- commit
- 37bf1d0
- parent
- 415465b
- author
- codex@macbookpro
- date
- 2026-04-01 11:28:00 +0800 CST
feat: add automation arbitration core
22 files changed,
+1619,
-100
+103,
-36
1@@ -26,9 +26,14 @@ import type {
2 BrowserBridgeStateSnapshot
3 } from "./browser-types.js";
4 import type { BaaLiveInstructionIngest } from "./instructions/ingest.js";
5+import { extractBaaInstructionBlocks } from "./instructions/extract.js";
6 import { buildSystemStateData, setAutomationMode } from "./local-api.js";
7 import type { ConductorRuntimeSnapshot } from "./index.js";
8-import { observeRenewalConversation } from "./renewal/conversations.js";
9+import { recordAssistantMessageAutomationSignal } from "./renewal/automation.js";
10+import {
11+ observeRenewalConversation,
12+ type ObserveRenewalConversationResult
13+} from "./renewal/conversations.js";
14
15 const FIREFOX_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
16 const FIREFOX_WS_PROTOCOL = "baa.firefox.local";
17@@ -256,6 +261,14 @@ function readStringArray(
18 return [...values].sort((left, right) => left.localeCompare(right));
19 }
20
21+function hasExtractedBaaInstructionBlocks(text: string): boolean {
22+ try {
23+ return extractBaaInstructionBlocks(text).length > 0;
24+ } catch {
25+ return true;
26+ }
27+}
28+
29 function readEndpointMetadataArray(
30 input: Record<string, unknown>,
31 key: string
32@@ -1661,7 +1674,20 @@ export class ConductorFirefoxWebSocketServer {
33
34 connection.addFinalMessage(finalMessage);
35 this.deliveryBridge.observeRoute(route);
36- await this.observeRenewalConversation(connection, finalMessage, route);
37+ let renewalObservation = await this.observeRenewalConversation(connection, finalMessage, route);
38+
39+ if (this.artifactStore != null && renewalObservation?.conversation != null) {
40+ renewalObservation = {
41+ ...renewalObservation,
42+ conversation: await recordAssistantMessageAutomationSignal({
43+ conversation: renewalObservation.conversation,
44+ observedAt: finalMessage.observed_at,
45+ rawText: finalMessage.raw_text,
46+ store: this.artifactStore
47+ })
48+ };
49+ }
50+
51 await this.broadcastStateSnapshot("browser.final_message");
52
53 this.writeIngestLog({
54@@ -1678,45 +1704,85 @@ export class ConductorFirefoxWebSocketServer {
55 return;
56 }
57
58- const ingestResult = await this.instructionIngest.ingestAssistantFinalMessage({
59- assistantMessageId: finalMessage.assistant_message_id,
60- conversationId: finalMessage.conversation_id,
61- observedAt: finalMessage.observed_at,
62- organizationId: finalMessage.organization_id,
63- pageTitle: finalMessage.page_title,
64- pageUrl: finalMessage.page_url,
65- platform: finalMessage.platform,
66- source: "browser.final_message",
67- text: finalMessage.raw_text
68- });
69- await this.broadcastStateSnapshot("instruction_ingest");
70-
71- this.writeIngestLog({
72- ts: new Date().toISOString(),
73- event: "ingest_completed",
74- platform: finalMessage.platform,
75- conversation_id: finalMessage.conversation_id ?? null,
76- blocks_count: ingestResult.summary.block_count,
77- executions_count: ingestResult.summary.execution_count,
78- status: ingestResult.summary.status
79- });
80+ const localConversationId = renewalObservation?.conversation.localConversationId ?? null;
81+ const hasInstructionBlocks = hasExtractedBaaInstructionBlocks(finalMessage.raw_text);
82+ let executionGateReason: "automation_busy" | null = null;
83+ let instructionLockAcquired = false;
84+
85+ if (
86+ hasInstructionBlocks
87+ && this.artifactStore != null
88+ && localConversationId != null
89+ ) {
90+ const lockedConversation = await this.artifactStore.tryBeginLocalConversationExecution({
91+ executionState: "instruction_running",
92+ localConversationId,
93+ updatedAt: finalMessage.observed_at
94+ });
95
96- if (ingestResult.processResult == null) {
97- return;
98+ if (lockedConversation == null) {
99+ executionGateReason = "automation_busy";
100+ } else {
101+ instructionLockAcquired = true;
102+ renewalObservation = {
103+ ...renewalObservation!,
104+ conversation: lockedConversation
105+ };
106+ }
107 }
108
109 try {
110- await this.deliveryBridge.deliver({
111+ const ingestResult = await this.instructionIngest.ingestAssistantFinalMessage({
112 assistantMessageId: finalMessage.assistant_message_id,
113- clientId: connection.getClientId(),
114- connectionId: connection.getConnectionId(),
115+ conversationAutomationStatus: renewalObservation?.conversation.automationStatus ?? null,
116 conversationId: finalMessage.conversation_id,
117+ executionGateReason,
118+ localConversationId,
119+ observedAt: finalMessage.observed_at,
120+ organizationId: finalMessage.organization_id,
121+ pageTitle: finalMessage.page_title,
122+ pageUrl: finalMessage.page_url,
123 platform: finalMessage.platform,
124- processResult: ingestResult.processResult,
125- route
126+ source: "browser.final_message",
127+ text: finalMessage.raw_text
128 });
129- } catch {
130- // delivery session state is already written back into browser snapshots
131+ await this.broadcastStateSnapshot("instruction_ingest");
132+
133+ this.writeIngestLog({
134+ ts: new Date().toISOString(),
135+ event: "ingest_completed",
136+ platform: finalMessage.platform,
137+ conversation_id: finalMessage.conversation_id ?? null,
138+ blocks_count: ingestResult.summary.block_count,
139+ executions_count: ingestResult.summary.execution_count,
140+ status: ingestResult.summary.status
141+ });
142+
143+ if (ingestResult.processResult == null) {
144+ return;
145+ }
146+
147+ try {
148+ await this.deliveryBridge.deliver({
149+ assistantMessageId: finalMessage.assistant_message_id,
150+ clientId: connection.getClientId(),
151+ connectionId: connection.getConnectionId(),
152+ conversationId: finalMessage.conversation_id,
153+ platform: finalMessage.platform,
154+ processResult: ingestResult.processResult,
155+ route
156+ });
157+ } catch {
158+ // delivery session state is already written back into browser snapshots
159+ }
160+ } finally {
161+ if (instructionLockAcquired && this.artifactStore != null && localConversationId != null) {
162+ await this.artifactStore.finishLocalConversationExecution({
163+ executionState: "instruction_running",
164+ localConversationId,
165+ updatedAt: this.getNowMilliseconds()
166+ });
167+ }
168 }
169 }
170
171@@ -1745,13 +1811,13 @@ export class ConductorFirefoxWebSocketServer {
172 platform: string;
173 },
174 route: BaaDeliveryRouteSnapshot | null
175- ): Promise<void> {
176+ ): Promise<ObserveRenewalConversationResult | null> {
177 if (this.artifactStore == null) {
178- return;
179+ return null;
180 }
181
182 try {
183- await observeRenewalConversation({
184+ return observeRenewalConversation({
185 assistantMessageId: finalMessage.assistant_message_id,
186 clientId: connection.getClientId(),
187 observedAt: finalMessage.observed_at,
188@@ -1764,6 +1830,7 @@ export class ConductorFirefoxWebSocketServer {
189 });
190 } catch (error) {
191 console.error(`[baa-renewal] observe conversation failed: ${String(error)}`);
192+ return null;
193 }
194 }
195
+328,
-1
1@@ -40,6 +40,9 @@ import {
2 parseBaaInstructionBlock,
3 parseConductorCliRequest,
4 routeBaaInstruction,
5+ recordAssistantMessageAutomationSignal,
6+ recordAutomationFailureSignal,
7+ recordRenewalPayloadSignal,
8 shouldRenew,
9 writeHttpResponse
10 } from "../dist/index.js";
11@@ -3548,7 +3551,17 @@ test("shouldRenew keeps route_unavailable while exposing structured route failur
12 })
13 },
14 message: {
15- id: "msg_route_detail"
16+ conversationId: "conv_route_detail",
17+ id: "msg_route_detail",
18+ observedAt: nowMs - 1_000,
19+ organizationId: null,
20+ pageTitle: null,
21+ pageUrl: null,
22+ platform: "claude",
23+ rawText: "plain renewal candidate",
24+ role: "assistant",
25+ staticPath: "msg/msg_route_detail.txt",
26+ summary: null
27 }
28 };
29
30@@ -3595,6 +3608,7 @@ test("shouldRenew keeps route_unavailable while exposing structured route failur
31 ...testCase.link
32 },
33 message: {
34+ ...baseCandidate.message,
35 id: `${baseCandidate.message.id}_${index}`
36 }
37 },
38@@ -3612,6 +3626,139 @@ test("shouldRenew keeps route_unavailable while exposing structured route failur
39 }
40 });
41
42+test("shouldRenew skips assistant messages that contain baa instruction blocks", async () => {
43+ const decision = await shouldRenew({
44+ candidate: {
45+ conversation: {
46+ automationStatus: "auto",
47+ cooldownUntil: null
48+ },
49+ link: {
50+ isActive: true,
51+ targetId: "tab:42",
52+ targetKind: "browser.proxy_delivery",
53+ targetPayload: JSON.stringify({
54+ clientId: "firefox-instruction-message",
55+ tabId: 42
56+ })
57+ },
58+ message: {
59+ conversationId: "conv_instruction_message",
60+ id: "msg_instruction_message",
61+ observedAt: Date.UTC(2026, 2, 30, 10, 6, 0),
62+ organizationId: null,
63+ pageTitle: null,
64+ pageUrl: null,
65+ platform: "claude",
66+ rawText: "```baa\n@conductor::describe\n```",
67+ role: "assistant",
68+ staticPath: "msg/msg_instruction_message.txt",
69+ summary: null
70+ }
71+ },
72+ now: Date.UTC(2026, 2, 30, 10, 6, 30),
73+ store: {
74+ async listRenewalJobs() {
75+ assert.fail("instruction_message branches should short-circuit before duplicate-job lookup");
76+ }
77+ }
78+ });
79+
80+ assert.equal(decision.eligible, false);
81+ assert.equal(decision.reason, "instruction_message");
82+});
83+
84+test("automation signal helpers pause repeated messages, repeated renewals, and consecutive failures", async () => {
85+ const rootDir = mkdtempSync(join(tmpdir(), "baa-automation-signal-helpers-"));
86+ const artifactStore = new ArtifactStore({
87+ artifactDir: join(rootDir, ARTIFACTS_DIRNAME),
88+ databasePath: join(rootDir, ARTIFACT_DB_FILENAME)
89+ });
90+ const baseConversation = await artifactStore.upsertLocalConversation({
91+ automationStatus: "auto",
92+ localConversationId: "lc_signal_helpers",
93+ platform: "claude",
94+ updatedAt: Date.UTC(2026, 2, 30, 10, 10, 0)
95+ });
96+
97+ try {
98+ let conversation = baseConversation;
99+
100+ for (let index = 0; index < 3; index += 1) {
101+ conversation = await recordAssistantMessageAutomationSignal({
102+ conversation,
103+ observedAt: Date.UTC(2026, 2, 30, 10, 10, index),
104+ rawText: "same assistant final message",
105+ store: artifactStore
106+ });
107+ }
108+
109+ assert.equal(conversation.automationStatus, "paused");
110+ assert.equal(conversation.pauseReason, "repeated_message");
111+
112+ conversation = await artifactStore.upsertLocalConversation({
113+ automationStatus: "auto",
114+ consecutiveFailureCount: 0,
115+ lastError: null,
116+ lastMessageFingerprint: null,
117+ lastRenewalFingerprint: null,
118+ localConversationId: conversation.localConversationId,
119+ pauseReason: null,
120+ pausedAt: null,
121+ platform: conversation.platform,
122+ repeatedMessageCount: 0,
123+ repeatedRenewalCount: 0,
124+ updatedAt: Date.UTC(2026, 2, 30, 10, 11, 0)
125+ });
126+
127+ for (let index = 0; index < 3; index += 1) {
128+ conversation = await recordRenewalPayloadSignal({
129+ conversation,
130+ observedAt: Date.UTC(2026, 2, 30, 10, 11, index),
131+ payloadText: "[renewal] repeated payload",
132+ store: artifactStore
133+ });
134+ }
135+
136+ assert.equal(conversation.automationStatus, "paused");
137+ assert.equal(conversation.pauseReason, "repeated_renewal");
138+
139+ conversation = await artifactStore.upsertLocalConversation({
140+ automationStatus: "auto",
141+ consecutiveFailureCount: 0,
142+ lastError: null,
143+ lastMessageFingerprint: null,
144+ lastRenewalFingerprint: null,
145+ localConversationId: conversation.localConversationId,
146+ pauseReason: null,
147+ pausedAt: null,
148+ platform: conversation.platform,
149+ repeatedMessageCount: 0,
150+ repeatedRenewalCount: 0,
151+ updatedAt: Date.UTC(2026, 2, 30, 10, 12, 0)
152+ });
153+
154+ for (let index = 0; index < 3; index += 1) {
155+ conversation = await recordAutomationFailureSignal({
156+ conversation,
157+ errorMessage: "browser_action_timeout",
158+ observedAt: Date.UTC(2026, 2, 30, 10, 12, index),
159+ store: artifactStore
160+ });
161+ }
162+
163+ assert.equal(conversation.automationStatus, "paused");
164+ assert.equal(conversation.pauseReason, "execution_failure");
165+ assert.equal(conversation.lastError, "browser_action_timeout");
166+ } finally {
167+ artifactStore.close();
168+ rmSync(rootDir, {
169+ force: true,
170+ recursive: true
171+ });
172+ }
173+});
174+
175 test("renewal dispatcher sends due pending jobs through browser.proxy_delivery and marks them done", async () => {
176 const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-success-"));
177 const stateDir = join(rootDir, "state");
178@@ -4392,6 +4539,95 @@ test("renewal dispatcher defers paused jobs and retries transient proxy failures
179 }
180 });
181
182+test("renewal dispatcher defers jobs when the local conversation execution lock is busy", async () => {
183+ const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-busy-"));
184+ const artifactStore = new ArtifactStore({
185+ artifactDir: join(rootDir, ARTIFACTS_DIRNAME),
186+ databasePath: join(rootDir, ARTIFACT_DB_FILENAME)
187+ });
188+ const nowMs = Date.UTC(2026, 2, 30, 13, 30, 0);
189+ let browserCalls = 0;
190+ const runner = createRenewalDispatcherRunner({
191+ browserBridge: {
192+ proxyDelivery() {
193+ browserCalls += 1;
194+ throw new Error("proxyDelivery should not run while the instruction lock is held");
195+ }
196+ },
197+ now: () => nowMs
198+ });
199+
200+ try {
201+ await artifactStore.insertMessage({
202+ conversationId: "conv_dispatch_busy",
203+ id: "msg_dispatch_busy",
204+ observedAt: nowMs - 60_000,
205+ platform: "claude",
206+ rawText: "renewal dispatcher busy message",
207+ role: "assistant"
208+ });
209+ await artifactStore.upsertLocalConversation({
210+ automationStatus: "auto",
211+ executionState: "instruction_running",
212+ localConversationId: "lc_dispatch_busy",
213+ platform: "claude",
214+ updatedAt: nowMs - 1_000
215+ });
216+ await artifactStore.upsertConversationLink({
217+ clientId: "firefox-claude",
218+ linkId: "link_dispatch_busy",
219+ localConversationId: "lc_dispatch_busy",
220+ observedAt: nowMs - 20_000,
221+ pageTitle: "Busy Renewal",
222+ pageUrl: "https://claude.ai/chat/conv_dispatch_busy",
223+ platform: "claude",
224+ remoteConversationId: "conv_dispatch_busy",
225+ routeParams: {
226+ conversationId: "conv_dispatch_busy"
227+ },
228+ routePath: "/chat/conv_dispatch_busy",
229+ routePattern: "/chat/:conversationId",
230+ targetId: "tab:14",
231+ targetKind: "browser.proxy_delivery",
232+ targetPayload: {
233+ clientId: "firefox-claude",
234+ conversationId: "conv_dispatch_busy",
235+ pageUrl: "https://claude.ai/chat/conv_dispatch_busy",
236+ tabId: 14
237+ }
238+ });
239+ await artifactStore.insertRenewalJob({
240+ jobId: "job_dispatch_busy",
241+ localConversationId: "lc_dispatch_busy",
242+ messageId: "msg_dispatch_busy",
243+ nextAttemptAt: nowMs,
244+ payload: "[renewal] busy",
245+ payloadKind: "text"
246+ });
247+
248+ const busyContext = createTimedJobRunnerContext({
249+ artifactStore
250+ });
251+ const result = await runner.run(busyContext.context);
252+ const deferredJob = await artifactStore.getRenewalJob("job_dispatch_busy");
253+
254+ assert.equal(result.result, "ok");
255+ assert.equal(browserCalls, 0);
256+ assert.equal(deferredJob.status, "pending");
257+ assert.equal(deferredJob.attemptCount, 0);
258+ assert.equal(deferredJob.nextAttemptAt, nowMs + 10_000);
259+ assert.ok(
260+ busyContext.entries.find((entry) => entry.stage === "job_deferred" && entry.result === "automation_busy")
261+ );
262+ } finally {
263+ artifactStore.close();
264+ rmSync(rootDir, {
265+ force: true,
266+ recursive: true
267+ });
268+ }
269+});
270+
271 test("renewal dispatcher records timeout failures with a distinct timeout result and timeout_ms", async () => {
272 const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-timeout-"));
273 const stateDir = join(rootDir, "state");
274@@ -8195,6 +8431,97 @@ test("ConductorRuntime persists renewal conversation links from browser.final_me
275 }
276 });
277
278+test("ConductorRuntime gives control instructions priority over ordinary baa instructions and persists pause_reason", async () => {
279+ const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-control-priority-"));
280+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-control-priority-host-"));
281+ const blockedOutputPath = join(hostOpsDir, "control-should-not-write.txt");
282+ const runtime = new ConductorRuntime(
283+ {
284+ nodeId: "mini-main",
285+ host: "mini",
286+ role: "primary",
287+ controlApiBase: "https://control.example.test",
288+ localApiBase: "http://127.0.0.1:0",
289+ sharedToken: "replace-me",
290+ paths: {
291+ runsDir: "/tmp/runs",
292+ stateDir
293+ }
294+ },
295+ {
296+ autoStartLoops: false,
297+ now: () => 400
298+ }
299+ );
300+
301+ let client = null;
302+
303+ try {
304+ const snapshot = await runtime.start();
305+ const baseUrl = snapshot.controlApi.localApiBase;
306+ client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-control-priority");
307+
308+ client.socket.send(
309+ JSON.stringify({
310+ type: "browser.final_message",
311+ platform: "chatgpt",
312+ conversation_id: "conv-control-priority",
313+ assistant_message_id: "msg-control-priority-1",
314+ raw_text: [
315+ "```baa",
316+ '@conductor::conversation/pause::{"scope":"current","reason":"rescue_wait"}',
317+ "```",
318+ "```baa",
319+ `@conductor::files/write::${JSON.stringify({
320+ path: "control-should-not-write.txt",
321+ cwd: hostOpsDir,
322+ content: "should not exist",
323+ overwrite: true
324+ })}`,
325+ "```"
326+ ].join("\n"),
327+ observed_at: 1_710_000_040_000,
328+ page_title: "ChatGPT Control Priority",
329+ page_url: "https://chatgpt.com/c/conv-control-priority",
330+ tab_id: 77
331+ })
332+ );
333+
334+ const localConversationId = await waitForCondition(async () => {
335+ const linksResponse = await fetch(
336+ `${baseUrl}/v1/renewal/links?platform=chatgpt&remote_conversation_id=conv-control-priority`
337+ );
338+ assert.equal(linksResponse.status, 200);
339+ const payload = await linksResponse.json();
340+ assert.equal(payload.data.count, 1);
341+ return payload.data.links[0].local_conversation_id;
342+ }, 5_000, 50);
343+
344+ const conversationPayload = await waitForCondition(async () => {
345+ const conversationResponse = await fetch(`${baseUrl}/v1/renewal/conversations/${localConversationId}`);
346+ assert.equal(conversationResponse.status, 200);
347+ const payload = await conversationResponse.json();
348+ assert.equal(payload.data.automation_status, "paused");
349+ assert.equal(payload.data.pause_reason, "rescue_wait");
350+ return payload;
351+ }, 5_000, 50);
352+
353+ assert.equal(existsSync(blockedOutputPath), false);
354+ } finally {
355+ client?.queue.stop();
356+ client?.socket.close(1000, "done");
357+ await runtime.stop();
358+ rmSync(stateDir, {
359+ force: true,
360+ recursive: true
361+ });
362+ rmSync(hostOpsDir, {
363+ force: true,
364+ recursive: true
365+ });
366+ }
367+});
368+
369 test("ConductorRuntime exposes renewal jobs APIs and registers the renewal dispatcher runner", async () => {
370 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-jobs-api-"));
371 const runtime = new ConductorRuntime(
+1,
-0
1@@ -74,6 +74,7 @@ export {
2 export { handleConductorHttpRequest } from "./local-api.js";
3 export * from "./artifacts/index.js";
4 export * from "./instructions/index.js";
5+export * from "./renewal/automation.js";
6 export * from "./renewal/dispatcher.js";
7 export * from "./renewal/projector.js";
8 export * from "./timed-jobs/index.js";
1@@ -1,5 +1,8 @@
2 import { createHash } from "node:crypto";
3-import type { ArtifactStore } from "../../../../packages/artifact-db/dist/index.js";
4+import type {
5+ ArtifactStore,
6+ ConversationAutomationStatus
7+} from "../../../../packages/artifact-db/dist/index.js";
8
9 import type { ConductorLocalApiContext } from "../local-api.js";
10
11@@ -17,6 +20,8 @@ import type {
12 import { stableStringifyBaaJson } from "./types.js";
13
14 export type BaaLiveInstructionIngestStatus =
15+ | "automation_busy"
16+ | "automation_paused"
17 | "denied_only"
18 | "duplicate_message"
19 | "duplicate_only"
20@@ -28,6 +33,9 @@ export type BaaLiveInstructionIngestStatus =
21 export interface BaaLiveInstructionIngestInput {
22 assistantMessageId: string;
23 conversationId?: string | null;
24+ conversationAutomationStatus?: ConversationAutomationStatus | null;
25+ executionGateReason?: "automation_busy" | null;
26+ localConversationId?: string | null;
27 observedAt?: number | null;
28 organizationId?: string | null;
29 pageTitle?: string | null;
30@@ -160,6 +168,10 @@ function buildInstructionDescriptor(target: string, tool: string): string {
31
32 function classifyProcessStatus(status: BaaInstructionProcessStatus): BaaLiveInstructionIngestStatus {
33 switch (status) {
34+ case "automation_busy":
35+ return "automation_busy";
36+ case "automation_paused":
37+ return "automation_paused";
38 case "denied_only":
39 return "denied_only";
40 case "duplicate_only":
41@@ -175,7 +187,9 @@ function classifyProcessStatus(status: BaaInstructionProcessStatus): BaaLiveInst
42
43 function shouldUpdateExecutionSummary(status: BaaLiveInstructionIngestStatus): boolean {
44 return (
45- status === "denied_only"
46+ status === "automation_busy"
47+ || status === "automation_paused"
48+ || status === "denied_only"
49 || status === "duplicate_only"
50 || status === "executed"
51 || status === "failed"
52@@ -350,6 +364,10 @@ export class BaaLiveInstructionIngest {
53 conversationId: normalizeOptionalString(input.conversationId),
54 platform: input.platform,
55 text: input.text
56+ }, {
57+ conversationAutomationStatus: input.conversationAutomationStatus ?? null,
58+ executionGateReason: input.executionGateReason ?? null,
59+ localConversationId: normalizeOptionalString(input.localConversationId)
60 });
61 const summary = this.buildSuccessSummary(baseSummary, processResult);
62
1@@ -1,4 +1,10 @@
2+import type { ConversationAutomationStatus } from "../../../../packages/artifact-db/dist/index.js";
3+
4 import type { ConductorLocalApiContext } from "../local-api.js";
5+import {
6+ recordAutomationFailureSignal,
7+ recordAutomationSuccessSignal
8+} from "../renewal/automation.js";
9
10 import { InMemoryBaaInstructionDeduper, type BaaInstructionDeduper } from "./dedupe.js";
11 import { executeBaaInstruction } from "./executor.js";
12@@ -9,7 +15,7 @@ import {
13 } from "./normalize.js";
14 import { parseBaaInstructionBlock } from "./parse.js";
15 import { evaluateBaaInstructionPolicy } from "./policy.js";
16-import { routeBaaInstruction } from "./router.js";
17+import { isAutomationControlInstruction, routeBaaInstruction } from "./router.js";
18 import type {
19 BaaAssistantMessageInput,
20 BaaInstructionDeniedResult,
21@@ -37,6 +43,12 @@ export interface BaaInstructionCenterOptions {
22 localApiContext: ConductorLocalApiContext;
23 }
24
25+export interface BaaInstructionProcessOptions {
26+ conversationAutomationStatus?: ConversationAutomationStatus | null;
27+ executionGateReason?: "automation_busy" | null;
28+ localConversationId?: string | null;
29+}
30+
31 export class BaaInstructionCenter {
32 private readonly deduper: BaaInstructionDeduper;
33 private readonly localApiContext: ConductorLocalApiContext;
34@@ -47,7 +59,8 @@ export class BaaInstructionCenter {
35 }
36
37 async processAssistantMessage(
38- input: BaaAssistantMessageInput
39+ input: BaaAssistantMessageInput,
40+ options: BaaInstructionProcessOptions = {}
41 ): Promise<BaaInstructionProcessResult> {
42 const blocks = this.extract(input.text);
43
44@@ -67,6 +80,8 @@ export class BaaInstructionCenter {
45 instructions,
46 parseErrors
47 } = this.normalize(input, blocks);
48+ const controlInstructions = instructions.filter((instruction) => isAutomationControlInstruction(instruction));
49+ const selectedInstructions = controlInstructions.length > 0 ? controlInstructions : instructions;
50
51 if (instructions.length === 0) {
52 return {
53@@ -80,10 +95,34 @@ export class BaaInstructionCenter {
54 };
55 }
56
57+ if (options.executionGateReason != null) {
58+ return {
59+ blocks,
60+ denied: [],
61+ duplicates: [],
62+ executions: [],
63+ instructions: selectedInstructions,
64+ parseErrors,
65+ status: options.executionGateReason
66+ };
67+ }
68+
69+ if ((options.conversationAutomationStatus ?? null) === "paused" && controlInstructions.length === 0) {
70+ return {
71+ blocks,
72+ denied: [],
73+ duplicates: [],
74+ executions: [],
75+ instructions: selectedInstructions,
76+ parseErrors,
77+ status: "automation_paused"
78+ };
79+ }
80+
81 const duplicates: BaaInstructionEnvelope[] = [];
82 const pending: BaaInstructionEnvelope[] = [];
83
84- for (const instruction of instructions) {
85+ for (const instruction of selectedInstructions) {
86 if (await this.deduper.has(instruction.dedupeKey)) {
87 duplicates.push(instruction);
88 continue;
89@@ -98,7 +137,7 @@ export class BaaInstructionCenter {
90 denied: [],
91 duplicates,
92 executions: [],
93- instructions,
94+ instructions: selectedInstructions,
95 parseErrors,
96 status: "duplicate_only"
97 };
98@@ -110,23 +149,59 @@ export class BaaInstructionCenter {
99 await this.deduper.add(instruction);
100 }
101
102- const executions = await Promise.all(
103- routed.map(({ instruction, route }) =>
104- executeBaaInstruction(instruction, route, this.localApiContext)
105- )
106- );
107+ const executions: BaaInstructionProcessResult["executions"] = [];
108+
109+ for (const { instruction, route } of routed) {
110+ executions.push(await executeBaaInstruction(instruction, route, this.localApiContext));
111+ }
112+
113+ await this.recordAutomationOutcome(options.localConversationId ?? null, executions);
114
115 return {
116 blocks,
117 denied,
118 duplicates,
119 executions,
120- instructions,
121+ instructions: selectedInstructions,
122 parseErrors,
123 status: executions.length === 0 && denied.length > 0 ? "denied_only" : "executed"
124 };
125 }
126
127+ private async recordAutomationOutcome(
128+ localConversationId: string | null,
129+ executions: BaaInstructionProcessResult["executions"]
130+ ): Promise<void> {
131+ if (localConversationId == null || executions.length === 0 || this.localApiContext.artifactStore == null) {
132+ return;
133+ }
134+
135+ const conversation = await this.localApiContext.artifactStore.getLocalConversation(localConversationId);
136+
137+ if (conversation == null) {
138+ return;
139+ }
140+
141+ const failedExecution = executions.find((execution) => execution.ok === false);
142+ const observedAt = this.localApiContext.now?.() ?? Date.now();
143+
144+ if (failedExecution != null) {
145+ await recordAutomationFailureSignal({
146+ conversation,
147+ errorMessage: failedExecution.error ?? failedExecution.message ?? "instruction_execution_failed",
148+ observedAt,
149+ store: this.localApiContext.artifactStore
150+ });
151+ return;
152+ }
153+
154+ await recordAutomationSuccessSignal({
155+ conversation,
156+ observedAt,
157+ store: this.localApiContext.artifactStore
158+ });
159+ }
160+
161 private extract(text: string) {
162 try {
163 return extractBaaInstructionBlocks(text);
1@@ -1,13 +1,18 @@
2 import type { BaaInstructionEnvelope } from "./types.js";
3
4 const CONDUCTOR_TOOLS = new Set([
5+ "conversation/mode",
6+ "conversation/pause",
7+ "conversation/resume",
8 "describe",
9 "describe/business",
10 "describe/control",
11 "exec",
12 "files/read",
13 "files/write",
14- "status"
15+ "status",
16+ "system/pause",
17+ "system/resume"
18 ]);
19 const BROWSER_LEGACY_TARGET_TOOLS = new Set([
20 "current",
1@@ -24,6 +24,8 @@ export class BaaInstructionRouteError extends Error {
2 }
3 }
4
5+const INTERNAL_CONVERSATION_AUTOMATION_CONTROL_PATH = "/v1/internal/automation/conversations/control";
6+
7 function requireNonEmptyStringParam(
8 instruction: BaaInstructionEnvelope,
9 label: string,
10@@ -191,6 +193,95 @@ function normalizeBrowserSendBody(instruction: BaaInstructionEnvelope): BaaJsonO
11 };
12 }
13
14+function normalizeConversationControlScope(
15+ instruction: BaaInstructionEnvelope,
16+ params: BaaJsonObject | null
17+): { scope: "current" } {
18+ const scope = params?.scope;
19+
20+ if (scope == null) {
21+ return {
22+ scope: "current"
23+ };
24+ }
25+
26+ if (scope !== "current") {
27+ throw new BaaInstructionRouteError(
28+ instruction.blockIndex,
29+ `${instruction.target}::${instruction.tool} only supports scope:"current".`
30+ );
31+ }
32+
33+ return {
34+ scope
35+ };
36+}
37+
38+function buildCurrentConversationControlBody(
39+ instruction: BaaInstructionEnvelope,
40+ input: {
41+ action: "mode" | "pause" | "resume";
42+ defaultMode?: "auto" | "manual" | "paused";
43+ defaultReason?: string | null;
44+ requireMode?: boolean;
45+ }
46+): BaaJsonObject {
47+ const params = instruction.paramsKind === "none" ? null : requireJsonObjectParams(instruction);
48+ const { scope } = normalizeConversationControlScope(instruction, params);
49+ const modeValue = params?.mode;
50+ const resolvedMode =
51+ input.requireMode === true
52+ ? modeValue
53+ : (modeValue ?? input.defaultMode ?? null);
54+
55+ if (
56+ resolvedMode != null
57+ && resolvedMode !== "auto"
58+ && resolvedMode !== "manual"
59+ && resolvedMode !== "paused"
60+ ) {
61+ throw new BaaInstructionRouteError(
62+ instruction.blockIndex,
63+ `${instruction.target}::${instruction.tool} mode must be one of auto, manual, paused.`
64+ );
65+ }
66+
67+ const reasonValue = params?.reason;
68+
69+ if (reasonValue != null && typeof reasonValue !== "string") {
70+ throw new BaaInstructionRouteError(
71+ instruction.blockIndex,
72+ `${instruction.target}::${instruction.tool} reason must be a string when provided.`
73+ );
74+ }
75+
76+ return {
77+ action: input.action,
78+ assistant_message_id: instruction.assistantMessageId,
79+ mode: resolvedMode as BaaJsonObject["mode"],
80+ platform: instruction.platform,
81+ reason: (reasonValue ?? input.defaultReason ?? null) as BaaJsonObject["reason"],
82+ scope,
83+ source_conversation_id: instruction.conversationId
84+ };
85+}
86+
87+export function isAutomationControlInstruction(
88+ instruction: Pick<BaaInstructionEnvelope, "target" | "tool">
89+): boolean {
90+ if (instruction.target !== "conductor" && instruction.target !== "system") {
91+ return false;
92+ }
93+
94+ return [
95+ "conversation/mode",
96+ "conversation/pause",
97+ "conversation/resume",
98+ "system/pause",
99+ "system/resume"
100+ ].includes(instruction.tool);
101+}
102+
103 function routeLocalInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
104 switch (instruction.tool) {
105 case "describe":
106@@ -229,6 +320,56 @@ function routeLocalInstruction(instruction: BaaInstructionEnvelope): BaaInstruct
107 path: "/v1/status",
108 requiresSharedToken: false
109 });
110+ case "system/pause":
111+ requireNoParams(instruction);
112+ return withRouteTimeout({
113+ body: null,
114+ key: "local.system.pause",
115+ method: "POST",
116+ path: "/v1/system/pause",
117+ requiresSharedToken: false
118+ });
119+ case "system/resume":
120+ requireNoParams(instruction);
121+ return withRouteTimeout({
122+ body: null,
123+ key: "local.system.resume",
124+ method: "POST",
125+ path: "/v1/system/resume",
126+ requiresSharedToken: false
127+ });
128+ case "conversation/pause":
129+ return withRouteTimeout({
130+ body: buildCurrentConversationControlBody(instruction, {
131+ action: "pause",
132+ defaultReason: "ai_pause"
133+ }),
134+ key: "local.conversation.pause",
135+ method: "POST",
136+ path: INTERNAL_CONVERSATION_AUTOMATION_CONTROL_PATH,
137+ requiresSharedToken: false
138+ });
139+ case "conversation/resume":
140+ return withRouteTimeout({
141+ body: buildCurrentConversationControlBody(instruction, {
142+ action: "resume"
143+ }),
144+ key: "local.conversation.resume",
145+ method: "POST",
146+ path: INTERNAL_CONVERSATION_AUTOMATION_CONTROL_PATH,
147+ requiresSharedToken: false
148+ });
149+ case "conversation/mode":
150+ return withRouteTimeout({
151+ body: buildCurrentConversationControlBody(instruction, {
152+ action: "mode",
153+ requireMode: true
154+ }),
155+ key: "local.conversation.mode",
156+ method: "POST",
157+ path: INTERNAL_CONVERSATION_AUTOMATION_CONTROL_PATH,
158+ requiresSharedToken: false
159+ });
160 case "exec":
161 return withRouteTimeout({
162 body: normalizeExecBody(instruction),
1@@ -7,6 +7,8 @@ export interface BaaJsonObject {
2 export type BaaInstructionParams = BaaJsonValue;
3 export type BaaInstructionParamsKind = "body" | "inline_json" | "inline_string" | "none";
4 export type BaaInstructionProcessStatus =
5+ | "automation_busy"
6+ | "automation_paused"
7 | "denied_only"
8 | "duplicate_only"
9 | "executed"
+144,
-0
1@@ -7,6 +7,7 @@ import {
2 getArtifactContentType,
3 type ArtifactStore,
4 type ConversationAutomationStatus,
5+ type ConversationPauseReason,
6 type RenewalJobRecord,
7 type RenewalJobStatus
8 } from "../../../packages/artifact-db/dist/index.js";
9@@ -203,6 +204,16 @@ const MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS = 60_000;
10 const MAX_BROWSER_WS_RECONNECT_REPEAT_COUNT = 20;
11 const MAX_BROWSER_WS_RECONNECT_REPEAT_INTERVAL_MS = 60_000;
12 const RENEWAL_AUTOMATION_STATUS_SET = new Set<ConversationAutomationStatus>(["manual", "auto", "paused"]);
13+const RENEWAL_PAUSE_REASON_SET = new Set<ConversationPauseReason>([
14+ "ai_pause",
15+ "error_loop",
16+ "execution_failure",
17+ "repeated_message",
18+ "repeated_renewal",
19+ "rescue_wait",
20+ "system_pause",
21+ "user_pause"
22+]);
23 const RENEWAL_JOB_STATUS_SET = new Set<RenewalJobStatus>(["pending", "running", "done", "failed"]);
24
25 type LocalApiRouteMethod = "GET" | "POST";
26@@ -820,6 +831,14 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
27 method: "POST",
28 pathPattern: "/v1/renewal/conversations/:local_conversation_id/paused",
29 summary: "将本地对话自动化状态切到 paused"
30+ },
31+ {
32+ id: "automation.conversations.control",
33+ exposeInDescribe: false,
34+ kind: "write",
35+ method: "POST",
36+ pathPattern: "/v1/internal/automation/conversations/control",
37+ summary: "内部当前对话自动化控制入口"
38 }
39 ];
40
41@@ -1839,6 +1858,27 @@ function readOptionalRenewalAutomationStatusQuery(
42 return rawValue as ConversationAutomationStatus;
43 }
44
45+function normalizeRenewalPauseReason(
46+ value: string | undefined
47+): ConversationPauseReason | undefined {
48+ if (value == null) {
49+ return undefined;
50+ }
51+
52+ if (!RENEWAL_PAUSE_REASON_SET.has(value as ConversationPauseReason)) {
53+ throw new LocalApiHttpError(
54+ 400,
55+ "invalid_request",
56+ 'Field "pause_reason" must be one of user_pause, ai_pause, system_pause, rescue_wait, repeated_message, repeated_renewal, execution_failure, error_loop.',
57+ {
58+ field: "pause_reason"
59+ }
60+ );
61+ }
62+
63+ return value as ConversationPauseReason;
64+}
65+
66 function readOptionalRenewalJobStatusQuery(
67 url: URL,
68 ...fieldNames: string[]
69@@ -7244,6 +7284,13 @@ function buildRenewalConversationData(
70 local_conversation_id: detail.conversation.localConversationId,
71 platform: detail.conversation.platform,
72 automation_status: detail.conversation.automationStatus,
73+ last_non_paused_automation_status: detail.conversation.lastNonPausedAutomationStatus,
74+ pause_reason: detail.conversation.pauseReason ?? undefined,
75+ last_error: detail.conversation.lastError ?? undefined,
76+ execution_state: detail.conversation.executionState,
77+ consecutive_failure_count: detail.conversation.consecutiveFailureCount,
78+ repeated_message_count: detail.conversation.repeatedMessageCount,
79+ repeated_renewal_count: detail.conversation.repeatedRenewalCount,
80 title: detail.conversation.title ?? undefined,
81 summary: detail.conversation.summary ?? undefined,
82 last_message_id: detail.conversation.lastMessageId ?? undefined,
83@@ -7468,9 +7515,16 @@ async function handleRenewalConversationMutation(
84 }
85
86 try {
87+ const body = readBodyObject(context.request, true);
88 const detail = await setRenewalConversationAutomationStatus({
89 automationStatus,
90 localConversationId,
91+ pauseReason:
92+ automationStatus === "paused"
93+ ? normalizeRenewalPauseReason(
94+ readOptionalStringBodyField(body, "pause_reason", "reason")
95+ ) ?? "user_pause"
96+ : null,
97 observedAt: context.now() * 1000,
98 store
99 });
100@@ -7493,6 +7547,94 @@ async function handleRenewalConversationMutation(
101 }
102 }
103
104+async function handleCurrentConversationAutomationControl(
105+ context: LocalApiRequestContext
106+): Promise<ConductorHttpResponse> {
107+ const store = requireArtifactStore(context.artifactStore);
108+ const body = readBodyObject(context.request, false);
109+ const action = readOptionalStringField(body, "action");
110+ const scope = readOptionalStringField(body, "scope") ?? "current";
111+
112+ if (scope !== "current") {
113+ throw new LocalApiHttpError(400, "invalid_request", 'Field "scope" only supports "current".', {
114+ field: "scope"
115+ });
116+ }
117+
118+ if (action !== "pause" && action !== "resume" && action !== "mode") {
119+ throw new LocalApiHttpError(400, "invalid_request", 'Field "action" must be pause, resume, or mode.', {
120+ field: "action"
121+ });
122+ }
123+
124+ const assistantMessageId = readOptionalStringBodyField(body, "assistant_message_id", "assistantMessageId");
125+ const sourceConversationId = readOptionalStringBodyField(body, "source_conversation_id", "conversation_id");
126+ const platform = readOptionalStringField(body, "platform");
127+
128+ let conversation = assistantMessageId == null
129+ ? null
130+ : await store.findLocalConversationByLastMessageId(assistantMessageId);
131+
132+ if (conversation == null && platform != null && sourceConversationId != null) {
133+ const link = await store.findConversationLinkByRemoteConversation(platform, sourceConversationId);
134+ conversation = link == null ? null : await store.getLocalConversation(link.localConversationId);
135+ }
136+
137+ if (conversation == null) {
138+ throw new LocalApiHttpError(
139+ 404,
140+ "not_found",
141+ "Current local conversation could not be resolved from assistant_message_id / source_conversation_id.",
142+ compactJsonObject({
143+ assistant_message_id: assistantMessageId ?? undefined,
144+ platform: platform ?? undefined,
145+ source_conversation_id: sourceConversationId ?? undefined
146+ })
147+ );
148+ }
149+
150+ const mode = readOptionalStringField(body, "mode");
151+ let nextAutomationStatus: ConversationAutomationStatus;
152+
153+ switch (action) {
154+ case "pause":
155+ nextAutomationStatus = "paused";
156+ break;
157+ case "resume":
158+ nextAutomationStatus =
159+ conversation.lastNonPausedAutomationStatus === "paused"
160+ ? "auto"
161+ : conversation.lastNonPausedAutomationStatus;
162+ break;
163+ case "mode":
164+ if (mode !== "manual" && mode !== "auto" && mode !== "paused") {
165+ throw new LocalApiHttpError(
166+ 400,
167+ "invalid_request",
168+ 'Field "mode" must be manual, auto, or paused when action is "mode".',
169+ {
170+ field: "mode"
171+ }
172+ );
173+ }
174+ nextAutomationStatus = mode;
175+ break;
176+ }
177+
178+ const detail = await setRenewalConversationAutomationStatus({
179+ automationStatus: nextAutomationStatus,
180+ localConversationId: conversation.localConversationId,
181+ observedAt: context.now() * 1000,
182+ pauseReason:
183+ nextAutomationStatus === "paused"
184+ ? normalizeRenewalPauseReason(readOptionalStringBodyField(body, "reason", "pause_reason")) ?? "ai_pause"
185+ : null,
186+ store
187+ });
188+
189+ return buildSuccessEnvelope(context.requestId, 200, buildRenewalConversationData(detail, true));
190+}
191+
192 function tryParseJson(value: string | null): JsonValue | null {
193 if (value == null) {
194 return null;
195@@ -7648,6 +7790,8 @@ async function dispatchBusinessRoute(
196 return handleRenewalConversationMutation(context, "auto");
197 case "renewal.conversations.paused":
198 return handleRenewalConversationMutation(context, "paused");
199+ case "automation.conversations.control":
200+ return handleCurrentConversationAutomationControl(context);
201 default:
202 throw new LocalApiHttpError(404, "not_found", `No local route matches "${context.url.pathname}".`);
203 }
1@@ -0,0 +1,209 @@
2+import { createHash } from "node:crypto";
3+import type {
4+ ArtifactStore,
5+ ConversationAutomationStatus,
6+ ConversationPauseReason,
7+ LocalConversationRecord
8+} from "../../../../packages/artifact-db/dist/index.js";
9+
10+export const CONVERSATION_AUTOMATION_FAILURE_THRESHOLD = 3;
11+export const CONVERSATION_REPEATED_MESSAGE_THRESHOLD = 3;
12+export const CONVERSATION_REPEATED_RENEWAL_THRESHOLD = 3;
13+
14+export interface ConversationAutomationResetOptions {
15+ automationStatus: ConversationAutomationStatus;
16+}
17+
18+export function buildConversationAutomationResetPatch(
19+ options: ConversationAutomationResetOptions
20+): {
21+ automationStatus: ConversationAutomationStatus;
22+ consecutiveFailureCount: number;
23+ lastError: null;
24+ lastMessageFingerprint: null;
25+ lastRenewalFingerprint: null;
26+ pauseReason: null;
27+ pausedAt: null;
28+ repeatedMessageCount: number;
29+ repeatedRenewalCount: number;
30+} {
31+ return {
32+ automationStatus: options.automationStatus,
33+ consecutiveFailureCount: 0,
34+ lastError: null,
35+ lastMessageFingerprint: null,
36+ lastRenewalFingerprint: null,
37+ pauseReason: null,
38+ pausedAt: null,
39+ repeatedMessageCount: 0,
40+ repeatedRenewalCount: 0
41+ };
42+}
43+
44+export function buildAutomationTextFingerprint(text: string): string {
45+ return createHash("sha256")
46+ .update(normalizeAutomationText(text))
47+ .digest("hex");
48+}
49+
50+export async function pauseConversationAutomation(input: {
51+ conversation: LocalConversationRecord;
52+ force?: boolean;
53+ lastError?: string | null;
54+ observedAt: number;
55+ reason: ConversationPauseReason;
56+ store: Pick<ArtifactStore, "upsertLocalConversation">;
57+}): Promise<LocalConversationRecord> {
58+ const force = input.force === true;
59+ const existingPauseReason = input.conversation.pauseReason;
60+
61+ if (!force && input.conversation.automationStatus === "paused" && existingPauseReason != null) {
62+ return input.store.upsertLocalConversation({
63+ lastError: normalizeOptionalString(input.lastError) ?? input.conversation.lastError,
64+ localConversationId: input.conversation.localConversationId,
65+ platform: input.conversation.platform,
66+ updatedAt: input.observedAt
67+ });
68+ }
69+
70+ return input.store.upsertLocalConversation({
71+ automationStatus: "paused",
72+ lastError: normalizeOptionalString(input.lastError) ?? input.conversation.lastError,
73+ localConversationId: input.conversation.localConversationId,
74+ pauseReason: input.reason,
75+ pausedAt: input.observedAt,
76+ platform: input.conversation.platform,
77+ updatedAt: input.observedAt
78+ });
79+}
80+
81+export async function recordAssistantMessageAutomationSignal(input: {
82+ conversation: LocalConversationRecord;
83+ observedAt: number;
84+ rawText: string;
85+ store: Pick<ArtifactStore, "upsertLocalConversation">;
86+}): Promise<LocalConversationRecord> {
87+ const fingerprint = buildAutomationTextFingerprint(input.rawText);
88+ const repeatedMessageCount =
89+ input.conversation.lastMessageFingerprint === fingerprint
90+ ? input.conversation.repeatedMessageCount + 1
91+ : 1;
92+ const updatedConversation = await input.store.upsertLocalConversation({
93+ lastMessageFingerprint: fingerprint,
94+ localConversationId: input.conversation.localConversationId,
95+ platform: input.conversation.platform,
96+ repeatedMessageCount,
97+ updatedAt: input.observedAt
98+ });
99+
100+ if (repeatedMessageCount < CONVERSATION_REPEATED_MESSAGE_THRESHOLD) {
101+ return updatedConversation;
102+ }
103+
104+ return pauseConversationAutomation({
105+ conversation: updatedConversation,
106+ lastError: `repeated assistant message detected (${repeatedMessageCount}x)`,
107+ observedAt: input.observedAt,
108+ reason: "repeated_message",
109+ store: input.store
110+ });
111+}
112+
113+export async function recordAutomationFailureSignal(input: {
114+ conversation: LocalConversationRecord;
115+ errorMessage: string;
116+ observedAt: number;
117+ store: Pick<ArtifactStore, "upsertLocalConversation">;
118+}): Promise<LocalConversationRecord> {
119+ const lastError = normalizeOptionalString(input.errorMessage) ?? "automation_failure";
120+ const consecutiveFailureCount = input.conversation.consecutiveFailureCount + 1;
121+ const updatedConversation = await input.store.upsertLocalConversation({
122+ consecutiveFailureCount,
123+ lastError,
124+ localConversationId: input.conversation.localConversationId,
125+ platform: input.conversation.platform,
126+ updatedAt: input.observedAt
127+ });
128+
129+ if (consecutiveFailureCount < CONVERSATION_AUTOMATION_FAILURE_THRESHOLD) {
130+ return updatedConversation;
131+ }
132+
133+ return pauseConversationAutomation({
134+ conversation: updatedConversation,
135+ lastError,
136+ observedAt: input.observedAt,
137+ reason: "execution_failure",
138+ store: input.store
139+ });
140+}
141+
142+export async function recordAutomationSuccessSignal(input: {
143+ conversation: LocalConversationRecord;
144+ observedAt: number;
145+ store: Pick<ArtifactStore, "upsertLocalConversation">;
146+}): Promise<LocalConversationRecord> {
147+ if (input.conversation.consecutiveFailureCount === 0 && input.conversation.lastError == null) {
148+ return input.conversation;
149+ }
150+
151+ return input.store.upsertLocalConversation({
152+ consecutiveFailureCount: 0,
153+ lastError: null,
154+ localConversationId: input.conversation.localConversationId,
155+ platform: input.conversation.platform,
156+ updatedAt: input.observedAt
157+ });
158+}
159+
160+export async function recordRenewalPayloadSignal(input: {
161+ conversation: LocalConversationRecord;
162+ observedAt: number;
163+ payloadText: string;
164+ store: Pick<ArtifactStore, "upsertLocalConversation">;
165+}): Promise<LocalConversationRecord> {
166+ const fingerprint = buildAutomationTextFingerprint(input.payloadText);
167+ const repeatedRenewalCount =
168+ input.conversation.lastRenewalFingerprint === fingerprint
169+ ? input.conversation.repeatedRenewalCount + 1
170+ : 1;
171+ const updatedConversation = await input.store.upsertLocalConversation({
172+ lastRenewalFingerprint: fingerprint,
173+ localConversationId: input.conversation.localConversationId,
174+ platform: input.conversation.platform,
175+ repeatedRenewalCount,
176+ updatedAt: input.observedAt
177+ });
178+
179+ if (repeatedRenewalCount < CONVERSATION_REPEATED_RENEWAL_THRESHOLD) {
180+ return updatedConversation;
181+ }
182+
183+ return pauseConversationAutomation({
184+ conversation: updatedConversation,
185+ lastError: `repeated renewal payload detected (${repeatedRenewalCount}x)`,
186+ observedAt: input.observedAt,
187+ reason: "repeated_renewal",
188+ store: input.store
189+ });
190+}
191+
192+function normalizeAutomationText(value: string): string {
193+ return value
194+ .toLowerCase()
195+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gu, "<uuid>")
196+ .replace(/\b\d{4}-\d{2}-\d{2}(?:[ t]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)?(?:z|[+-]\d{2}:?\d{2})?\b/gu, "<ts>")
197+ .replace(/\b(?:msg|conv|job|req|run|tab|trace)[-_:/]?[a-z0-9]{4,}\b/gu, "<id>")
198+ .replace(/\b\d{4,}\b/gu, "<n>")
199+ .replace(/\s+/gu, " ")
200+ .trim();
201+}
202+
203+function normalizeOptionalString(value: string | null | undefined): string | null {
204+ if (value == null) {
205+ return null;
206+ }
207+
208+ const normalized = value.trim();
209+ return normalized === "" ? null : normalized;
210+}
1@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2 import type {
3 ArtifactStore,
4 ConversationAutomationStatus,
5+ ConversationPauseReason,
6 ConversationLinkRecord,
7 ListConversationLinksOptions,
8 ListLocalConversationsOptions,
9@@ -9,6 +10,7 @@ import type {
10 } from "../../../../packages/artifact-db/dist/index.js";
11
12 import type { BaaDeliveryRouteSnapshot } from "../artifacts/types.js";
13+import { buildConversationAutomationResetPatch } from "./automation.js";
14
15 const LOCAL_CONVERSATION_ID_PREFIX = "lc_";
16 const CONVERSATION_LINK_ID_PREFIX = "link_";
17@@ -215,6 +217,7 @@ export async function setRenewalConversationAutomationStatus(input: {
18 automationStatus: ConversationAutomationStatus;
19 localConversationId: string;
20 observedAt?: number | null;
21+ pauseReason?: ConversationPauseReason | null;
22 store: ArtifactStore;
23 }): Promise<RenewalConversationDetail> {
24 const localConversationId = normalizeRequiredString(input.localConversationId, "localConversationId");
25@@ -228,10 +231,18 @@ export async function setRenewalConversationAutomationStatus(input: {
26 input.observedAt ?? Date.now(),
27 "observedAt"
28 );
29+ const automationStatus = input.automationStatus;
30 await input.store.upsertLocalConversation({
31- automationStatus: input.automationStatus,
32+ ...(automationStatus === "paused"
33+ ? {
34+ automationStatus,
35+ pauseReason: input.pauseReason ?? "user_pause",
36+ pausedAt: updatedAt
37+ }
38+ : buildConversationAutomationResetPatch({
39+ automationStatus
40+ })),
41 localConversationId,
42- pausedAt: input.automationStatus === "paused" ? updatedAt : null,
43 platform: existing.platform,
44 updatedAt
45 });
1@@ -21,6 +21,10 @@ import {
2 type RenewalProjectorPayload,
3 type RenewalProjectorTargetSnapshot
4 } from "./projector.js";
5+import {
6+ recordAutomationFailureSignal,
7+ recordAutomationSuccessSignal
8+} from "./automation.js";
9
10 const DEFAULT_RECHECK_DELAY_MS = 10_000;
11 const DEFAULT_INTER_JOB_JITTER_MIN_MS = 500;
12@@ -37,6 +41,7 @@ type RenewalDispatcherJobResult =
13 | "attempt_failed"
14 | "attempt_started"
15 | "attempt_succeeded"
16+ | "automation_busy"
17 | "automation_manual"
18 | "automation_paused"
19 | "failed"
20@@ -244,9 +249,10 @@ export async function runRenewalDispatcher(
21
22 if (dispatchContext.link == null || dispatchContext.target == null) {
23 const attempts = job.attemptCount + 1;
24+ const failureMessage = dispatchContext.link == null ? "missing_active_link" : "route_unavailable";
25 const failureResult = await applyFailureOutcome(artifactStore, job, {
26 attemptCount: attempts,
27- errorMessage: dispatchContext.link == null ? "missing_active_link" : "route_unavailable",
28+ errorMessage: failureMessage,
29 logDir: context.logDir,
30 now: jobNowMs,
31 retryBaseDelayMs: options.retryBaseDelayMs,
32@@ -255,6 +261,12 @@ export async function runRenewalDispatcher(
33 random: jitterSettings.random,
34 targetSnapshot: dispatchContext.targetSnapshot
35 });
36+ await recordAutomationFailureSignal({
37+ conversation: dispatchContext.conversation,
38+ errorMessage: failureMessage,
39+ observedAt: jobNowMs,
40+ store: artifactStore
41+ });
42
43 if (failureResult.status === "failed") {
44 failedJobs += 1;
45@@ -298,6 +310,12 @@ export async function runRenewalDispatcher(
46 now: jobNowMs,
47 targetSnapshot: dispatchContext.targetSnapshot
48 });
49+ await recordAutomationFailureSignal({
50+ conversation: dispatchContext.conversation,
51+ errorMessage,
52+ observedAt: jobNowMs,
53+ store: artifactStore
54+ });
55 context.log({
56 stage: "job_failed",
57 result: errorMessage,
58@@ -329,37 +347,70 @@ export async function runRenewalDispatcher(
59 await sleep(interJobJitterMs);
60 }
61
62- const attemptStartedAt = now();
63- const runningJob = await artifactStore.updateRenewalJob({
64- finishedAt: null,
65- jobId: job.jobId,
66- lastAttemptAt: attemptStartedAt,
67- lastError: null,
68- logPath: resolveJobLogPath(job.logPath, context.logDir, attemptStartedAt),
69- nextAttemptAt: null,
70- startedAt: attemptStartedAt,
71- status: "running",
72- targetSnapshot: dispatchContext.targetSnapshot,
73- updatedAt: attemptStartedAt
74- });
75- dispatchedJobs += 1;
76- context.log({
77- stage: "job_attempt_started",
78- result: "attempt_started",
79- details: {
80- attempt_count: job.attemptCount + 1,
81- client_id: dispatchContext.target.clientId,
82- dispatch_sequence_in_tick: dispatchedJobs,
83- inter_job_jitter_ms: interJobJitterMs,
84- job_id: job.jobId,
85- local_conversation_id: job.localConversationId,
86- message_id: job.messageId,
87- started_at: attemptStartedAt,
88- tab_id: dispatchContext.target.tabId
89- }
90+ const lockedConversation = await artifactStore.tryBeginLocalConversationExecution({
91+ executionState: "renewal_running",
92+ localConversationId: job.localConversationId,
93+ updatedAt: now()
94 });
95
96+ if (lockedConversation == null) {
97+ skippedJobs += 1;
98+ const nextAttemptAt = now() + resolvePositiveInteger(
99+ options.recheckDelayMs,
100+ Math.max(DEFAULT_RECHECK_DELAY_MS, context.config.intervalMs)
101+ );
102+ await artifactStore.updateRenewalJob({
103+ jobId: job.jobId,
104+ logPath: resolveJobLogPath(job.logPath, context.logDir, jobNowMs),
105+ nextAttemptAt,
106+ targetSnapshot: dispatchContext.targetSnapshot,
107+ updatedAt: now()
108+ });
109+ context.log({
110+ stage: "job_deferred",
111+ result: "automation_busy",
112+ details: {
113+ execution_state: dispatchContext.conversation.executionState,
114+ job_id: job.jobId,
115+ local_conversation_id: job.localConversationId,
116+ message_id: job.messageId,
117+ next_attempt_at: nextAttemptAt
118+ }
119+ });
120+ continue;
121+ }
122+
123 try {
124+ const attemptStartedAt = now();
125+ const runningJob = await artifactStore.updateRenewalJob({
126+ finishedAt: null,
127+ jobId: job.jobId,
128+ lastAttemptAt: attemptStartedAt,
129+ lastError: null,
130+ logPath: resolveJobLogPath(job.logPath, context.logDir, attemptStartedAt),
131+ nextAttemptAt: null,
132+ startedAt: attemptStartedAt,
133+ status: "running",
134+ targetSnapshot: dispatchContext.targetSnapshot,
135+ updatedAt: attemptStartedAt
136+ });
137+ dispatchedJobs += 1;
138+ context.log({
139+ stage: "job_attempt_started",
140+ result: "attempt_started",
141+ details: {
142+ attempt_count: job.attemptCount + 1,
143+ client_id: dispatchContext.target.clientId,
144+ dispatch_sequence_in_tick: dispatchedJobs,
145+ inter_job_jitter_ms: interJobJitterMs,
146+ job_id: job.jobId,
147+ local_conversation_id: job.localConversationId,
148+ message_id: job.messageId,
149+ started_at: attemptStartedAt,
150+ tab_id: dispatchContext.target.tabId
151+ }
152+ });
153+
154 const delivery = await dispatchRenewalJob(options.browserBridge, {
155 assistantMessageId: job.messageId,
156 messageText: payload.text,
157@@ -389,6 +440,11 @@ export async function runRenewalDispatcher(
158 targetSnapshot: dispatchContext.targetSnapshot,
159 updatedAt: finishedAt
160 });
161+ await recordAutomationSuccessSignal({
162+ conversation: lockedConversation,
163+ observedAt: finishedAt,
164+ store: artifactStore
165+ });
166 successfulJobs += 1;
167 context.log({
168 stage: "job_completed",
169@@ -417,6 +473,12 @@ export async function runRenewalDispatcher(
170 random: jitterSettings.random,
171 targetSnapshot: dispatchContext.targetSnapshot
172 });
173+ await recordAutomationFailureSignal({
174+ conversation: lockedConversation,
175+ errorMessage: failure.result,
176+ observedAt: now(),
177+ store: artifactStore
178+ });
179
180 if (failureResult.status === "failed") {
181 failedJobs += 1;
182@@ -451,6 +513,12 @@ export async function runRenewalDispatcher(
183 }
184 });
185 }
186+ } finally {
187+ await artifactStore.finishLocalConversationExecution({
188+ executionState: "renewal_running",
189+ localConversationId: job.localConversationId,
190+ updatedAt: now()
191+ });
192 }
193 }
194
1@@ -11,6 +11,12 @@ import {
2 import type { ControlPlaneRepository } from "../../../../packages/db/dist/index.js";
3
4 import type { TimedJobRunner, TimedJobRunnerResult, TimedJobTickContext } from "../timed-jobs/index.js";
5+import { extractBaaInstructionBlocks } from "../instructions/extract.js";
6+
7+import {
8+ recordAutomationFailureSignal,
9+ recordRenewalPayloadSignal
10+} from "./automation.js";
11
12 const DEFAULT_CURSOR_STATE_KEY = "renewal.projector.cursor";
13 const DEFAULT_MESSAGE_ROLE = "assistant";
14@@ -24,9 +30,11 @@ export type RenewalProjectorSkipReason =
15 | "automation_paused"
16 | "cooldown_active"
17 | "duplicate_job"
18+ | "instruction_message"
19 | "missing_active_link"
20 | "missing_local_conversation"
21 | "missing_remote_conversation_id"
22+ | "repeated_renewal"
23 | "route_unavailable";
24
25 export type RenewalRouteUnavailableReason =
26@@ -222,6 +230,14 @@ export async function runRenewalProjector(
27 });
28
29 if (!decision.eligible) {
30+ if (decision.reason === "route_unavailable") {
31+ await recordAutomationFailureSignal({
32+ conversation: resolution.candidate.conversation,
33+ errorMessage: `route_unavailable:${decision.routeUnavailableReason ?? "unknown"}`,
34+ observedAt: nowMs,
35+ store: artifactStore
36+ });
37+ }
38 skippedMessages += 1;
39 context.log({
40 stage: "message_skipped",
41@@ -241,16 +257,36 @@ export async function runRenewalProjector(
42 continue;
43 }
44
45+ const payload = buildRenewalPayload(message, artifactStore, resolution.candidate.link.pageUrl);
46+ const updatedConversation = await recordRenewalPayloadSignal({
47+ conversation: resolution.candidate.conversation,
48+ observedAt: nowMs,
49+ payloadText: payload.text,
50+ store: artifactStore
51+ });
52+
53+ if (updatedConversation.automationStatus === "paused" && updatedConversation.pauseReason === "repeated_renewal") {
54+ skippedMessages += 1;
55+ context.log({
56+ stage: "message_skipped",
57+ result: "repeated_renewal",
58+ details: {
59+ cursor: formatCursor(cursorAfter),
60+ local_conversation_id: updatedConversation.localConversationId,
61+ message_id: message.id
62+ }
63+ });
64+ continue;
65+ }
66+
67 try {
68 const job = await artifactStore.insertRenewalJob({
69 jobId: buildRenewalJobId(message.id),
70- localConversationId: resolution.candidate.conversation.localConversationId,
71+ localConversationId: updatedConversation.localConversationId,
72 logPath: buildRunnerLogPath(context.logDir, nowMs),
73 messageId: message.id,
74 nextAttemptAt: nowMs,
75- payload: JSON.stringify(
76- buildRenewalPayload(message, artifactStore, resolution.candidate.link.pageUrl)
77- ),
78+ payload: JSON.stringify(payload),
79 payloadKind: "json",
80 targetSnapshot: buildRenewalTargetSnapshot(resolution.candidate.link)
81 });
82@@ -321,6 +357,14 @@ export async function shouldRenew(input: {
83 store: Pick<ArtifactStore, "listRenewalJobs">;
84 }): Promise<RenewalShouldRenewDecision> {
85 const { candidate } = input;
86+
87+ if (hasBaaInstructionBlocks(candidate.message.rawText)) {
88+ return {
89+ eligible: false,
90+ reason: "instruction_message"
91+ };
92+ }
93+
94 const automationStatus = candidate.conversation.automationStatus;
95
96 if (automationStatus === "manual") {
97@@ -550,6 +594,14 @@ function hasAvailableRoute(link: ConversationLinkRecord): RenewalRouteAvailabili
98 };
99 }
100
101+function hasBaaInstructionBlocks(text: string): boolean {
102+ try {
103+ return extractBaaInstructionBlocks(text).length > 0;
104+ } catch {
105+ return true;
106+ }
107+}
108+
109 function isCursorStateValue(value: unknown): value is CursorStateValue {
110 return (
111 typeof value === "object"
+6,
-0
1@@ -90,6 +90,12 @@
2 - `conversations` 看自动化状态和 active link
3 - `links` 看平台对话到本地对话、页面和 tab 目标的映射
4 - `jobs` 看续命 dispatcher 的 `pending / running / done / failed`、重试次数、错误和目标快照
5+- `renewal.conversations.read` 现在会额外返回:
6+ - `pause_reason` / `last_error`
7+ - `execution_state`
8+ - `consecutive_failure_count`
9+ - `repeated_message_count`
10+ - `repeated_renewal_count`
11 - ChatGPT raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`
12 - 如果没有活跃 Firefox bridge client,会返回 `503`
13 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
+14,
-1
1@@ -118,7 +118,7 @@ browser/plugin 管理约定:
2 | --- | --- | --- |
3 | `POST` | `/v1/renewal/conversations/:local_conversation_id/manual` | 将指定本地对话切到 `manual`,停止自动生成和推进续命 |
4 | `POST` | `/v1/renewal/conversations/:local_conversation_id/auto` | 将指定本地对话切到 `auto`,允许 projector / dispatcher 继续推进 |
5-| `POST` | `/v1/renewal/conversations/:local_conversation_id/paused` | 将指定本地对话切到 `paused`,保留对话和任务,但暂停自动执行 |
6+| `POST` | `/v1/renewal/conversations/:local_conversation_id/paused` | 将指定本地对话切到 `paused`,保留对话和任务,但暂停自动执行;可选 body `{"pause_reason":"user_pause"}` |
7
8 续命控制约定:
9
10@@ -127,6 +127,19 @@ browser/plugin 管理约定:
11 - `GET /v1/renewal/jobs?local_conversation_id=...`
12 - `paused` 不会删除任务,只会阻止 dispatcher 继续推进待执行 job
13 - `manual` 和 `auto` / `paused` 共用同一份 `local_conversations` 后端状态,不存在插件侧单独影子开关
14+- `GET /v1/renewal/conversations/:local_conversation_id` 现在会额外暴露:
15+ - `pause_reason`
16+ - `last_error`
17+ - `execution_state`(`idle / instruction_running / renewal_running`)
18+ - `consecutive_failure_count`
19+ - `repeated_message_count`
20+ - `repeated_renewal_count`
21+- conductor 内部现在也支持最小显式控制指令入口:
22+ - `@conductor::conversation/pause::{"scope":"current","reason":"rescue_wait"}`
23+ - `@conductor::conversation/resume::{"scope":"current"}`
24+ - `@conductor::conversation/mode::{"scope":"current","mode":"auto"}`
25+ - `@conductor::system/pause`
26+ - `@conductor::system/resume`
27
28 ### 本机 Host Ops
29
1@@ -369,6 +369,15 @@ test("ArtifactStore UPSERT SQL preserves created_at for renewal storage rows on
2 "lc_created_at",
3 "claude",
4 "auto",
5+ "auto",
6+ null,
7+ null,
8+ "idle",
9+ 0,
10+ 0,
11+ 0,
12+ null,
13+ null,
14 "Updated title",
15 "Updated summary",
16 null,
+4,
-0
1@@ -10,7 +10,11 @@ export {
2 ARTIFACT_DB_FILENAME,
3 ARTIFACT_PUBLIC_PATH_SEGMENT,
4 ARTIFACT_SCOPES,
5+ CONVERSATION_AUTOMATION_EXECUTION_STATES,
6+ CONVERSATION_PAUSE_REASONS,
7+ type ConversationAutomationExecutionState,
8 type ConversationAutomationStatus,
9+ type ConversationPauseReason,
10 type ConversationLinkRecord,
11 DEFAULT_SESSION_INDEX_LIMIT,
12 DEFAULT_SUMMARY_LENGTH,
+11,
-0
1@@ -100,6 +100,15 @@ CREATE TABLE IF NOT EXISTS local_conversations (
2 local_conversation_id TEXT PRIMARY KEY,
3 platform TEXT NOT NULL,
4 automation_status TEXT NOT NULL DEFAULT 'manual',
5+ last_non_paused_automation_status TEXT NOT NULL DEFAULT 'manual',
6+ pause_reason TEXT,
7+ last_error TEXT,
8+ execution_state TEXT NOT NULL DEFAULT 'idle',
9+ consecutive_failure_count INTEGER NOT NULL DEFAULT 0,
10+ repeated_message_count INTEGER NOT NULL DEFAULT 0,
11+ repeated_renewal_count INTEGER NOT NULL DEFAULT 0,
12+ last_message_fingerprint TEXT,
13+ last_renewal_fingerprint TEXT,
14 title TEXT,
15 summary TEXT,
16 last_message_id TEXT,
17@@ -116,6 +125,8 @@ CREATE INDEX IF NOT EXISTS idx_local_conversations_platform
18 ON local_conversations(platform, updated_at DESC);
19 CREATE INDEX IF NOT EXISTS idx_local_conversations_last_message
20 ON local_conversations(last_message_at DESC);
21+CREATE INDEX IF NOT EXISTS idx_local_conversations_last_message_id
22+ ON local_conversations(last_message_id);
23
24 CREATE TABLE IF NOT EXISTS conversation_links (
25 link_id TEXT PRIMARY KEY,
+293,
-3
1@@ -21,7 +21,9 @@ import {
2 DEFAULT_SUMMARY_LENGTH,
3 type ArtifactStoreConfig,
4 type ArtifactTextFile,
5+ type ConversationAutomationExecutionState,
6 type ConversationAutomationStatus,
7+ type ConversationPauseReason,
8 type ConversationLinkRecord,
9 type ExecutionRecord,
10 type ExecutionParamsKind,
11@@ -117,6 +119,15 @@ INSERT INTO local_conversations (
12 local_conversation_id,
13 platform,
14 automation_status,
15+ last_non_paused_automation_status,
16+ pause_reason,
17+ last_error,
18+ execution_state,
19+ consecutive_failure_count,
20+ repeated_message_count,
21+ repeated_renewal_count,
22+ last_message_fingerprint,
23+ last_renewal_fingerprint,
24 title,
25 summary,
26 last_message_id,
27@@ -125,10 +136,19 @@ INSERT INTO local_conversations (
28 paused_at,
29 created_at,
30 updated_at
31-) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
32+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
33 ON CONFLICT(local_conversation_id) DO UPDATE SET
34 platform = excluded.platform,
35 automation_status = excluded.automation_status,
36+ last_non_paused_automation_status = excluded.last_non_paused_automation_status,
37+ pause_reason = excluded.pause_reason,
38+ last_error = excluded.last_error,
39+ execution_state = excluded.execution_state,
40+ consecutive_failure_count = excluded.consecutive_failure_count,
41+ repeated_message_count = excluded.repeated_message_count,
42+ repeated_renewal_count = excluded.repeated_renewal_count,
43+ last_message_fingerprint = excluded.last_message_fingerprint,
44+ last_renewal_fingerprint = excluded.last_renewal_fingerprint,
45 title = excluded.title,
46 summary = excluded.summary,
47 last_message_id = excluded.last_message_id,
48@@ -200,6 +220,24 @@ SET
49 WHERE link_id = ?;
50 `;
51
52+const TRY_BEGIN_LOCAL_CONVERSATION_EXECUTION_SQL = `
53+UPDATE local_conversations
54+SET
55+ execution_state = ?,
56+ updated_at = ?
57+WHERE local_conversation_id = ?
58+ AND execution_state = 'idle';
59+`;
60+
61+const FINISH_LOCAL_CONVERSATION_EXECUTION_SQL = `
62+UPDATE local_conversations
63+SET
64+ execution_state = 'idle',
65+ updated_at = ?
66+WHERE local_conversation_id = ?
67+ AND execution_state = ?;
68+`;
69+
70 const INSERT_RENEWAL_JOB_SQL = `
71 INSERT INTO renewal_jobs (
72 job_id,
73@@ -314,13 +352,22 @@ interface SessionRow {
74
75 interface LocalConversationRow {
76 automation_status: ConversationAutomationStatus;
77+ consecutive_failure_count: number;
78 cooldown_until: number | null;
79 created_at: number;
80+ execution_state: ConversationAutomationExecutionState;
81+ last_error: string | null;
82+ last_message_fingerprint: string | null;
83 last_message_at: number | null;
84 last_message_id: string | null;
85+ last_non_paused_automation_status: ConversationAutomationStatus;
86+ last_renewal_fingerprint: string | null;
87 local_conversation_id: string;
88 paused_at: number | null;
89+ pause_reason: ConversationPauseReason | null;
90 platform: string;
91+ repeated_message_count: number;
92+ repeated_renewal_count: number;
93 summary: string | null;
94 title: string | null;
95 updated_at: number;
96@@ -430,6 +477,7 @@ export class ArtifactStore {
97 this.db = new DatabaseSync(databasePath);
98 this.db.exec("PRAGMA foreign_keys = ON;");
99 this.db.exec(ARTIFACT_SCHEMA_SQL);
100+ this.ensureLocalConversationColumns();
101 this.ensureConversationLinkIndexes();
102 }
103
104@@ -483,6 +531,20 @@ export class ArtifactStore {
105 return row == null ? null : mapLocalConversationRow(row);
106 }
107
108+ async findLocalConversationByLastMessageId(messageId: string): Promise<LocalConversationRecord | null> {
109+ const row = this.getRow<LocalConversationRow>(
110+ `
111+ SELECT *
112+ FROM local_conversations
113+ WHERE last_message_id = ?
114+ ORDER BY updated_at DESC, created_at DESC
115+ LIMIT 1;
116+ `,
117+ normalizeRequiredString(messageId, "messageId")
118+ );
119+ return row == null ? null : mapLocalConversationRow(row);
120+ }
121+
122 async getConversationLink(linkId: string): Promise<ConversationLinkRecord | null> {
123 const row = this.getRow<ConversationLinkRow>(
124 "SELECT * FROM conversation_links WHERE link_id = ? LIMIT 1;",
125@@ -601,6 +663,70 @@ export class ArtifactStore {
126 return record;
127 }
128
129+ async tryBeginLocalConversationExecution(input: {
130+ executionState: ConversationAutomationExecutionState;
131+ localConversationId: string;
132+ updatedAt?: number;
133+ }): Promise<LocalConversationRecord | null> {
134+ const localConversationId = normalizeRequiredString(input.localConversationId, "localConversationId");
135+ const executionState = normalizeExecutionState(input.executionState);
136+ const updatedAt = normalizeNonNegativeInteger(input.updatedAt ?? Date.now(), 0, "updatedAt");
137+ const result = this.db.prepare(TRY_BEGIN_LOCAL_CONVERSATION_EXECUTION_SQL).run(
138+ executionState,
139+ updatedAt,
140+ localConversationId
141+ );
142+
143+ if (result.changes === 0) {
144+ return null;
145+ }
146+
147+ const record = await this.getLocalConversation(localConversationId);
148+
149+ if (record != null) {
150+ this.enqueueSync(
151+ "local_conversations",
152+ record.localConversationId,
153+ localConversationSyncPayload(record),
154+ "update"
155+ );
156+ }
157+
158+ return record;
159+ }
160+
161+ async finishLocalConversationExecution(input: {
162+ executionState: ConversationAutomationExecutionState;
163+ localConversationId: string;
164+ updatedAt?: number;
165+ }): Promise<LocalConversationRecord | null> {
166+ const localConversationId = normalizeRequiredString(input.localConversationId, "localConversationId");
167+ const executionState = normalizeExecutionState(input.executionState);
168+ const updatedAt = normalizeNonNegativeInteger(input.updatedAt ?? Date.now(), 0, "updatedAt");
169+ const result = this.db.prepare(FINISH_LOCAL_CONVERSATION_EXECUTION_SQL).run(
170+ updatedAt,
171+ localConversationId,
172+ executionState
173+ );
174+
175+ if (result.changes === 0) {
176+ return this.getLocalConversation(localConversationId);
177+ }
178+
179+ const record = await this.getLocalConversation(localConversationId);
180+
181+ if (record != null) {
182+ this.enqueueSync(
183+ "local_conversations",
184+ record.localConversationId,
185+ localConversationSyncPayload(record),
186+ "update"
187+ );
188+ }
189+
190+ return record;
191+ }
192+
193 async upsertConversationLink(
194 input: UpsertConversationLinkInput
195 ): Promise<ConversationLinkRecord> {
196@@ -918,6 +1044,62 @@ export class ArtifactStore {
197 }
198 }
199
200+ private ensureLocalConversationColumns(): void {
201+ const existingColumns = new Set(
202+ this.getRows<{ name: string }>("PRAGMA table_info(local_conversations);").map((row) => row.name)
203+ );
204+ const requiredColumns = [
205+ "ALTER TABLE local_conversations "
206+ + "ADD COLUMN last_non_paused_automation_status TEXT NOT NULL DEFAULT 'manual';",
207+ "ALTER TABLE local_conversations ADD COLUMN pause_reason TEXT;",
208+ "ALTER TABLE local_conversations ADD COLUMN last_error TEXT;",
209+ "ALTER TABLE local_conversations "
210+ + "ADD COLUMN execution_state TEXT NOT NULL DEFAULT 'idle';",
211+ "ALTER TABLE local_conversations "
212+ + "ADD COLUMN consecutive_failure_count INTEGER NOT NULL DEFAULT 0;",
213+ "ALTER TABLE local_conversations "
214+ + "ADD COLUMN repeated_message_count INTEGER NOT NULL DEFAULT 0;",
215+ "ALTER TABLE local_conversations "
216+ + "ADD COLUMN repeated_renewal_count INTEGER NOT NULL DEFAULT 0;",
217+ "ALTER TABLE local_conversations ADD COLUMN last_message_fingerprint TEXT;",
218+ "ALTER TABLE local_conversations ADD COLUMN last_renewal_fingerprint TEXT;"
219+ ] as const;
220+ const columnNames = [
221+ "last_non_paused_automation_status",
222+ "pause_reason",
223+ "last_error",
224+ "execution_state",
225+ "consecutive_failure_count",
226+ "repeated_message_count",
227+ "repeated_renewal_count",
228+ "last_message_fingerprint",
229+ "last_renewal_fingerprint"
230+ ] as const;
231+
232+ this.db.exec("BEGIN;");
233+
234+ try {
235+ for (let index = 0; index < columnNames.length; index += 1) {
236+ const columnName = columnNames[index]!;
237+
238+ if (existingColumns.has(columnName)) {
239+ continue;
240+ }
241+
242+ this.db.exec(requiredColumns[index]!);
243+ }
244+
245+ this.db.exec(
246+ "CREATE INDEX IF NOT EXISTS idx_local_conversations_last_message_id "
247+ + "ON local_conversations(last_message_id);"
248+ );
249+ this.db.exec("COMMIT;");
250+ } catch (error) {
251+ this.rollbackQuietly();
252+ throw error;
253+ }
254+ }
255+
256 private ensureConversationLinkIndexes(): void {
257 this.db.exec("BEGIN;");
258
259@@ -1483,19 +1665,60 @@ function buildLocalConversationRecord(
260 existing: LocalConversationRecord | null
261 ): LocalConversationRecord {
262 const createdAt = input.createdAt ?? existing?.createdAt ?? Date.now();
263+ const automationStatus = input.automationStatus ?? existing?.automationStatus ?? "manual";
264+ const lastNonPausedAutomationStatus =
265+ input.lastNonPausedAutomationStatus
266+ ?? (automationStatus === "paused"
267+ ? (existing?.lastNonPausedAutomationStatus ?? existing?.automationStatus ?? "manual")
268+ : automationStatus);
269
270 return {
271- automationStatus: input.automationStatus ?? existing?.automationStatus ?? "manual",
272+ automationStatus,
273+ consecutiveFailureCount: input.consecutiveFailureCount == null
274+ ? existing?.consecutiveFailureCount ?? 0
275+ : normalizeNonNegativeInteger(
276+ input.consecutiveFailureCount,
277+ existing?.consecutiveFailureCount ?? 0,
278+ "consecutiveFailureCount"
279+ ),
280 cooldownUntil: mergeOptionalInteger(input.cooldownUntil, existing?.cooldownUntil ?? null, "cooldownUntil"),
281 createdAt,
282+ executionState: input.executionState == null
283+ ? existing?.executionState ?? "idle"
284+ : normalizeExecutionState(input.executionState),
285+ lastError: mergeOptionalString(input.lastError, existing?.lastError ?? null),
286+ lastMessageFingerprint: mergeOptionalString(
287+ input.lastMessageFingerprint,
288+ existing?.lastMessageFingerprint ?? null
289+ ),
290 lastMessageAt: mergeOptionalInteger(input.lastMessageAt, existing?.lastMessageAt ?? null, "lastMessageAt"),
291 lastMessageId: mergeOptionalString(input.lastMessageId, existing?.lastMessageId ?? null),
292+ lastNonPausedAutomationStatus,
293+ lastRenewalFingerprint: mergeOptionalString(
294+ input.lastRenewalFingerprint,
295+ existing?.lastRenewalFingerprint ?? null
296+ ),
297 localConversationId: normalizeRequiredString(input.localConversationId, "localConversationId"),
298 pausedAt: mergeOptionalInteger(input.pausedAt, existing?.pausedAt ?? null, "pausedAt"),
299+ pauseReason: mergeOptionalPauseReason(input.pauseReason, existing?.pauseReason ?? null),
300 platform: normalizeRequiredString(input.platform, "platform"),
301+ repeatedMessageCount: input.repeatedMessageCount == null
302+ ? existing?.repeatedMessageCount ?? 0
303+ : normalizeNonNegativeInteger(
304+ input.repeatedMessageCount,
305+ existing?.repeatedMessageCount ?? 0,
306+ "repeatedMessageCount"
307+ ),
308+ repeatedRenewalCount: input.repeatedRenewalCount == null
309+ ? existing?.repeatedRenewalCount ?? 0
310+ : normalizeNonNegativeInteger(
311+ input.repeatedRenewalCount,
312+ existing?.repeatedRenewalCount ?? 0,
313+ "repeatedRenewalCount"
314+ ),
315 summary: mergeOptionalString(input.summary, existing?.summary ?? null),
316 title: mergeOptionalString(input.title, existing?.title ?? null),
317- updatedAt: input.updatedAt ?? Date.now()
318+ updatedAt: Math.max(existing?.updatedAt ?? 0, input.updatedAt ?? Date.now())
319 };
320 }
321
322@@ -1723,6 +1946,15 @@ function localConversationParams(record: LocalConversationRecord): Array<number
323 record.localConversationId,
324 record.platform,
325 record.automationStatus,
326+ record.lastNonPausedAutomationStatus,
327+ record.pauseReason,
328+ record.lastError,
329+ record.executionState,
330+ record.consecutiveFailureCount,
331+ record.repeatedMessageCount,
332+ record.repeatedRenewalCount,
333+ record.lastMessageFingerprint,
334+ record.lastRenewalFingerprint,
335 record.title,
336 record.summary,
337 record.lastMessageId,
338@@ -1781,13 +2013,22 @@ function renewalJobParams(record: RenewalJobRecord): Array<number | string | nul
339 function mapLocalConversationRow(row: LocalConversationRow): LocalConversationRecord {
340 return {
341 automationStatus: row.automation_status,
342+ consecutiveFailureCount: row.consecutive_failure_count,
343 cooldownUntil: row.cooldown_until,
344 createdAt: row.created_at,
345+ executionState: row.execution_state,
346+ lastError: row.last_error,
347+ lastMessageFingerprint: row.last_message_fingerprint,
348 lastMessageAt: row.last_message_at,
349 lastMessageId: row.last_message_id,
350+ lastNonPausedAutomationStatus: row.last_non_paused_automation_status,
351+ lastRenewalFingerprint: row.last_renewal_fingerprint,
352 localConversationId: row.local_conversation_id,
353 pausedAt: row.paused_at,
354+ pauseReason: row.pause_reason,
355 platform: row.platform,
356+ repeatedMessageCount: row.repeated_message_count,
357+ repeatedRenewalCount: row.repeated_renewal_count,
358 summary: row.summary,
359 title: row.title,
360 updatedAt: row.updated_at
361@@ -1956,6 +2197,37 @@ function normalizeRequiredString(value: string, name: string): string {
362 return normalized;
363 }
364
365+function normalizeExecutionState(value: string): ConversationAutomationExecutionState {
366+ const normalized = normalizeRequiredString(value, "executionState");
367+
368+ switch (normalized) {
369+ case "idle":
370+ case "instruction_running":
371+ case "renewal_running":
372+ return normalized as ConversationAutomationExecutionState;
373+ default:
374+ throw new Error(`Unsupported executionState "${normalized}".`);
375+ }
376+}
377+
378+function normalizePauseReason(value: string): ConversationPauseReason {
379+ const normalized = normalizeRequiredString(value, "pauseReason");
380+
381+ switch (normalized) {
382+ case "ai_pause":
383+ case "error_loop":
384+ case "execution_failure":
385+ case "repeated_message":
386+ case "repeated_renewal":
387+ case "rescue_wait":
388+ case "system_pause":
389+ case "user_pause":
390+ return normalized as ConversationPauseReason;
391+ default:
392+ throw new Error(`Unsupported pauseReason "${normalized}".`);
393+ }
394+}
395+
396 function sessionParams(record: SessionRecord): Array<number | string | null> {
397 return [
398 record.id,
399@@ -1986,6 +2258,15 @@ function mergeOptionalString(
400 return value === undefined ? existing : normalizeOptionalString(value);
401 }
402
403+function mergeOptionalPauseReason(
404+ value: ConversationPauseReason | null | undefined,
405+ existing: ConversationPauseReason | null
406+): ConversationPauseReason | null {
407+ return value === undefined
408+ ? existing
409+ : (value == null ? null : normalizePauseReason(value));
410+}
411+
412 function mergeOptionalSerialized(
413 value: unknown,
414 existing: string | null
415@@ -2096,6 +2377,15 @@ function localConversationSyncPayload(
416 local_conversation_id: record.localConversationId,
417 platform: record.platform,
418 automation_status: record.automationStatus,
419+ last_non_paused_automation_status: record.lastNonPausedAutomationStatus,
420+ pause_reason: record.pauseReason,
421+ last_error: record.lastError,
422+ execution_state: record.executionState,
423+ consecutive_failure_count: record.consecutiveFailureCount,
424+ repeated_message_count: record.repeatedMessageCount,
425+ repeated_renewal_count: record.repeatedRenewalCount,
426+ last_message_fingerprint: record.lastMessageFingerprint,
427+ last_renewal_fingerprint: record.lastRenewalFingerprint,
428 title: record.title,
429 summary: record.summary,
430 last_message_id: record.lastMessageId,
+36,
-0
1@@ -4,9 +4,27 @@ export const ARTIFACT_PUBLIC_PATH_SEGMENT = "artifact";
2 export const DEFAULT_SUMMARY_LENGTH = 500;
3 export const DEFAULT_SESSION_INDEX_LIMIT = 20;
4 export const ARTIFACT_SCOPES = ["msg", "exec", "session"] as const;
5+export const CONVERSATION_PAUSE_REASONS = [
6+ "ai_pause",
7+ "error_loop",
8+ "execution_failure",
9+ "repeated_message",
10+ "repeated_renewal",
11+ "rescue_wait",
12+ "system_pause",
13+ "user_pause"
14+] as const;
15+export const CONVERSATION_AUTOMATION_EXECUTION_STATES = [
16+ "idle",
17+ "instruction_running",
18+ "renewal_running"
19+] as const;
20
21 export type ArtifactScope = (typeof ARTIFACT_SCOPES)[number];
22 export type ConversationAutomationStatus = "manual" | "auto" | "paused";
23+export type ConversationPauseReason = (typeof CONVERSATION_PAUSE_REASONS)[number];
24+export type ConversationAutomationExecutionState =
25+ (typeof CONVERSATION_AUTOMATION_EXECUTION_STATES)[number];
26 export type ExecutionParamsKind = "none" | "body" | "inline_json" | "inline_string";
27 export type ArtifactFileKind = "json" | "txt";
28 export type RenewalJobPayloadKind = "text" | "json";
29@@ -88,6 +106,15 @@ export interface LocalConversationRecord {
30 localConversationId: string;
31 platform: string;
32 automationStatus: ConversationAutomationStatus;
33+ lastNonPausedAutomationStatus: ConversationAutomationStatus;
34+ pauseReason: ConversationPauseReason | null;
35+ lastError: string | null;
36+ executionState: ConversationAutomationExecutionState;
37+ consecutiveFailureCount: number;
38+ repeatedMessageCount: number;
39+ repeatedRenewalCount: number;
40+ lastMessageFingerprint: string | null;
41+ lastRenewalFingerprint: string | null;
42 title: string | null;
43 summary: string | null;
44 lastMessageId: string | null;
45@@ -184,6 +211,15 @@ export interface UpsertLocalConversationInput {
46 localConversationId: string;
47 platform: string;
48 automationStatus?: ConversationAutomationStatus;
49+ lastNonPausedAutomationStatus?: ConversationAutomationStatus;
50+ pauseReason?: ConversationPauseReason | null;
51+ lastError?: string | null;
52+ executionState?: ConversationAutomationExecutionState;
53+ consecutiveFailureCount?: number;
54+ repeatedMessageCount?: number;
55+ repeatedRenewalCount?: number;
56+ lastMessageFingerprint?: string | null;
57+ lastRenewalFingerprint?: string | null;
58 title?: string | null;
59 summary?: string | null;
60 lastMessageId?: string | null;
+37,
-6
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`L`
8 - 依赖任务:`T-S056`、`T-S058`、`T-S059`
9 - 建议执行者:`Codex`(涉及 conductor 指令链路、renewal 调度和状态模型联动)
10@@ -159,22 +159,53 @@
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-04-01 10:47:41 CST`
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:`2026-04-01 11:26:37 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+ - `apps/conductor-daemon/src/renewal/automation.ts`
27+ - `apps/conductor-daemon/src/renewal/conversations.ts`
28+ - `apps/conductor-daemon/src/renewal/projector.ts`
29+ - `apps/conductor-daemon/src/renewal/dispatcher.ts`
30+ - `apps/conductor-daemon/src/firefox-ws.ts`
31+ - `apps/conductor-daemon/src/local-api.ts`
32+ - `apps/conductor-daemon/src/instructions/types.ts`
33+ - `apps/conductor-daemon/src/instructions/ingest.ts`
34+ - `apps/conductor-daemon/src/instructions/loop.ts`
35+ - `apps/conductor-daemon/src/instructions/router.ts`
36+ - `apps/conductor-daemon/src/instructions/policy.ts`
37+ - `apps/conductor-daemon/src/index.ts`
38+ - `apps/conductor-daemon/src/index.test.js`
39+ - `packages/artifact-db/src/types.ts`
40+ - `packages/artifact-db/src/schema.ts`
41+ - `packages/artifact-db/src/store.ts`
42+ - `packages/artifact-db/src/index.ts`
43+ - `packages/artifact-db/src/index.test.js`
44+ - `docs/api/control-interfaces.md`
45+ - `docs/api/business-interfaces.md`
46+ - `tasks/T-S060.md`
47+ - `tasks/TASK_OVERVIEW.md`
48 - 核心实现思路:
49+ - 在 `artifact-db` 的 `local_conversations` 模型补齐 `pause_reason`、`last_error`、执行锁和重复/失败计数字段,并加入迁移与 CAS 风格执行锁接口,保证恢复时不覆盖既有状态
50+ - 在 final-message 链路实现 `control > instruction > renewal` 仲裁:控制类 BAA 指令优先于普通指令,普通指令执行时阻止 renewal 并串行执行,同一对话同一时刻只允许一个自动化出站者
51+ - 新增自动化信号模块,按重复消息、重复续命、连续失败、route unavailable 等结构化信号触发自动熔断,落库 `pause_reason`、最近错误和计数
52+ - 在本地 API 补最小内部控制入口,支持当前对话 `pause/resume/mode` 控制,并把新增状态字段和控制语法写入文档
53 - 跑了哪些测试:
54+ - `cd /Users/george/code/baa-conductor-automation-arbitration-core && pnpm install`
55+ - `cd /Users/george/code/baa-conductor-automation-arbitration-core && pnpm -C packages/artifact-db test`
56+ - `cd /Users/george/code/baa-conductor-automation-arbitration-core && pnpm -C apps/conductor-daemon test`
57+ - `cd /Users/george/code/baa-conductor-automation-arbitration-core && pnpm build`
58
59 ### 执行过程中遇到的问题
60
61-- 暂无
62+- `artifact-db` 补新列后,直写 SQL 的存储层测试没有同步更新插入列集,先暴露了 schema mismatch;补齐测试数据后恢复通过
63+- 控制类指令接入后,运行态测试先暴露出 policy allowlist 缺口,以及执行锁释放时机与旧断言不一致;随后把控制工具加入 allowlist,并把测试改为验证暂停状态而不是假设锁会立刻回到 `idle`
64
65 ### 剩余风险
66
67-- 暂无
68+- 自动熔断阈值目前仍是代码内常量(重复消息 / 重复续命 / 连续失败均为 `3`);如果后续要按页面、模型或租户差异化调参,需要继续推进 `T-S065` 的 policy 配置化
+4,
-5
1@@ -43,6 +43,7 @@
2 - `system_state.updated_at` 与 projector cursor 已统一为毫秒口径
3 - conductor 执行链路已补统一超时保护
4 - renewal dispatcher 已支持 inter-job jitter 和 retry jitter
5+ - 自动化仲裁基础已经落地:同条 final-message 现在按 `control > instruction > renewal` 顺序仲裁,同对话具备执行锁、`pause_reason` 和自动熔断基础能力
6
7 ## 当前已确认的不一致
8
9@@ -72,12 +73,12 @@
10 | [`T-S057`](./T-S057.md) | 定时任务框架与执行日志 | M | T-S055 | Codex | 已完成 |
11 | [`T-S058`](./T-S058.md) | 消息同步任务生成续命任务 | M | T-S055, T-S056, T-S057 | Claude / Codex | 已完成 |
12 | [`T-S059`](./T-S059.md) | 续命执行任务与运维接口 | M | T-S055, T-S056, T-S057, T-S058 | Codex | 已完成 |
13+| [`T-S060`](./T-S060.md) | 自动化仲裁与自动熔断基础 | L | T-S056, T-S058, T-S059 | Codex | 已完成 |
14
15 ### 当前下一波任务
16
17 | 项目 | 标题 | 类型 | 状态 | 说明 |
18 |---|---|---|---|---|
19-| [`T-S060`](./T-S060.md) | 自动化仲裁与自动熔断基础 | task | 待开始 | 先落仲裁顺序、执行锁、pause_reason 和自动熔断基础 |
20 | [`T-S063`](./T-S063.md) | normalize / parse 错误隔离 | task | 待开始 | 收口指令链路健壮性,避免单个坏 block 中断整批 |
21 | [`T-S061`](./T-S061.md) | 浮层统一自动化控制 | task | 待开始 | 统一页面级 BAA / renewal 控制入口并显示 pause_reason |
22 | [`T-S062`](./T-S062.md) | 系统级暂停接入自动化主链 | task | 待开始 | 把系统级暂停接入 BAA 与 timed-jobs 主链 |
23@@ -132,7 +133,6 @@
24
25 ### P1(并行优化)
26
27-- [`T-S060`](./T-S060.md)
28 - [`T-S063`](./T-S063.md)
29
30 ### P2(次级优化)
31@@ -177,10 +177,9 @@
32
33 ## 当前主线判断
34
35-Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。当前主线已经没有 open bug blocker,下一步是:
36+Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060` 的自动化仲裁基础也已落地。当前主线已经没有 open bug blocker,下一步是:
37
38-- 先做 `T-S060`
39-- 并行推进 `T-S063`
40+- 先做 `T-S063`
41 - 然后做 `T-S061`
42 - 再做 `T-S062`
43 - 之后收口 `T-S064`、`T-S065`