baa-conductor

git clone 

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
M apps/conductor-daemon/src/firefox-ws.ts
+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 
M apps/conductor-daemon/src/index.test.js
+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(
M apps/conductor-daemon/src/index.ts
+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";
M apps/conductor-daemon/src/instructions/ingest.ts
+20, -2
 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 
M apps/conductor-daemon/src/instructions/loop.ts
+85, -10
  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);
M apps/conductor-daemon/src/instructions/policy.ts
+6, -1
 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",
M apps/conductor-daemon/src/instructions/router.ts
+141, -0
  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),
M apps/conductor-daemon/src/instructions/types.ts
+2, -0
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"
M apps/conductor-daemon/src/local-api.ts
+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   }
A apps/conductor-daemon/src/renewal/automation.ts
+209, -0
  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+}
M apps/conductor-daemon/src/renewal/conversations.ts
+13, -2
 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   });
M apps/conductor-daemon/src/renewal/dispatcher.ts
+97, -29
  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 
M apps/conductor-daemon/src/renewal/projector.ts
+56, -4
  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"
M docs/api/business-interfaces.md
+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`
M docs/api/control-interfaces.md
+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 
M packages/artifact-db/src/index.test.js
+9, -0
 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,
M packages/artifact-db/src/index.ts
+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,
M packages/artifact-db/src/schema.ts
+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,
M packages/artifact-db/src/store.ts
+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,
M packages/artifact-db/src/types.ts
+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;
M tasks/T-S060.md
+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 配置化
M tasks/TASK_OVERVIEW.md
+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`