baa-conductor

git clone 

commit
d1c9090
parent
d349be1
author
im_wower
date
2026-03-25 23:35:11 +0800 CST
merge: land T-S006 and T-S008
21 files changed,  +361, -60
M README.md
+2, -2
 1@@ -6,7 +6,7 @@
 2 
 3 - canonical local API: `http://100.71.210.78:4317`
 4 - canonical public host: `https://conductor.makefile.so`
 5-- `control-api.makefile.so`、Cloudflare Worker、D1 只算迁移期兼容件,不再作为默认控制面
 6+- `control-api.makefile.so`、Cloudflare Worker、D1 只算迁移期 legacy 兼容件和残留依赖盘点目标,不再作为默认控制面
 7 - `baa-hand` / `baa-shell` 只保留为接口参考,不再继续维护为主系统
 8 
 9 主备切换、failover、switchback 和其它历史方案已经从当前主线移除;如需回溯,直接查看 tag `ha-failover-archive-2026-03-22`。
10@@ -106,7 +106,7 @@ docs/
11 
12 legacy 兼容说明:
13 
14-- `https://control-api.makefile.so` 只用于迁移期间兜底和识别残留依赖
15+- `https://control-api.makefile.so` 只用于迁移期间 legacy 兜底和识别残留依赖
16 - 业务查询和系统控制写入已经不再依赖 Cloudflare Worker / D1 真相源
17 - `apps/control-api-worker` 已从当前仓库移除;剩余 legacy 只保留在线上资产和历史文档背景里
18 
M apps/codexd/src/app-server-transport.ts
+60, -9
  1@@ -21,8 +21,24 @@ export interface CodexdAppServerProcessLike {
  2   on(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
  3 }
  4 
  5+export type CodexdAppServerTransportCloseSource =
  6+  | "process.error"
  7+  | "process.exit"
  8+  | "stdout.end"
  9+  | "stdout.error";
 10+
 11+export interface CodexdAppServerTransportCloseDiagnostic {
 12+  exitCode?: number | null;
 13+  flushedTrailingMessage: boolean;
 14+  message: string;
 15+  signal?: string | null;
 16+  source: CodexdAppServerTransportCloseSource;
 17+  trailingMessageLength: number;
 18+}
 19+
 20 export interface CodexdAppServerStdioTransportConfig {
 21   endStdinOnClose?: boolean;
 22+  onCloseDiagnostic?: (diagnostic: CodexdAppServerTransportCloseDiagnostic) => void;
 23   process: CodexdAppServerProcessLike;
 24 }
 25 
 26@@ -63,23 +79,47 @@ export function createCodexdAppServerStdioTransport(
 27     }
 28   };
 29 
 30-  const flushTrailingMessage = (): void => {
 31+  const flushTrailingMessage = (): {
 32+    flushedTrailingMessage: boolean;
 33+    trailingMessageLength: number;
 34+  } => {
 35     const line = buffer.trim();
 36     buffer = "";
 37 
 38-    if (line !== "") {
 39-      handlers?.onMessage(line);
 40+    if (line === "") {
 41+      return {
 42+        flushedTrailingMessage: false,
 43+        trailingMessageLength: 0
 44+      };
 45     }
 46+
 47+    handlers?.onMessage(line);
 48+    return {
 49+      flushedTrailingMessage: true,
 50+      trailingMessageLength: line.length
 51+    };
 52   };
 53 
 54-  const closeTransport = (error: Error): void => {
 55+  const closeTransport = (
 56+    error: Error,
 57+    diagnostic: Omit<
 58+      CodexdAppServerTransportCloseDiagnostic,
 59+      "flushedTrailingMessage" | "message" | "trailingMessageLength"
 60+    >
 61+  ): void => {
 62     if (closed) {
 63       return;
 64     }
 65 
 66-    flushTrailingMessage();
 67+    const trailingMessage = flushTrailingMessage();
 68     closed = true;
 69     connected = false;
 70+    config.onCloseDiagnostic?.({
 71+      ...diagnostic,
 72+      flushedTrailingMessage: trailingMessage.flushedTrailingMessage,
 73+      message: error.message,
 74+      trailingMessageLength: trailingMessage.trailingMessageLength
 75+    });
 76     handlers?.onClose(error);
 77   };
 78 
 79@@ -109,19 +149,30 @@ export function createCodexdAppServerStdioTransport(
 80         emitBufferedMessages();
 81       });
 82       stdout.on("end", () => {
 83-        closeTransport(new Error("Codex app-server stdio stdout ended."));
 84+        closeTransport(new Error("Codex app-server stdio stdout ended."), {
 85+          source: "stdout.end"
 86+        });
 87       });
 88       stdout.on("error", (error) => {
 89-        closeTransport(error);
 90+        closeTransport(error, {
 91+          source: "stdout.error"
 92+        });
 93       });
 94       config.process.on("error", (error) => {
 95-        closeTransport(error);
 96+        closeTransport(error, {
 97+          source: "process.error"
 98+        });
 99       });
100       config.process.on("exit", (code, signal) => {
101         closeTransport(
102           new Error(
103             `Codex app-server stdio child exited (code=${String(code)}, signal=${String(signal)}).`
104-          )
105+          ),
106+          {
107+            exitCode: code,
108+            signal,
109+            source: "process.exit"
110+          }
111         );
112       });
113     },
M apps/codexd/src/daemon.ts
+69, -0
 1@@ -33,6 +33,7 @@ import {
 2 } from "../../../packages/codex-exec/src/index.js";
 3 import {
 4   createCodexdAppServerStdioTransport,
 5+  type CodexdAppServerTransportCloseDiagnostic,
 6   type CodexdAppServerProcessLike
 7 } from "./app-server-transport.js";
 8 import type {
 9@@ -816,6 +817,71 @@ export class CodexdDaemon {
10     await this.stateStore.recordEvent(mapAppServerEventToRecentEvent(event));
11   }
12 
13+  private async handleAppServerTransportClosed(
14+    diagnostic: CodexdAppServerTransportCloseDiagnostic
15+  ): Promise<void> {
16+    this.appServerClient = null;
17+    this.appServerInitializeResult = null;
18+    this.appServerInitializationPromise = null;
19+
20+    await this.stateStore.recordEvent({
21+      level: "warn",
22+      type: "app-server.transport.closed",
23+      message: `App-server transport closed via ${diagnostic.source}: ${diagnostic.message}`,
24+      detail: {
25+        exitCode: diagnostic.exitCode ?? null,
26+        flushedTrailingMessage: diagnostic.flushedTrailingMessage,
27+        signal: diagnostic.signal ?? null,
28+        source: diagnostic.source,
29+        trailingMessageLength: diagnostic.trailingMessageLength
30+      }
31+    });
32+
33+    const childState = this.stateStore.getChildState();
34+    const interruptedSessions = this.stateStore
35+      .listSessions()
36+      .filter(
37+        (session) =>
38+          session.status === "active" && session.threadId != null && session.currentTurnId != null
39+      );
40+
41+    for (const session of interruptedSessions) {
42+      const turnId = session.currentTurnId ?? session.lastTurnId;
43+
44+      if (turnId == null) {
45+        continue;
46+      }
47+
48+      await this.stateStore.upsertSession({
49+        ...session,
50+        currentTurnId: null,
51+        lastTurnId: turnId,
52+        lastTurnStatus: "failed",
53+        updatedAt: new Date().toISOString()
54+      });
55+      await this.stateStore.recordEvent({
56+        level: "error",
57+        type: "app-server.turn.completed.missing",
58+        message: `App-server transport closed before turn ${turnId} completed.`,
59+        detail: {
60+          childExitCode: diagnostic.exitCode ?? childState.exitCode,
61+          childPid: childState.pid,
62+          childSignal: diagnostic.signal ?? childState.signal,
63+          childStatus: childState.status,
64+          failureClass: "transport_closed_before_turn_completed",
65+          flushedTrailingMessage: diagnostic.flushedTrailingMessage,
66+          sessionId: session.sessionId,
67+          threadId: session.threadId,
68+          trailingMessageLength: diagnostic.trailingMessageLength,
69+          transportCloseMessage: diagnostic.message,
70+          transportCloseSource: diagnostic.source,
71+          turnId,
72+          turnStatusAtClose: session.lastTurnStatus
73+        }
74+      });
75+    }
76+  }
77+
78   private async patchSessionsByThreadId(
79     threadId: string,
80     update: (session: CodexdSessionRecord) => CodexdSessionRecord
81@@ -928,6 +994,9 @@ export class CodexdDaemon {
82           version: this.config.version ?? DEFAULT_APP_SERVER_VERSION
83         },
84         transport: createCodexdAppServerStdioTransport({
85+          onCloseDiagnostic: (diagnostic) => {
86+            void this.handleAppServerTransportClosed(diagnostic);
87+          },
88           process: context.child
89         })
90       });
M apps/codexd/src/index.test.js
+126, -16
  1@@ -134,6 +134,25 @@ class FakeRpcAppServerChild extends FakeChild {
  2     this.stdout.emit("data", `${JSON.stringify(payload)}${trailingNewline ? "\n" : ""}`);
  3   }
  4 
  5+  finishRetryingTurn(session, turnId, completedTurn) {
  6+    session.thread.turns = session.thread.turns.map((entry) =>
  7+      entry.id === turnId ? completedTurn : entry
  8+    );
  9+    this.emitRpcMessage(
 10+      {
 11+        method: "turn/completed",
 12+        params: {
 13+          threadId: session.thread.id,
 14+          turn: completedTurn
 15+        }
 16+      },
 17+      {
 18+        trailingNewline: false
 19+      }
 20+    );
 21+    this.stdout.emit("end");
 22+  }
 23+
 24   handleRequest(request) {
 25     switch (request.method) {
 26       case "initialize":
 27@@ -263,22 +282,7 @@ class FakeRpcAppServerChild extends FakeChild {
 28               }
 29             });
 30             setTimeout(() => {
 31-              session.thread.turns = session.thread.turns.map((entry) =>
 32-                entry.id === turnId ? completedTurn : entry
 33-              );
 34-              this.emitRpcMessage(
 35-                {
 36-                  method: "turn/completed",
 37-                  params: {
 38-                    threadId: session.thread.id,
 39-                    turn: completedTurn
 40-                  }
 41-                },
 42-                {
 43-                  trailingNewline: false
 44-                }
 45-              );
 46-              this.stdout.emit("end");
 47+              this.finishRetryingTurn(session, turnId, completedTurn);
 48             }, 25);
 49             return;
 50           }
 51@@ -311,6 +315,12 @@ class FakeRpcAppServerChild extends FakeChild {
 52   }
 53 }
 54 
 55+class FakeRpcAppServerDisconnectBeforeCompletedChild extends FakeRpcAppServerChild {
 56+  finishRetryingTurn() {
 57+    this.stdout.emit("end");
 58+  }
 59+}
 60+
 61 class FakeAppServerAdapter {
 62   constructor(defaultCwd, options = {}) {
 63     this.defaultCwd = defaultCwd;
 64@@ -852,6 +862,106 @@ test("CodexdDaemon flushes the final stdio completion event so sequential sessio
 65   }
 66 });
 67 
 68+test("CodexdDaemon classifies transport closes before a legal completion as a new failure", async () => {
 69+  const repoRoot = mkdtempSync(join(tmpdir(), "codexd-stdio-disconnect-test-"));
 70+  const config = resolveCodexdConfig({
 71+    logsDir: join(repoRoot, "logs"),
 72+    repoRoot,
 73+    stateDir: join(repoRoot, "state")
 74+  });
 75+  const fakeChild = new FakeRpcAppServerDisconnectBeforeCompletedChild();
 76+  const daemon = new CodexdDaemon(config, {
 77+    env: {
 78+      HOME: repoRoot
 79+    },
 80+    spawner: {
 81+      spawn(command, args, options) {
 82+        assert.equal(command, "codex");
 83+        assert.deepEqual(args, ["app-server"]);
 84+        assert.equal(options.cwd, repoRoot);
 85+
 86+        queueMicrotask(() => {
 87+          fakeChild.emit("spawn");
 88+        });
 89+
 90+        return fakeChild;
 91+      }
 92+    }
 93+  });
 94+
 95+  await daemon.start();
 96+
 97+  try {
 98+    const firstSession = await daemon.createSession({
 99+      cwd: repoRoot,
100+      purpose: "duplex"
101+    });
102+    await daemon.createTurn({
103+      input: "First turn.",
104+      sessionId: firstSession.sessionId
105+    });
106+    await waitFor(() => {
107+      const current = daemon.getSession(firstSession.sessionId);
108+      return current?.lastTurnStatus === "completed" ? current : null;
109+    });
110+
111+    const secondSession = await daemon.createSession({
112+      cwd: repoRoot,
113+      purpose: "duplex"
114+    });
115+    const secondTurn = await daemon.createTurn({
116+      input: "Second turn.",
117+      sessionId: secondSession.sessionId
118+    });
119+
120+    const failedSecondSession = await waitFor(() => {
121+      const current = daemon.getSession(secondSession.sessionId);
122+      const missingCompletedEvent = daemon
123+        .getStatusSnapshot()
124+        .recentEvents.events.find(
125+          (event) =>
126+            event.type === "app-server.turn.completed.missing" &&
127+            event.detail?.turnId === secondTurn.turnId
128+        );
129+
130+      return current?.lastTurnStatus === "failed" && missingCompletedEvent != null
131+        ? {
132+            event: missingCompletedEvent,
133+            session: current
134+          }
135+        : null;
136+    });
137+    assert.equal(failedSecondSession.session.currentTurnId, null);
138+    assert.equal(failedSecondSession.session.lastTurnId, secondTurn.turnId);
139+    assert.equal(failedSecondSession.session.lastTurnStatus, "failed");
140+    assert.deepEqual(failedSecondSession.event.detail, {
141+      childExitCode: null,
142+      childPid: 4242,
143+      childSignal: null,
144+      childStatus: "running",
145+      failureClass: "transport_closed_before_turn_completed",
146+      flushedTrailingMessage: false,
147+      sessionId: secondSession.sessionId,
148+      threadId: secondSession.threadId,
149+      trailingMessageLength: 0,
150+      transportCloseMessage: "Codex app-server stdio stdout ended.",
151+      transportCloseSource: "stdout.end",
152+      turnId: secondTurn.turnId,
153+      turnStatusAtClose: "inProgress"
154+    });
155+    assert.equal(
156+      daemon.getStatusSnapshot().recentEvents.events.some(
157+        (event) =>
158+          event.type === "app-server.turn.completed" &&
159+          event.detail?.turnId === secondTurn.turnId
160+      ),
161+      false
162+    );
163+  } finally {
164+    await daemon.stop();
165+  }
166+});
167+
168 async function fetchJson(url, init) {
169   const response = await fetch(url, init);
170 
M bugs/BUG-008-codexd-second-thread-turn-timeout.md
+23, -1
 1@@ -11,6 +11,14 @@
 2 - session B 的最终 `turn/completed` 以无换行尾包到达
 3 - codexd 仍然把 `lastTurnStatus` 正确收口到 `completed`
 4 
 5+同时,`codexd` 现在会把“合法 `turn/completed` 根本没到,就先 transport close”的场景单独分类:
 6+
 7+- recent events 先记 `app-server.transport.closed`
 8+- 若关闭时还有 `currentTurnId`,再记 `app-server.turn.completed.missing`
 9+- `detail.failureClass = "transport_closed_before_turn_completed"`
10+
11+这类诊断不再归到 `BUG-008`。
12+
13 ## 现象
14 
15 向 codexd 创建第一个 session 并发 turn,成功完成(turn.completed)。
16@@ -94,6 +102,20 @@ High —— 单次对话能通,但多轮/多 session 场景(即 duplex 核
17 - 第二次失败的 error detail: codexErrorInfo.responseStreamDisconnected.httpStatusCode = null,additionalDetails = "timeout waiting for child process to exit"
18 - child 进程(PID 20904)在失败后仍处于 running 状态,不是 crash
19 
20+## Reopen 规则
21+
22+下面这些仍属于已修复范围,不 reopen `BUG-008`:
23+
24+- recent events 能看到该 turn 的 `app-server.turn.completed`
25+- 即使 completed 落在无换行尾包,session 最终仍收口到 `lastTurnStatus: completed`
26+- 中间出现 `Reconnecting... N/5` / `willRetry=true`,但最终 completed 到达
27+
28+下面这些应新开 bug,不算 `BUG-008` 复发:
29+
30+- `app-server.transport.closed` 出现后,该 turn 没有任何合法 `app-server.turn.completed`
31+- `codexd` 追加 `app-server.turn.completed.missing`
32+- 事件 detail 标明 `failureClass = "transport_closed_before_turn_completed"`
33+
34 ## 剩余风险
35 
36-如果后续在线上再次复现“第二个 session 必然卡死”,且日志能证明 `turn/completed` 根本没有从 app-server 发出,那么应作为新的独立 bug 重新打开;当前代码与测试还没有看到这类证据。
37+当前代码与测试已经把“尾包无换行但 completed 实际发出”和“transport 提前断流导致 completed 缺失”拆成两类;后一类如果在线上再次出现,应作为新的 child / transport 故障单独跟踪。
M bugs/BUG-010-codexd-turn-status-stuck-inprogress.md
+23, -1
 1@@ -6,6 +6,13 @@
 2 
 3 `apps/codexd/src/app-server-transport.ts` 现在会在 stdio 连接关闭前先冲刷尾部缓冲区,确保最后一条没有换行的 JSON-RPC 消息也会被交给 `CodexAppServerClient`。`apps/codexd/src/index.test.js` 新增了顺序两个 session 的回归测试,覆盖第二个 turn 先 `willRetry=true`、再以无换行尾包发出 `turn/completed` 的路径。
 4 
 5+另外,`codexd` 现在会对“没有合法 completed 就先断流”给出独立诊断:
 6+
 7+- recent events 记录 `app-server.transport.closed`
 8+- 若关闭时 turn 仍未完成,额外记录 `app-server.turn.completed.missing`
 9+- 对应 session 会从 `inProgress` 明确收口到 `failed`
10+- 事件 detail 带 `failureClass = "transport_closed_before_turn_completed"`
11+
12 ## 现象
13 
14 向 codexd 提交 turn 后,Codex 实际上已经产出完整回复(agentMessage 在 events.jsonl 中有记录),但 session 的 `lastTurnStatus` 始终停留在 `inProgress`,不更新为 `completed`。
15@@ -105,6 +112,21 @@ High —— 核心链路(发消息 → 得到完成状态)不通,调用方
16 - events.jsonl 里已确认的完整 agentMessage:「可以,我现在能稳定工作。」,对应 turn 019d1b95-b947-72c1-b6c2-02ee0a56202e
17 - lastTurnStatus 可能的值:inProgress / completed / failed;问题是 completed 从未出现
18 
19+## Reopen 规则
20+
21+下面这些仍算 `BUG-010` 已修复范围,不 reopen:
22+
23+- `turn/completed` 的确发出了,只是恰好落在无换行尾包
24+- recent events 中能看到该 turn 的 `app-server.turn.completed`
25+- session 最终收口到 `lastTurnStatus: completed`
26+
27+下面这些应视为新的 bug:
28+
29+- transport / child 提前断流,recent events 出现 `app-server.transport.closed`
30+- 同一 turn 没有任何合法 `app-server.turn.completed`
31+- `codexd` 记录 `app-server.turn.completed.missing`
32+- 事件 detail 标明 `failureClass = "transport_closed_before_turn_completed"`
33+
34 ## 剩余风险
35 
36-如果未来再次出现 `stdout` 提前结束,但最后根本没有发出合法的 `turn/completed` 消息,这会是新的 child / transport 故障,不属于这次修复覆盖的范围。
37+未来如果再次出现 `stdout` 提前结束,且最终没有合法 `turn/completed`,现在会被 codexd 明确归类为新的 child / transport 故障,不再混入本 bug。
M bugs/README.md
+2, -2
 1@@ -8,9 +8,9 @@
 2 
 3 按当前工作区状态,这 3 个 bug 都已完成修复,但仍保留文档作为根因与回归记录:
 4 
 5-1. `BUG-008` — 已随 `BUG-010` 一并修复;如果未来再次出现“根本没有发出合法 turn/completed 就断流”,应作为新 bug reopen
 6+1. `BUG-008` — 已随 `BUG-010` 一并修复;如果后续 recent events 出现 `app-server.turn.completed.missing` / `failureClass=transport_closed_before_turn_completed`,应作为新的 child / transport bug 记录,而不是 reopen 旧尾包问题
 7 2. `BUG-009` — 已修复;如果后续测试绕开 `withRuntimeFixture(...)` 或关闭路径本身阻塞,仍需新开专项问题
 8-3. `BUG-010` — 已修复;当前剩余风险与 child / transport 提前断流场景有关
 9+3. `BUG-010` — 已修复;如果后续还有 `app-server.transport.closed` 且同一 turn 没有合法 `app-server.turn.completed`,应按新 bug 跟踪
10 
11 修复任务:
12 
M docs/api/README.md
+2, -2
 1@@ -21,7 +21,7 @@
 2 
 3 - `conductor-daemon` 本地 API 是这些业务接口的真相源
 4 - `status-api` 仍是只读状态视图
 5-- Cloudflare Worker / D1 / `control-api.makefile.so` 都不再是这批业务接口的主路径
 6+- Cloudflare Worker / D1 / `control-api.makefile.so` 都只剩 legacy 兼容或残留依赖盘点背景,不再是这批业务接口的主路径
 7 
 8 ## 入口
 9 
10@@ -279,7 +279,7 @@ truth source:
11 - `https://conductor.makefile.so` 是同一套 conductor 主接口的公网入口;只有本地 `4317` 不可达时才需要显式改到公网
12 - `BAA_CONTROL_API_BASE` 只保留两个兼容点:`conductor-daemon` 仍读取这个历史变量名作为 upstream/public API base,`status-api` 只在手工或旧配置缺少 `BAA_CONDUCTOR_LOCAL_API` 时回退使用
13 - 默认 launchd 不再给 `status-api` 写入 `BAA_CONTROL_API_BASE`
14-- legacy `control-api.makefile.so` 不再是默认或 canonical truth source
15+- 如果旧文档或配置里还出现 legacy `control-api.makefile.so`,只能按迁移兼容 / 残留依赖盘点目标理解,绝不再是默认或 canonical truth source
16 - `status-api` 负责把该状态整理成 JSON 或 HTML
17 
18 当前端点:
M docs/api/business-interfaces.md
+1, -1
1@@ -28,7 +28,7 @@
2 
3 - 本地 `4317` 是当前主真相源
4 - `conductor.makefile.so` 是公网访问入口
5-- 不再提供单独 `control-api` 域名作为业务接口主入口
6+- 如果文档或旧配置还提到单独 `control-api` 域名,也只按 legacy 兼容 / 残留依赖盘点理解,不再作为业务接口主入口
7 
8 ## 给 AI 的最小使用规则
9 
M docs/api/hand-shell-migration.md
+4, -4
 1@@ -40,7 +40,7 @@
 2 
 3 - `baa-hand` / `baa-shell` 只作为参考实现
 4 - `baa-conductor` 成为唯一主项目
 5-- `control-api.makefile.so` 不再作为默认业务接口
 6+- `control-api.makefile.so` 不再作为默认业务接口;如果线上仍保留同名域名,也只按 legacy 兼容背景理解
 7 
 8 ## 3. 迁移边界
 9 
10@@ -195,7 +195,7 @@
11 - local: `http://100.71.210.78:4317`
12 - public: `https://conductor.makefile.so`
13 
14-不再保留单独 `control-api.makefile.so` 作为兼容或默认目标。
15+不再把单独 `control-api.makefile.so` 写成默认或推荐目标;如果线上仍保留同名域名,也只按 legacy 兼容或残留依赖盘点目标理解。
16 
17 ## 8. 实施顺序
18 
19@@ -250,13 +250,13 @@
20 ### 第四批(当前主线已完成)
21 
22 - 浏览器、CLI、运维脚本和文档默认目标已经切到 `conductor.makefile.so`
23-- `control-api.makefile.so`、Cloudflare Worker、D1 与 public `status-api` 不再承担默认控制面角色
24+- `control-api.makefile.so`、Cloudflare Worker、D1 与 public `status-api` 即使仍有线上残留,也不再承担默认控制面角色
25 
26 ## 9. 删旧范围
27 
28 cutover 完成后,优先删除或归档:
29 
30-- `control-api.makefile.so` 作为主业务接口的所有描述
31+- 把 `control-api.makefile.so` 写成主业务接口的所有当前时态描述
32 - Cloudflare Worker / D1 的默认控制面角色
33 - `status-api` 的公网职责
34 - `baa-hand` / `baa-shell` 作为主系统的维护承诺
M docs/runtime/README.md
+1, -1
1@@ -20,7 +20,7 @@
2 - canonical public host: `https://conductor.makefile.so`
3 - `status-api` `http://100.71.210.78:4318` 只作为本地只读观察面,默认回源 `BAA_CONDUCTOR_LOCAL_API`,当前 canonical 值是 `http://100.71.210.78:4317`
4 - `https://conductor.makefile.so` 是同一套 conductor 主接口的公网入口
5-- 默认 launchd 只把 `BAA_CONTROL_API_BASE` 写给 `conductor`;`status-api` 只保留代码层兼容回退,不再把它当 canonical truth source
6+- 默认 launchd 只把 legacy 变量名 `BAA_CONTROL_API_BASE` 写给 `conductor`;`status-api` 只保留代码层兼容回退,不再把它当 canonical truth source
7 - 推荐仓库路径:`/Users/george/code/baa-conductor`
8 - repo 内的 plist 只作为模板;真正加载的是脚本渲染出来的安装副本
9 
M docs/runtime/codexd.md
+18, -0
 1@@ -112,6 +112,24 @@ BAA_CODEXD_LOCAL_API_BASE=http://127.0.0.1:4319
 2 - 只有终态失败才把 session 的 `lastTurnStatus` 写成 `failed`
 3 - stdio transport 在 child `stdout` 结束或进程退出前,会先冲刷最后一个未换行的 JSON-RPC 消息,再关闭连接;`turn/completed` 不会再因为尾包没有换行而丢失
 4 - 顺序创建多个 session / thread 时,即使后一个 turn 先收到 `willRetry=true` 的错误,再在流尾收到最终完成事件,session 的 `lastTurnStatus` 也应收口到 `completed`
 5+- 如果 stdio transport 提前关闭,recent events 会先记录 `app-server.transport.closed`
 6+- 如果关闭时某个 session 仍有 `currentTurnId`,codexd 会把该 turn 收口为 `failed`,并追加 `app-server.turn.completed.missing`
 7+- 这类事件会带 `detail.failureClass = "transport_closed_before_turn_completed"`,用来明确区分“合法 completed 缺失”与“completed 已发出但尾包无换行”的旧问题
 8+
 9+## Reopen 规则
10+
11+下面这些仍属于已修复的旧尾包问题范围:
12+
13+- recent events 能看到目标 turn 的 `app-server.turn.completed`
14+- session 最终收口到 `lastTurnStatus: completed`
15+- transport close 前冲刷了无换行尾包,completed 成功入库
16+
17+下面这些应按新的 child / transport 故障 reopen:
18+
19+- recent events 出现 `app-server.transport.closed`
20+- 同一 turn 没有任何合法 `app-server.turn.completed`
21+- codexd 额外记录 `app-server.turn.completed.missing`
22+- 事件 detail 标明 `failureClass = "transport_closed_before_turn_completed"`
23 
24 ## 运行职责边界
25 
M docs/runtime/environment.md
+2, -2
 1@@ -9,7 +9,7 @@
 2 - canonical public host: `https://conductor.makefile.so`
 3 - local status view: `http://100.71.210.78:4318`
 4 - `status-api` 默认真相源:`BAA_CONDUCTOR_LOCAL_API` -> `http://100.71.210.78:4317/v1/system/state`
 5-- `BAA_CONTROL_API_BASE` 只剩两个保留点:`conductor` 运行时仍用这个历史变量名解析 upstream/public API base,`status-api` 只在手工兼容场景下回退读取
 6+- legacy 变量名 `BAA_CONTROL_API_BASE` 只剩两个保留点:`conductor` 运行时仍用它解析 upstream/public API base,`status-api` 只在手工兼容场景下回退读取
 7 
 8 ## 共享变量
 9 
10@@ -124,4 +124,4 @@ Firefox WS 派生规则:
11   --status-api-host 100.71.210.78
12 ```
13 
14-默认 mini 安装不需要显式传 `--control-api-base`;只有 `conductor` 的 upstream/public API base 不是 `https://conductor.makefile.so` 时才需要覆盖。即使显式传入,脚本现在也只会把它写给 `conductor` 安装副本;`status-api` 默认仍然优先读取 `--local-api-base` 对应的 conductor 主接口。
15+默认 mini 安装不需要显式传 `--control-api-base`;这个参数名本身也是 legacy 兼容名,只有 `conductor` 的 upstream/public API base 不是 `https://conductor.makefile.so` 时才需要覆盖。即使显式传入,脚本现在也只会把它写给 `conductor` 安装副本;`status-api` 默认仍然优先读取 `--local-api-base` 对应的 conductor 主接口。
M docs/runtime/launchd.md
+3, -3
 1@@ -40,13 +40,13 @@
 2 
 3 - `~/.config/baa-conductor/runtime-secrets.env`
 4 
 5-里提取 `BAA_SHARED_TOKEN` 并生成它;如果新的 env 文件不存在,还会回退读取 legacy `control-api-worker.secrets.env`。
 6+里提取 `BAA_SHARED_TOKEN` 并生成它;如果新的 env 文件不存在,还会回退读取 legacy `control-api-worker.secrets.env` 这个迁移前文件名。
 7 
 8 说明:
 9 
10 - `codexd` 独立安装时不需要共享 token
11 - `status-api` 默认真相源是 `BAA_CONDUCTOR_LOCAL_API`
12-- `--control-api-base` 仍然保留,但只影响 `conductor` 安装副本;默认值就是 `https://conductor.makefile.so`
13+- `--control-api-base` 仍然保留为 legacy 兼容参数名,但只影响 `conductor` 安装副本;默认值就是 `https://conductor.makefile.so`
14 - `--codexd-local-api-base` 会同时写给 `codexd` 和 `conductor`
15 - `codexd` 正式运行面只写入 `app-server` 会话链路所需默认值
16 - `codexd` 正式服务面只保留 `/healthz`、`/v1/codexd/status`、`/v1/codexd/sessions`、`/v1/codexd/turn`、`/v1/codexd/events`
17@@ -113,7 +113,7 @@
18   --status-api-host 100.71.210.78
19 ```
20 
21-默认 mini 安装不需要显式传 `--control-api-base`;只有 `conductor` 的 upstream/public API base 不是 `https://conductor.makefile.so` 时才需要覆盖。即使显式传入,脚本现在也只会把它写给 `conductor` 安装副本;`status-api` 实际默认仍然优先读 `--local-api-base http://100.71.210.78:4317`。
22+默认 mini 安装不需要显式传 `--control-api-base`;这个参数名本身是 legacy 兼容名,只有 `conductor` 的 upstream/public API base 不是 `https://conductor.makefile.so` 时才需要覆盖。即使显式传入,脚本现在也只会把它写给 `conductor` 安装副本;`status-api` 实际默认仍然优先读 `--local-api-base http://100.71.210.78:4317`。
23 
24 单独安装 `codexd`:
25 
M docs/runtime/node-verification.md
+1, -1
1@@ -28,7 +28,7 @@ npx --yes pnpm -r build
2 
3 说明:
4 
5-- 默认 mini 静态检查不需要显式传 `--control-api-base`;只有 `conductor` 的 upstream/public API base 不是 `https://conductor.makefile.so` 时才需要覆盖
6+- 默认 mini 静态检查不需要显式传 `--control-api-base`;这个参数名本身是 legacy 兼容名,只有 `conductor` 的 upstream/public API base 不是 `https://conductor.makefile.so` 时才需要覆盖
7 - `status-api` 的有效默认真相源仍然是 `--local-api-base http://100.71.210.78:4317`
8 - `check-launchd.sh` 现在只校验 `conductor` 安装副本里的 `BAA_CONTROL_API_BASE`;其他服务要求该变量不存在
9 - `check-launchd.sh` 现在也会校验 `conductor` 安装副本里的 `BAA_CODEXD_LOCAL_API_BASE`
M plans/STATUS_SUMMARY.md
+4, -4
 1@@ -6,7 +6,7 @@
 2 
 3 ## 当前代码基线
 4 
 5-- 主线基线:`main@1078d2b`
 6+- 主线基线:`main@d349be1`
 7 - 任务文档已统一收口到 `tasks/`
 8 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
 9 - `T-S001` 到 `T-S004` 已经合入主线
10@@ -29,7 +29,7 @@
11 - `mini` 本地 conductor:`http://100.71.210.78:4317`
12 - `mini` 本地 status-api:`http://100.71.210.78:4318`,仅用于本地只读观察
13 - `mini` 本地 codexd:`http://127.0.0.1:4319`
14-- `https://control-api.makefile.so`,仅用于迁移期兼容与残留依赖盘点
15+- `https://control-api.makefile.so`,仅作为迁移期 legacy 兼容壳和残留依赖盘点目标,不再是当前入口
16 
17 ## 当前保留内容
18 
19@@ -49,17 +49,17 @@
20 - `T-S003`:`status-api`、launchd 模板和 runtime 文档已切到当前 `conductor` 主接口,`BAA_CONTROL_API_BASE` 只保留为兼容覆盖
21 - `T-S004`:`conductor-daemon` 测试已统一通过 `withRuntimeFixture(...)` 收口 cleanup,`BUG-009` 已修复
22 - `T-S005`:默认 launchd 现在只把 `BAA_CONTROL_API_BASE` 写给 `conductor`;`status-api` 和 `worker-runner` 不再携带这个变量
23+- `T-S006`:`tasks/`、`plans/`、`README` 与 `docs/api/**` / `docs/runtime/**` 已把 `control-api` 收口为 legacy 背景,不再写成当前默认入口
24 - `T-S007`:`ConductorRuntime.stop()` 已补专项测试,覆盖 listener 释放、Firefox bridge client 关闭和重复 stop 的幂等行为
25 
26 ## 下一步任务
27 
28-- `T-S006`:清理历史任务 / 缺陷 / 文档里的 `control-api` 命名残留
29 - `T-S008`:给 codexd child / transport 提前断流补诊断与 reopen 规则
30 
31 ## 当前仍需关注
32 
33 - 如果后续再次出现 app-server 根本没有发出合法 `turn/completed` 就提前断流,这属于新的 child / transport 故障,应单独新开 bug
34 - `BAA_CONTROL_API_BASE` 仍保留在仓库里,定位是兼容入口,不是 canonical truth source
35-- 按任务边界没有完全清掉的 legacy 命名仍散落在部分历史任务文档、缺陷文档和兼容说明里
36+- 仍保留的 `control-api` 命名已经限定在历史任务卡、legacy 测试路径、兼容变量名和外部残留资产说明里;如果未来要继续删旧,需要单独评估文件名和兼容面
37 - 如果未来新增 runtime 测试绕开 `withRuntimeFixture(...)`,同类 listener 泄漏仍可能重新出现
38 - 这次没有改 `ConductorRuntime.stop()` 内部逻辑;如果未来关闭路径本身阻塞,还需要单独补运行时层测试
M tasks/T-S002.md
+2, -0
1@@ -1,5 +1,7 @@
2 # Task T-S002:清理 control-api-worker 残留与坏测试
3 
4+> 历史说明:本卡记录的是该任务开工时的状态;当前主线默认入口已是 `http://100.71.210.78:4317` / `https://conductor.makefile.so`,这里的 `control-api*` 只应按 legacy 背景理解。
5+
6 ## 直接给对话的提示词
7 
8 读 `/Users/george/code/baa-conductor/tasks/T-S002.md` 任务文档,完成开发任务。
M tasks/T-S003.md
+2, -0
1@@ -1,5 +1,7 @@
2 # Task T-S003:切换 status-api 到 conductor 主接口
3 
4+> 历史说明:本卡记录的是该任务开工时的状态;当前主线默认入口已是 `http://100.71.210.78:4317` / `https://conductor.makefile.so`,`control-api.makefile.so` 和 `BAA_CONTROL_API_BASE` 只保留 legacy 兼容语义。
5+
6 ## 直接给对话的提示词
7 
8 读 `/Users/george/code/baa-conductor/tasks/T-S003.md` 任务文档,完成开发任务。
M tasks/T-S005.md
+2, -0
1@@ -1,5 +1,7 @@
2 # Task T-S005:收口 `BAA_CONTROL_API_BASE` 兼容入口
3 
4+> 历史说明:本卡记录的是该任务开工时的状态;当前主线默认入口已是 `http://100.71.210.78:4317` / `https://conductor.makefile.so`,`BAA_CONTROL_API_BASE` 只保留 legacy 兼容语义。
5+
6 ## 直接给对话的提示词
7 
8 读 `/Users/george/code/baa-conductor/tasks/T-S005.md` 任务文档,完成开发任务。
M tasks/T-S006.md
+1, -1
1@@ -17,7 +17,7 @@
2 
3 - 仓库:`/Users/george/code/baa-conductor`
4 - 分支:`main`
5-- 提交:`1078d2b`
6+- 提交:`d349be1`
7 - 开工要求:不要从其他任务分支切出;如需新分支,从当前 `main` 新切
8 
9 ## 建议分支名
M tasks/TASK_OVERVIEW.md
+13, -10
 1@@ -6,10 +6,10 @@
 2 
 3 - canonical local API: `http://100.71.210.78:4317`
 4 - canonical public host: `https://conductor.makefile.so`
 5-- `control-api.makefile.so`、Cloudflare Worker、D1 只剩迁移期兼容残留
 6+- `control-api.makefile.so`、Cloudflare Worker、D1 只剩迁移期 legacy 兼容残留和依赖盘点用途
 7 - `baa-hand` / `baa-shell` 只保留为接口语义参考,不再作为主系统维护
 8 - 当前任务卡都放在本目录
 9-- 当前任务基线:`main@1078d2b`
10+- 当前任务基线:`main@d349be1`
11 
12 ## 最近完成任务
13 
14@@ -20,20 +20,23 @@
15 3. [`T-S003.md`](./T-S003.md):切换 `status-api` 到 `conductor` 主接口
16 4. [`T-S004.md`](./T-S004.md):修复 `conductor-daemon` 测试 listener 泄漏
17 5. [`T-S005.md`](./T-S005.md):收口 `BAA_CONTROL_API_BASE` 兼容入口
18-6. [`T-S007.md`](./T-S007.md):补 `ConductorRuntime.stop()` 关闭路径专项测试
19+6. [`T-S006.md`](./T-S006.md):清理历史 `control-api` 命名残留
20+7. [`T-S007.md`](./T-S007.md):补 `ConductorRuntime.stop()` 关闭路径专项测试
21+
22+说明:
23+
24+- 这些已完成任务卡里出现的 `control-api` / `BAA_CONTROL_API_BASE` 描述,默认都指任务开工时的历史背景;当前主线 canonical 口径仍是 `http://100.71.210.78:4317` 和 `https://conductor.makefile.so`
25 
26 ## 当前活动任务
27 
28-围绕剩余风险,当前建议继续推进这 2 张任务卡:
29+围绕剩余风险,当前建议继续推进这 1 张任务卡:
30 
31-1. [`T-S006.md`](./T-S006.md):清理历史 `control-api` 命名残留
32-2. [`T-S008.md`](./T-S008.md):补 codexd child / transport 断流诊断与 reopen 规则
33+1. [`T-S008.md`](./T-S008.md):补 codexd child / transport 断流诊断与 reopen 规则
34 
35-并行建议:
36+说明:
37 
38-- `T-S006` 主要改文档和状态口径
39+- `T-S006` 已完成并回写当前文档口径
40 - `T-S008` 主要改 codexd 与 bug/runtime 文档
41-- 这两张卡写范围基本不重叠,可以并行
42 
43 ## 任务文档约定
44 
45@@ -54,7 +57,7 @@
46 
47 - 直接在当前 `tasks/` 目录新建任务卡
48 - 所有新任务默认以 `100.71.210.78:4317` 和 `conductor.makefile.so` 为 canonical 接口面
49-- `control-api.makefile.so` 只允许作为删旧前的兼容背景出现
50+- `control-api.makefile.so` 只允许作为删旧前的 legacy 兼容背景或残留依赖盘点说明出现
51 - 当前任务编号继续使用 `T-S***`
52 - 能并行的任务优先拆开,并明确写清允许修改的目录
53 - 新任务文档结构参考 [`task-doc-template.md`](./task-doc-template.md)