baa-conductor

git clone 

commit
74f14ea
parent
701bc15
author
codex@macbookpro
date
2026-04-01 15:59:15 +0800 CST
feat: gate automation on system pause
10 files changed,  +253, -20
M apps/conductor-daemon/src/index.test.js
+161, -0
  1@@ -997,6 +997,75 @@ test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed
  2   }
  3 });
  4 
  5+test("BaaInstructionCenter blocks ordinary instructions while system automation is paused and resumes through control instructions", async () => {
  6+  const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
  7+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-system-paused-"));
  8+  const outputPath = join(hostOpsDir, "system-gate-restored.txt");
  9+  const center = new BaaInstructionCenter({
 10+    localApiContext: {
 11+      fetchImpl: globalThis.fetch,
 12+      repository,
 13+      sharedToken,
 14+      snapshotLoader: () => snapshot
 15+    }
 16+  });
 17+  const writeMessage = [
 18+    "```baa",
 19+    `@conductor::files/write::${JSON.stringify({
 20+      path: "system-gate-restored.txt",
 21+      cwd: hostOpsDir,
 22+      content: "system gate lifted",
 23+      overwrite: true
 24+    })}`,
 25+    "```"
 26+  ].join("\n");
 27+
 28+  try {
 29+    await repository.setAutomationMode("paused", 250);
 30+
 31+    const blocked = await center.processAssistantMessage({
 32+      assistantMessageId: "msg-system-paused-1",
 33+      conversationId: "conv-system-paused-1",
 34+      platform: "claude",
 35+      text: writeMessage
 36+    });
 37+
 38+    assert.equal(blocked.status, "system_paused");
 39+    assert.equal(blocked.executions.length, 0);
 40+    assert.equal(existsSync(outputPath), false);
 41+
 42+    const resume = await center.processAssistantMessage({
 43+      assistantMessageId: "msg-system-paused-resume-1",
 44+      conversationId: "conv-system-paused-1",
 45+      platform: "claude",
 46+      text: ["```baa", "@conductor::system/resume", "```"].join("\n")
 47+    });
 48+
 49+    assert.equal(resume.status, "executed");
 50+    assert.equal(resume.executions.length, 1);
 51+    assert.equal(resume.executions[0]?.ok, true);
 52+    assert.equal((await repository.getAutomationState())?.mode, "running");
 53+
 54+    const resumed = await center.processAssistantMessage({
 55+      assistantMessageId: "msg-system-paused-2",
 56+      conversationId: "conv-system-paused-1",
 57+      platform: "claude",
 58+      text: writeMessage
 59+    });
 60+
 61+    assert.equal(resumed.status, "executed");
 62+    assert.equal(resumed.executions.length, 1);
 63+    assert.equal(resumed.executions[0]?.ok, true);
 64+    assert.equal(readFileSync(outputPath, "utf8"), "system gate lifted");
 65+  } finally {
 66+    controlPlane.close();
 67+    rmSync(hostOpsDir, {
 68+      force: true,
 69+      recursive: true
 70+    });
 71+  }
 72+});
 73+
 74 test("BaaInstructionCenter executes browser.claude send/current through the Phase 1 route layer", async () => {
 75   const { controlPlane, repository, snapshot } = await createLocalApiFixture();
 76   const browser = createBrowserBridgeStub();
 77@@ -5010,6 +5079,98 @@ test("ConductorTimedJobs keeps standby runners idle and clears interval handles
 78   }
 79 });
 80 
 81+test("ConductorRuntime skips timed-jobs ticks while system automation is paused", async () => {
 82+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-system-paused-state-"));
 83+  const logsDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-system-paused-logs-"));
 84+  const runtime = new ConductorRuntime(
 85+    {
 86+      nodeId: "mini-main",
 87+      host: "mini",
 88+      role: "primary",
 89+      controlApiBase: "https://control.example.test",
 90+      localApiBase: "http://127.0.0.1:0",
 91+      sharedToken: "replace-me",
 92+      timedJobsIntervalMs: 50,
 93+      paths: {
 94+        logsDir,
 95+        runsDir: "/tmp/runs",
 96+        stateDir
 97+      }
 98+    },
 99+    {
100+      now: () => 250
101+    }
102+  );
103+
104+  try {
105+    const snapshot = await runtime.start();
106+    const baseUrl = snapshot.controlApi.localApiBase;
107+    const pauseResponse = await fetch(`${baseUrl}/v1/system/pause`, {
108+      body: JSON.stringify({
109+        reason: "pause_before_timed_jobs_tick",
110+        requested_by: "integration_test"
111+      }),
112+      headers: {
113+        "content-type": "application/json"
114+      },
115+      method: "POST"
116+    });
117+    assert.equal(pauseResponse.status, 200);
118+
119+    const timedJobsEntries = await waitForJsonlEntries(
120+      join(logsDir, "timed-jobs"),
121+      (items) =>
122+        items.some((entry) =>
123+          entry.runner === "timed-jobs.framework"
124+          && entry.stage === "tick_completed"
125+          && entry.result === "skipped_system_paused"
126+        )
127+        && items.some((entry) =>
128+          entry.runner === "renewal.projector"
129+          && entry.stage === "runner_skipped"
130+          && entry.result === "skipped_system_paused"
131+        )
132+        && items.some((entry) =>
133+          entry.runner === "renewal.dispatcher"
134+          && entry.stage === "runner_skipped"
135+          && entry.result === "skipped_system_paused"
136+        )
137+    );
138+
139+    assert.ok(
140+      timedJobsEntries.find((entry) =>
141+        entry.runner === "timed-jobs.framework"
142+        && entry.stage === "tick_completed"
143+        && entry.result === "skipped_system_paused"
144+      )
145+    );
146+    assert.ok(
147+      timedJobsEntries.find((entry) =>
148+        entry.runner === "renewal.projector"
149+        && entry.stage === "runner_skipped"
150+        && entry.result === "skipped_system_paused"
151+      )
152+    );
153+    assert.ok(
154+      timedJobsEntries.find((entry) =>
155+        entry.runner === "renewal.dispatcher"
156+        && entry.stage === "runner_skipped"
157+        && entry.result === "skipped_system_paused"
158+      )
159+    );
160+  } finally {
161+    await runtime.stop();
162+    rmSync(stateDir, {
163+      force: true,
164+      recursive: true
165+    });
166+    rmSync(logsDir, {
167+      force: true,
168+      recursive: true
169+    });
170+  }
171+});
172+
173 test("createFetchControlApiClient unwraps control-api envelopes and sends bearer auth", async () => {
174   const observedRequests = [];
175   const client = createFetchControlApiClient(
M apps/conductor-daemon/src/index.ts
+11, -1
 1@@ -14,6 +14,7 @@ import {
 2   DEFAULT_SUMMARY_LENGTH
 3 } from "../../../packages/artifact-db/dist/index.js";
 4 import {
 5+  DEFAULT_AUTOMATION_MODE,
 6   DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT,
 7   type ControlPlaneRepository
 8 } from "../../../packages/db/dist/index.js";
 9@@ -2318,7 +2319,16 @@ export class ConductorRuntime {
10         autoStart: options.autoStartLoops ?? true,
11         clearIntervalImpl: options.clearIntervalImpl,
12         logDir: timedJobsLogDir,
13-        schedule: (work) => this.daemon.runSchedulerPass(work),
14+        schedule: async (work) => {
15+          const automationState = await this.localControlPlane.repository.getAutomationState();
16+          const automationMode = automationState?.mode ?? DEFAULT_AUTOMATION_MODE;
17+
18+          if (automationMode === "paused") {
19+            return "skipped_system_paused";
20+          }
21+
22+          return this.daemon.runSchedulerPass(work);
23+        },
24         setIntervalImpl: options.setIntervalImpl
25       }
26     );
M apps/conductor-daemon/src/instructions/ingest.ts
+5, -1
 1@@ -28,7 +28,8 @@ export type BaaLiveInstructionIngestStatus =
 2   | "executed"
 3   | "failed"
 4   | "ignored_no_instructions"
 5-  | "parse_error_only";
 6+  | "parse_error_only"
 7+  | "system_paused";
 8 
 9 export interface BaaLiveInstructionIngestInput {
10   assistantMessageId: string;
11@@ -182,6 +183,8 @@ function classifyProcessStatus(status: BaaInstructionProcessStatus): BaaLiveInst
12       return "ignored_no_instructions";
13     case "parse_error_only":
14       return "parse_error_only";
15+    case "system_paused":
16+      return "system_paused";
17   }
18 }
19 
20@@ -194,6 +197,7 @@ function shouldUpdateExecutionSummary(status: BaaLiveInstructionIngestStatus): b
21     || status === "executed"
22     || status === "failed"
23     || status === "parse_error_only"
24+    || status === "system_paused"
25   );
26 }
27 
M apps/conductor-daemon/src/instructions/loop.ts
+28, -2
 1@@ -1,4 +1,5 @@
 2 import type { ConversationAutomationStatus } from "../../../../packages/artifact-db/dist/index.js";
 3+import { DEFAULT_AUTOMATION_MODE } from "../../../../packages/db/dist/index.js";
 4 
 5 import type { ConductorLocalApiContext } from "../local-api.js";
 6 import {
 7@@ -95,7 +96,9 @@ export class BaaInstructionCenter {
 8       };
 9     }
