- 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
+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(
+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 );
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
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);
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;
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
+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
+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
+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 机制
+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`