10 
11-    if (options.executionGateReason != null) {
12+    const systemAutomationMode = await this.loadSystemAutomationMode();
13+
14+    if (systemAutomationMode === "paused" && controlInstructions.length === 0) {
15       return {
16         blocks,
17         denied: [],
18@@ -103,7 +106,7 @@ export class BaaInstructionCenter {
19         executions: [],
20         instructions: selectedInstructions,
21         parseErrors,
22-        status: options.executionGateReason
23+        status: "system_paused"
24       };
25     }
26 
27@@ -119,6 +122,18 @@ export class BaaInstructionCenter {
28       };
29     }
30 
31+    if (options.executionGateReason != null) {
32+      return {
33+        blocks,
34+        denied: [],
35+        duplicates: [],
36+        executions: [],
37+        instructions: selectedInstructions,
38+        parseErrors,
39+        status: options.executionGateReason
40+      };
41+    }
42+
43     const duplicates: BaaInstructionEnvelope[] = [];
44     const pending: BaaInstructionEnvelope[] = [];
45 
46@@ -202,6 +217,17 @@ export class BaaInstructionCenter {
47     });
48   }
49 
50+  private async loadSystemAutomationMode(): Promise<"draining" | "paused" | "running"> {
51+    const repository = this.localApiContext.repository;
52+
53+    if (repository == null) {
54+      return DEFAULT_AUTOMATION_MODE;
55+    }
56+
57+    const automationState = await repository.getAutomationState();
58+    return automationState?.mode ?? DEFAULT_AUTOMATION_MODE;
59+  }
60+
61   private extract(text: string) {
62     try {
63       return extractBaaInstructionBlocks(text);
M apps/conductor-daemon/src/instructions/types.ts
+2, -1
 1@@ -13,7 +13,8 @@ export type BaaInstructionProcessStatus =
 2   | "duplicate_only"
 3   | "executed"
 4   | "no_instructions"
 5-  | "parse_error_only";
 6+  | "parse_error_only"
 7+  | "system_paused";
 8 
 9 export interface BaaExtractedBlock {
10   blockIndex: number;
M apps/conductor-daemon/src/timed-jobs/runtime.ts
+6, -5
 1@@ -10,7 +10,8 @@ export type TimedJobsTickDecision =
 2   | "scheduled"
 3   | "skipped_busy"
 4   | "skipped_no_runners"
 5-  | "skipped_not_leader";
 6+  | "skipped_not_leader"
 7+  | "skipped_system_paused";
 8 
 9 export interface TimedJobsConfig {
10   intervalMs: number;
11@@ -64,7 +65,7 @@ export interface TimedJobsTickResult {
12 
13 export type TimedJobsSchedule = (
14   work: (context: TimedJobScheduleContext) => Promise<void>
15-) => Promise<"scheduled" | "skipped_not_leader">;
16+) => Promise<"scheduled" | "skipped_not_leader" | "skipped_system_paused">;
17 
18 type TimedJobsAppendFile = (filePath: string, data: string) => Promise<void>;
19 
20@@ -288,7 +289,7 @@ export class ConductorTimedJobs {
21         }
22       });
23 
24-      if (decision === "skipped_not_leader") {
25+      if (decision === "skipped_not_leader" || decision === "skipped_system_paused") {
26         for (const runner of runners) {
27           this.writeRunnerLog(runner.name, {
28             batchId,
29@@ -296,7 +297,7 @@ export class ConductorTimedJobs {
30               trigger
31             },
32             durationMs: 0,
33-            result: "skipped_not_leader",
34+            result: decision,
35             stage: "runner_skipped"
36           });
37         }
38@@ -308,7 +309,7 @@ export class ConductorTimedJobs {
39             trigger
40           },
41           durationMs: Date.now() - tickStartedAt,
42-          result: "skipped_not_leader",
43+          result: decision,
44           stage: "tick_completed"
45         });
46 
M docs/api/control-interfaces.md
+8, -0
 1@@ -113,6 +113,13 @@ browser/plugin 管理约定:
 2 | `POST` | `/v1/system/resume` | 切到 `running` |
 3 | `POST` | `/v1/system/drain` | 切到 `draining` |
 4 
 5+system 控制约定:
 6+
 7+- `POST /v1/system/pause` 只切系统级 gate,不改各对话已有 `automation_status`、`pause_reason` 或 `last_non_paused_automation_status`
 8+- system mode 为 `paused` 时,普通 BAA 指令会在 live ingest 中记录为 `system_paused`,不会进入实际执行
 9+- system mode 为 `paused` 时,timed-jobs 整个 tick 会在调度层跳过,并写出 `skipped_system_paused`
10+- `POST /v1/system/resume` 只恢复系统级 gate;已经是 `manual` 或 `paused` 的对话不会被自动改回 `auto`
11+
12 ### 续命自动化控制
13 
14 | 方法 | 路径 | 作用 |
15@@ -142,6 +149,7 @@ browser/plugin 管理约定:
16   - `@conductor::conversation/mode::{"scope":"current","mode":"auto"}`
17   - `@conductor::system/pause`
18   - `@conductor::system/resume`
19+- system paused 时仍允许显式 system 控制指令通过,以便后续 final-message 可发出 `@conductor::system/resume`
20 
21 ### 本机 Host Ops
22 
M docs/api/firefox-local-ws.md
+7, -0
 1@@ -165,6 +165,13 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 2   - `last_execute`
 3   - `recent_ingests`
 4   - `recent_executes`
 5+- `snapshot.browser.instruction_ingest.*.status` 常见值包括:
 6+  - `executed`
 7+  - `duplicate_message`
 8+  - `automation_busy`
 9+  - `automation_paused`
10+  - `system_paused`
11+- 当 `snapshot.system.mode === "paused"` 时,普通 BAA 指令会写成 `system_paused`;system 状态本身仍通过 `snapshot.system` 单独同步
12 - 上述摘要历史来自 conductor 本地有界 journal;进程重启后仍可恢复
13 - `snapshot.browser.clients[].request_hooks` 只回传 endpoint 列表、`endpoint_metadata` 和更新时间
14 
M tasks/T-S062.md
+22, -6
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- 当前状态:`待开始`
 6+- 当前状态:`已完成`
 7 - 规模预估:`M`
 8 - 依赖任务:`T-S060`
 9 - 建议执行者:`Codex`(以后端状态门控、timed-jobs 和控制接口为主)
10@@ -150,22 +150,38 @@
11 
12 ### 开始执行
13 
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-04-01 15:12:07 CST`
18 - 状态变更:`待开始` → `进行中`
19 
20 ### 完成摘要
21 
22-- 完成时间:
23+- 完成时间:`2026-04-01 15:57:43 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+  - `apps/conductor-daemon/src/instructions/types.ts`
27+  - `apps/conductor-daemon/src/instructions/ingest.ts`
28+  - `apps/conductor-daemon/src/instructions/loop.ts`
29+  - `apps/conductor-daemon/src/timed-jobs/runtime.ts`
30+  - `apps/conductor-daemon/src/index.ts`
31+  - `apps/conductor-daemon/src/index.test.js`
32+  - `docs/api/control-interfaces.md`
33+  - `docs/api/firefox-local-ws.md`
34+  - `tasks/T-S062.md`
35+  - `tasks/TASK_OVERVIEW.md`
36 - 核心实现思路:
37+  - 在 `BaaInstructionCenter` 接入系统级 `automation.mode` 读取,系统为 `paused` 时把普通 BAA 指令直接记为 `system_paused` 并阻断执行,同时保留 `@conductor::system/pause` / `resume` 这类显式控制指令的通路
38+  - 在 `ConductorRuntime` 的 timed-jobs 调度包装层先检查系统级 mode;命中 `paused` 时整批 tick 返回 `skipped_system_paused`,framework 和 runner JSONL 日志都会写出同名结果
39+  - 补文档说明 `/v1/system/*` 的全局 gate 语义,以及 Firefox WS `instruction_ingest` 在系统暂停时的 `system_paused` 状态
40 - 跑了哪些测试:
41+  - `cd /Users/george/code/baa-conductor-system-level-automation-pause && pnpm install`
42+  - `cd /Users/george/code/baa-conductor-system-level-automation-pause && pnpm -C apps/conductor-daemon test`
43+  - `cd /Users/george/code/baa-conductor-system-level-automation-pause && pnpm -C packages/db test`
44 
45 ### 执行过程中遇到的问题
46 
47-- 暂无
48+- 新建 worktree 初始缺少 `node_modules`,`pnpm exec tsc` 报 `Command "tsc" not found`;先在 worktree 根目录执行 `pnpm install` 后恢复正常
49 
50 ### 剩余风险
51 
52-- 暂无
53+- 当前实现会阻断新的 BAA 指令执行和 timed-jobs tick,但不会强制中断已经在执行中的单次 browser proxy / host-op 请求;如果后续需要“立刻打断”语义,还要补 execution cancel 机制
M tasks/TASK_OVERVIEW.md
+3, -4
 1@@ -47,6 +47,7 @@
 2   - Firefox 浮层统一自动化控制已经落地:页面入口会同步显示系统状态、当前对话 `automation_status` 和 `pause_reason`,并通过 WS 与 renewal/page_control 保持一致
 3   - BAA normalize / parse 现在按 block 做错误隔离,单个坏 block 不再中断整批合法指令
 4   - timed-jobs JSONL 日志现在已改为异步写入,减少 tick 周期内的同步 IO 阻塞
 5+  - 系统级 automation pause 已落地:`system paused` 会同时阻断 live BAA 指令和 timed-jobs 主链,且不会覆盖各对话原有 `pause_reason`
 6 
 7 ## 当前已确认的不一致
 8 
 9@@ -78,6 +79,7 @@
10 | [`T-S059`](./T-S059.md) | 续命执行任务与运维接口 | M | T-S055, T-S056, T-S057, T-S058 | Codex | 已完成 |
11 | [`T-S060`](./T-S060.md) | 自动化仲裁与自动熔断基础 | L | T-S056, T-S058, T-S059 | Codex | 已完成 |
12 | [`T-S061`](./T-S061.md) | 浮层统一自动化控制 | M | T-S060 | Codex | 已完成 |
13+| [`T-S062`](./T-S062.md) | 系统级暂停接入自动化主链 | M | T-S060 | Codex | 已完成 |
14 | [`T-S063`](./T-S063.md) | normalize / parse 错误隔离 | S | 无 | Codex | 已完成 |
15 | [`T-S064`](./T-S064.md) | timed-jobs 异步日志写入 | S | 无 | Codex | 已完成 |
16 
17@@ -85,7 +87,6 @@
18 
19 | 项目 | 标题 | 类型 | 状态 | 说明 |
20 |---|---|---|---|---|
21-| [`T-S062`](./T-S062.md) | 系统级暂停接入自动化主链 | task | 待开始 | 把系统级暂停接入 BAA 与 timed-jobs 主链 |
22 | [`T-S066`](./T-S066.md) | 风控状态持久化 | task | 待开始 | 让限流、退避、熔断和 `pause_reason` 在重启后恢复 |
23 | [`T-S069`](./T-S069.md) | proxy_delivery 成功语义增强 | task | 待开始 | 至少补齐下游 HTTP 状态码回传,避免“已派发”直接等于成功 |
24 | [`T-S068`](./T-S068.md) | ChatGPT proxy send 冷启动降级保护 | task | 待开始 | 减少插件重载后首批 delivery 直接失败或退回 DOM fallback |
25@@ -140,7 +141,6 @@
26 
27 ### P1(并行优化)
28 
29-- [`T-S062`](./T-S062.md)
30 - [`T-S066`](./T-S066.md)
31 
32 ### P2(次级优化)
33@@ -185,9 +185,8 @@
34 
35 ## 当前主线判断
36 
37-Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S063`、`T-S064` 已经落地。当前主线已经没有 open bug blocker,下一步是:
38+Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S062`、`T-S063`、`T-S064` 已经落地。当前主线已经没有 open bug blocker,下一步是:
39 
40-- 先做 `T-S062`
41 - 并行推进 `T-S066`
42 - 然后收口 `T-S069`
43 - 再做 `T-S068`、`T-S065`