@sema-agent/server 7.24.0 → 7.25.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,14 @@ export interface ShutdownCtx {
28
28
  logger: Logger;
29
29
  server: ReturnType<typeof createHttpServer>;
30
30
  reaper: NodeJS.Timeout;
31
+ /** #261:fleet 存活对账腿(60s 定时器 + 总线订阅)。与 `reaper` 同列文件头契约 3 —— 收尾期不该再有
32
+ * 对账 tick 打向正在关闭的池,订阅也要摘(否则 bus 的扇出还在往一个已停的观察者写)。 */
33
+ fleetReconcile: {
34
+ timer: NodeJS.Timeout;
35
+ reconciler: {
36
+ stop(): void;
37
+ };
38
+ };
31
39
  otelExporter: ReturnType<typeof startOtlpExporter> | undefined;
32
40
  breakerState: ReturnType<BreakerStateStore["startRefresh"]> | undefined;
33
41
  costQuota: CostQuota | CostQuotaStore | undefined;
@@ -18,7 +18,7 @@ import { createSighupIdleHandler } from "../sighup-idle.js";
18
18
  import { createParentWatch } from "../parent-watch.js";
19
19
  /** 注册 SIGTERM/SIGINT/SIGHUP 收尾链。**必须在 listen 之后调用**(见文件头「位置即契约」)。 */
20
20
  export function installShutdownHandlers(ctx) {
21
- const { config, logger, server, reaper, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, } = ctx;
21
+ const { config, logger, server, reaper, fleetReconcile, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, } = ctx;
22
22
  let closing = false;
23
23
  /** #219:parent 监视腿(装配在本函数尾,`config.parentPid` 在场才建)。声明提前只为让 hardShutdown
24
24
  * 能把它一并停掉 —— 文件头契约 3 同族(收尾期不再有后台 tick)。 */
@@ -28,6 +28,8 @@ export function installShutdownHandlers(ctx) {
28
28
  return;
29
29
  closing = true;
30
30
  clearInterval(reaper);
31
+ clearInterval(fleetReconcile.timer); // #261:契约 3 同族——收尾期不再有对账 tick 打向正在关闭的池
32
+ fleetReconcile.reconciler.stop(); // 摘总线订阅(观察者已停,扇出不该再喂它)
31
33
  parentWatch?.stop(); // #219:契约 3 同族——收尾期不再探父进程
32
34
  storeLiveProbe?.stop(); // #131-2:契约 3 同族——收尾期不再有探针 tick 打向正在关闭的池
33
35
  configCenter?.stopRefreshLoop(); // #131-2:停机中途不再热应用配置
@@ -134,6 +134,32 @@ export interface FleetTaskRow {
134
134
  * 自己发明「复合 id 首段==parentId 且撞 (parentId,name)」那类集合级判据(cli 侧的防御税)。
135
135
  * ADDITIVE / tolerate-absent。 */
136
136
  sourceLane?: "run-leg";
137
+ /**
138
+ * #261 §2①(core 5.36.0 #258 供给,server 白名单透传):这一行的**代际号**。语义 = core 的
139
+ * stop-cycle 计数(fresh spawn IS cycle 1,每次复活翻转 +1),与 `bg_notification.seq` /
140
+ * `task_progress.seq` / `BackgroundChildEvent.seq` **同域同轴**(core 原话:一条轴,不是第三种拼法)。
141
+ *
142
+ * 消费端读法(定死,防两端各写):同 id 帧 `cycleSeq` **更大 ⇒ 复活**(新代际,行内累计量重置);
143
+ * **更小 ⇒ 前代迟到帧,忽略**(不回退——本仓在 {@link FleetEventBus.publishTask} 里 merge 前就判掉了,
144
+ * 消费端拿到的流已经是单调的);**缺席 ⇒ 回落现行为**(tolerate-absent)。
145
+ *
146
+ * 🔴 缺席是**事实**不是缺口(core [4043] 逐字定谳,以其措辞为准):没有 `a*` registry 行的 run
147
+ * ——同步委派子代、workflow `wa*` agent、**顶层 run 行**——根本没有代际概念,「缺席即『无此概念』,
148
+ * 非『第一代』」。任何把缺席读成 1 的消费端都会把「首帧迟到」误判成「复活」。
149
+ * ADDITIVE / tolerate-absent。
150
+ */
151
+ cycleSeq?: number;
152
+ /**
153
+ * #261 §2②:这一帧的终态**不是发布方亲报**,而是对账腿({@link ../fleet/fleet-reconciler.js})
154
+ * 从 durable run 行**投影**出来的 —— 发布方死了(事件环异常中断 / 引擎被壳 reuse 而旧 run 对象已死 /
155
+ * BCE 终态通知丢失),行本会永久僵在 `tasks` Map 里当幽灵。
156
+ *
157
+ * 词表现为**单词闭集**;读侧按 SDK 开集读纪律(未来若有第二个投影者,它会是一个新词而不是改义)。
158
+ * 发布方亲报的终态帧**恒不带**此键 —— 两种终态在 wire 上因此**可判**(消费端要区分「引擎说它完了」
159
+ * 与「我们从库里读出来它完了」时,这是唯一的判据)。只在终态帧出现。
160
+ * ADDITIVE / tolerate-absent。
161
+ */
162
+ retiredBy?: "reconcile";
137
163
  }
138
164
  /** One workflow row (wire subset of contract `FleetWorkflow`). */
139
165
  export interface FleetWorkflowRow {
@@ -185,6 +211,22 @@ export type FleetFrame = {
185
211
  type: "task_remove";
186
212
  id: string;
187
213
  ts: number;
214
+ /**
215
+ * #261 §2③:**陈述级退场** —— 行没了,但**没有**结算语义。对照:无此键的 remove = 终态之后的
216
+ * 常规清场(发布方亲报终态、或 `retiredBy:"reconcile"` 的投影退休,两者都先发过终态 `task` 帧)。
217
+ *
218
+ * 铸点只有对账腿:durable 说这条 run 还 `running`(reaper 宽限窗内)、或 claim 在他副本、或
219
+ * durable 行压根不存在 —— 三形共同点是**没有终局可投影**,而本副本的行必然陈旧。此时造一个终态
220
+ * 就是编:判死的属主始终是 durable 侧的 reaper。所以只陈述「这条行离场了」,不逼消费端结算。
221
+ * (cli 侧裁量 [4044]:渲「离场」**不清行** —— 行保留、中性离场形、active 扣除、折叠排后。)
222
+ * ADDITIVE / tolerate-absent。
223
+ */
224
+ removeReason?: "orphaned";
225
+ /** #261 §2 判死×判代合成规则:退场帧带**被退那一代**的 {@link FleetTaskRow.cycleSeq}。
226
+ * 消费端因此能把 `retire(gen=N)` 之后的 `frame(gen=N+1)` 读成**复活**(行重生,退休不粘住新代),
227
+ * 而 `retire(gen=N)` 之后的 `frame(gen=N)` 读成**前代迟到帧,忽略**。缺席同 {@link FleetTaskRow.cycleSeq}
228
+ * ——「无此概念」,不是「第一代」。 */
229
+ cycleSeq?: number;
188
230
  } | {
189
231
  type: "workflow";
190
232
  row: FleetWorkflowRow;
@@ -299,6 +341,10 @@ export declare class FleetEventBus {
299
341
  private readonly emitter;
300
342
  private readonly tasks;
301
343
  private readonly workflows;
344
+ /** #261 / codex R1-F2:**代际墓碑** —— 已离场行的最后已知 `cycleSeq`(行本身已从 `tasks` 删掉,
345
+ * 代际状态却必须活得比行久一点,否则前代迟到帧会在空 Map 上把幽灵行重铸出来)。有界 LRU。 */
346
+ private readonly retiredGen;
347
+ private static readonly RETIRED_GEN_MAX;
302
348
  constructor(now?: () => number);
303
349
  /** Upsert a task row (MERGE onto any existing row by `id`) + fan out the merged row. Publishers may send a
304
350
  * partial `{ id, tokens }` etc.; the row keeps its other fields. */
@@ -307,8 +353,15 @@ export declare class FleetEventBus {
307
353
  }): void;
308
354
  /** 推一帧 hook 判定未完成的观测通知(事件语义,不进 snapshot —— 与 `bg_notification` 同族)。 */
309
355
  publishHookNotice(notice: HookNotice): void;
310
- /** Drop a task row (terminal + swept) + fan out the removal. Idempotent (a no-op if already gone). */
311
- removeTask(id: string): void;
356
+ /** Drop a task row (terminal + swept) + fan out the removal. Idempotent (a no-op if already gone).
357
+ *
358
+ * `opts`(#261,两键均 ADDITIVE):`removeReason:"orphaned"` = **陈述级退场**(没有结算语义,见
359
+ * {@link FleetFrame} 的 `task_remove` 臂);`cycleSeq` = 被退那一代的代际号。发布方的常规清场
360
+ * (`onTerminal`/`onChildTerminal`)不传 opts ⇒ 帧逐字节与本改动之前相同。 */
361
+ removeTask(id: string, opts?: {
362
+ removeReason?: "orphaned";
363
+ cycleSeq?: number;
364
+ }): void;
312
365
  private readonly bceClaims;
313
366
  private static readonly BCE_CLAIM_MAX;
314
367
  private static bceKey;
@@ -369,6 +422,7 @@ export declare function fleetRunPublisher(bus: FleetEventBus | undefined, run: {
369
422
  toolName?: string;
370
423
  parentToolCallId?: string;
371
424
  status?: string;
425
+ seq?: number;
372
426
  }) => void;
373
427
  onForwardEvent: (ev: {
374
428
  type?: string;
@@ -378,6 +432,7 @@ export declare function fleetRunPublisher(bus: FleetEventBus | undefined, run: {
378
432
  usage?: unknown;
379
433
  parentToolCallId?: string;
380
434
  status?: string;
435
+ seq?: number;
381
436
  }) => void;
382
437
  /** Flip + remove one subagent CHILD row when its background completion notification arrives
383
438
  * (task_progress never emits a terminal tick — without this a bg child sat "running" until parent settle).
@@ -92,6 +92,10 @@ export class FleetEventBus {
92
92
  emitter = new EventEmitter();
93
93
  tasks = new Map();
94
94
  workflows = new Map();
95
+ /** #261 / codex R1-F2:**代际墓碑** —— 已离场行的最后已知 `cycleSeq`(行本身已从 `tasks` 删掉,
96
+ * 代际状态却必须活得比行久一点,否则前代迟到帧会在空 Map 上把幽灵行重铸出来)。有界 LRU。 */
97
+ retiredGen = new Map();
98
+ static RETIRED_GEN_MAX = 4096;
95
99
  constructor(now = () => Date.now()) {
96
100
  this.now = now;
97
101
  this.emitter.setMaxListeners(0); // unbounded SSE subscribers (each /v1/fleet/stream connection)
@@ -99,9 +103,30 @@ export class FleetEventBus {
99
103
  /** Upsert a task row (MERGE onto any existing row by `id`) + fan out the merged row. Publishers may send a
100
104
  * partial `{ id, tokens }` etc.; the row keeps its other fields. */
101
105
  publishTask(delta) {
106
+ const existing = this.tasks.get(delta.id);
107
+ // 🔴 #261 §2 判代闸(**merge 前判**):同 id 上一个**更小**代际的帧 = 前代迟到帧 ⇒ 整帧忽略,
108
+ // 既不 merge 也不扇出。为什么必须在 merge 之前:MERGE 语义下一个迟到的前代 delta 会把行内累计量
109
+ // (tokens / elapsedMs / toolUses)**回退**到上一代的读数,而回退过的行再也没人会修正它 ——
110
+ // 消费端看到的是一条"复活之后 token 越跑越少"的行,且无从判别。判据只在**两边都有**代际号时生效:
111
+ // 缺席 = 「无此概念」(顶层 run 行恒缺席),不参与比较,行为逐字回落到本闸之前。
112
+ if (delta.cycleSeq !== undefined && existing?.cycleSeq !== undefined && delta.cycleSeq < existing.cycleSeq)
113
+ return;
114
+ // 🔴 同一条判据在**行已离场**之后的半场(codex 对抗复审 R1-F2 [high],红先复现)。上面那条闸只跟
115
+ // **内存行**比对,而 `removeTask` 顺手把行连同它的代际状态一起删了 —— 于是 `retire(N)` 之后一条
116
+ // `frame(N)` 会在空 Map 上把行**重新铸出来**,而那条行的发布方恰恰是已经死了(那就是我们退它的理由),
117
+ // 面板上于是又立起一条永不离场的幽灵行。墓碑补上这一半:行缺席时与**被退那一代**比,不前进的帧一律
118
+ // 不铸;更大代际(真复活)照常出生。
119
+ // ⚠️ 例外恰好一个:带 `retiredBy` 的帧**就是**对账腿对"被退那一代"的结算(行已不在 bus,故它必须
120
+ // 先瞬时重铸再撤 —— 见 fleet-reconciler.ts 的候补窗 flip-through)。它与墓碑说的是**同一代**,拦掉
121
+ // 它等于让"陈述级退场 → 后来 durable 翻终局 → 补发成对终帧"这条设计承诺静默失效(红先复现)。
122
+ if (existing === undefined && delta.retiredBy === undefined) {
123
+ const tomb = this.retiredGen.get(delta.id);
124
+ if (tomb !== undefined && delta.cycleSeq !== undefined && delta.cycleSeq <= tomb)
125
+ return;
126
+ }
102
127
  // [2070]③:懒铸底座**不带 name** —— 一条只有 `{id,…}` 的首帧此前会把行 id 铸成显示名,而这里
103
128
  // 恰恰是「谁都没给过名字」的位置。真名后到时照常 MERGE 上行(见本文件末的 [2070]③ 第 4 条钉)。
104
- const merged = { ...(this.tasks.get(delta.id) ?? { id: delta.id, status: "running" }), ...delta };
129
+ const merged = { ...(existing ?? { id: delta.id, status: "running" }), ...delta };
105
130
  this.tasks.set(delta.id, merged);
106
131
  this.emit({ type: "task", row: merged, ts: this.now() });
107
132
  }
@@ -109,10 +134,42 @@ export class FleetEventBus {
109
134
  publishHookNotice(notice) {
110
135
  this.emit({ type: "hook_notice", notice, ts: this.now() });
111
136
  }
112
- /** Drop a task row (terminal + swept) + fan out the removal. Idempotent (a no-op if already gone). */
113
- removeTask(id) {
114
- if (this.tasks.delete(id))
115
- this.emit({ type: "task_remove", id, ts: this.now() });
137
+ /** Drop a task row (terminal + swept) + fan out the removal. Idempotent (a no-op if already gone).
138
+ *
139
+ * `opts`(#261,两键均 ADDITIVE):`removeReason:"orphaned"` = **陈述级退场**(没有结算语义,见
140
+ * {@link FleetFrame} 的 `task_remove` 臂);`cycleSeq` = 被退那一代的代际号。发布方的常规清场
141
+ * (`onTerminal`/`onChildTerminal`)不传 opts ⇒ 帧逐字节与本改动之前相同。 */
142
+ removeTask(id, opts) {
143
+ const existing = this.tasks.get(id);
144
+ // 🔴 **代际条件删**(codex 对抗复审 R1-F2 反向臂 [high],红先复现):调用方拿的是一份快照,而它
145
+ // 在 await 里等 durable 读的那几十毫秒里,同一 id 完全可能已经**复活成新一代**(BCE revive spawn /
146
+ // run-leg 新腿)。无条件删就是拿过期快照把一条**活着的新行**从面板上摘掉 —— 而顶层 run 行没有代际号,
147
+ // 那一格由调用方的 await-后复核兜(见 fleet-reconciler.ts);有代际号的子代行由这条闸兜。
148
+ // 请求方没带代际(发布方的常规清场:它就是行的属主,不存在"拿旧快照删新行")⇒ 照旧无条件删。
149
+ if (existing !== undefined && opts?.cycleSeq !== undefined && existing.cycleSeq !== undefined && existing.cycleSeq > opts.cycleSeq)
150
+ return;
151
+ if (this.tasks.delete(id)) {
152
+ // 代际墓碑:记下**被退那一代**(优先取行上的真值,退化取请求值)。见 publishTask 的墓碑臂。
153
+ const gen = existing?.cycleSeq ?? opts?.cycleSeq;
154
+ if (gen !== undefined) {
155
+ this.retiredGen.delete(id);
156
+ this.retiredGen.set(id, gen);
157
+ // 容量兜底:逐最老。丢一条墓碑 = 那个 id 退回本闸之前的行为(迟到帧可重铸,下一个对账周期再退它一次)
158
+ // —— 可见性回退,不是错行,与 bceClaims 同一条取舍。
159
+ if (this.retiredGen.size > FleetEventBus.RETIRED_GEN_MAX) {
160
+ const oldest = this.retiredGen.keys().next();
161
+ if (!oldest.done)
162
+ this.retiredGen.delete(oldest.value);
163
+ }
164
+ }
165
+ this.emit({
166
+ type: "task_remove",
167
+ id,
168
+ ts: this.now(),
169
+ ...(opts?.removeReason !== undefined ? { removeReason: opts.removeReason } : {}),
170
+ ...(opts?.cycleSeq !== undefined ? { cycleSeq: opts.cycleSeq } : {}),
171
+ });
172
+ }
116
173
  }
117
174
  // ┌─ [2070]①/[2080] 让位登记簿(BCE 车道 → run-leg 车道的单向让位信号)────────────────────────────┐
118
175
  // 为什么登记簿而不是"查快照有没有 a* 行":两条车道用**不同 id 域**给同一个 agent 建行(BCE=`a*`/`wa*`
@@ -353,7 +410,8 @@ export function fleetRunPublisher(bus, run) {
353
410
  // [1415]③ 点亮半场:core 1.354 起 settle 时补终态 tick(status 值域 additive 扩 completed|failed;
354
411
  // 此前恒 "running"=行为逐位不变)——workflow 子的 uuid 行终于有完成态可见(running→removed 的
355
412
  // [1414]#3 形就此闭)。
356
- bus.publishTask({ id: cid, sourceLane: "run-leg", ...(childName !== undefined ? { name: childName, agentType: childName } : {}), parentId: run.runId, scope: run.scope, ...sessionField, status: tickStatus(ev.status), tokens: sumTokens(ev.usage), ...(() => { const t = toolUsesOf(ev.usage); return t !== undefined ? { toolUses: t } : {}; })(), elapsedMs: Date.now() - childStartedAt.get(cid) });
413
+ // #261 §2:`seq` 透传成行的 `cycleSeq`(**缺席不铸** —— core 不发这一位,而「缺席禁读作 cycle 1」)
414
+ bus.publishTask({ id: cid, sourceLane: "run-leg", ...(childName !== undefined ? { name: childName, agentType: childName } : {}), parentId: run.runId, scope: run.scope, ...sessionField, status: tickStatus(ev.status), tokens: sumTokens(ev.usage), ...(typeof ev.seq === "number" ? { cycleSeq: ev.seq } : {}), ...(() => { const t = toolUsesOf(ev.usage); return t !== undefined ? { toolUses: t } : {}; })(), elapsedMs: Date.now() - childStartedAt.get(cid) });
357
415
  }
358
416
  else if (ev.type === "tool_start" && ev.toolName) {
359
417
  // BC-1 (clay 2026-06-27): the top-level run row's `description` = LIVE ACTIVITY (the tool now running), NOT
@@ -399,7 +457,7 @@ export function fleetRunPublisher(bus, run) {
399
457
  childStartedAt.set(cid, Date.now());
400
458
  if (ev.parentToolCallId)
401
459
  childByToolCall.set(ev.parentToolCallId, cid);
402
- bus.publishTask({ id: cid, sourceLane: "run-leg", ...(fwdName !== undefined ? { name: fwdName, agentType: fwdName } : {}), parentId, scope: run.scope, ...sessionField, status: tickStatus(ev.status), tokens: sumTokens(ev.usage), ...(() => { const t = toolUsesOf(ev.usage); return t !== undefined ? { toolUses: t } : {}; })(), elapsedMs: Date.now() - childStartedAt.get(cid) }); // [1415]③ 同上;[1748] toolUses 同 bg lane 口径
460
+ bus.publishTask({ id: cid, sourceLane: "run-leg", ...(fwdName !== undefined ? { name: fwdName, agentType: fwdName } : {}), parentId, scope: run.scope, ...sessionField, status: tickStatus(ev.status), tokens: sumTokens(ev.usage), ...(typeof ev.seq === "number" ? { cycleSeq: ev.seq } : {}), ...(() => { const t = toolUsesOf(ev.usage); return t !== undefined ? { toolUses: t } : {}; })(), elapsedMs: Date.now() - childStartedAt.get(cid) }); // [1415]③ 同上;[1748] toolUses 同 bg lane 口径;#261 seq→cycleSeq 缺席不铸
403
461
  },
404
462
  onChildTerminal(taskId, status, altId, toolUseId) {
405
463
  // A background subagent/bash CHILD settles — task_progress never emits a terminal tick,
@@ -751,7 +809,9 @@ export function fleetBackgroundChildPublisher(bus, log) {
751
809
  // [2400] FLEET-WORKFLOWS-7:[2070]③「名不铸」的第四处——spawn 双缺 description/name 时此前 `?? e.taskId`
752
810
  // 拿 a* handle 冒充显示名;tick 臂只在 name 在场才发 + 总线 MERGE ⇒ 假名永久留行。缺席不铸,与其余三处同判。
753
811
  const spawnName = e.description ?? e.name;
754
- bus.publishTask({ id: e.taskId, ...(spawnName !== undefined ? { name: redactSecrets(spawnName) } : {}), ...(e.agentType ? { agentType: redactSecrets(e.agentType) } : {}), ...rowTags(m), status: "running", tokens: 0, ...(e.transcriptId ? { transcriptId: e.transcriptId } : {}) });
812
+ // #261 §2:core 5.36.0(#258) `seq` 从「只在 terminal 帧」放宽到 **spawn/tick 也带**(registry
813
+ // 的 stop-cycle 代际)。本车道是设计稿点名的第二条供给点 —— 三帧一律透传成行的 `cycleSeq`,**缺席不铸**。
814
+ bus.publishTask({ id: e.taskId, ...(spawnName !== undefined ? { name: redactSecrets(spawnName) } : {}), ...(e.agentType ? { agentType: redactSecrets(e.agentType) } : {}), ...rowTags(m), status: "running", tokens: 0, ...(typeof e.seq === "number" ? { cycleSeq: e.seq } : {}), ...(e.transcriptId ? { transcriptId: e.transcriptId } : {}) });
755
815
  dlog("bg_child_event", { kind: "spawn", taskId: e.taskId, sessionScoped: e.sessionScoped, scope: m.tenantScope, hostSessionId: m.hostSessionId ?? null, parentTaskId: m.parentTaskId ?? null });
756
816
  };
757
817
  const handleTick = (e, m) => {
@@ -806,7 +866,7 @@ export function fleetBackgroundChildPublisher(bus, log) {
806
866
  // cli [1748]:两个 additive 位。`toolUses` 是**累计值**(与 turn_end 的每轮增量相反)⇒ 直接上行不累加;
807
867
  // 缺席时**不发这个键**(消费方不得把缺席当 0)。`transcriptId` 是委派 prompt 的**映射** ——
808
868
  // prompt 本身不在 `BackgroundChildEvent` 上,server 收不到就不造。
809
- bus.publishTask({ id: e.taskId, ...rowTags(m), status: "running", ...(tokens !== undefined ? { tokens } : {}), ...(name ? { name } : {}), ...(agentType ? { agentType } : {}), ...(currentAction ? { currentAction } : {}), ...(currentTool ? { currentTool } : {}), ...(e.usage?.toolUses !== undefined ? { toolUses: e.usage.toolUses } : {}), ...(e.transcriptId ? { transcriptId: e.transcriptId } : {}) });
869
+ bus.publishTask({ id: e.taskId, ...rowTags(m), status: "running", ...(tokens !== undefined ? { tokens } : {}), ...(name ? { name } : {}), ...(agentType ? { agentType } : {}), ...(currentAction ? { currentAction } : {}), ...(currentTool ? { currentTool } : {}), ...(typeof e.seq === "number" ? { cycleSeq: e.seq } : {}), ...(e.usage?.toolUses !== undefined ? { toolUses: e.usage.toolUses } : {}), ...(e.transcriptId ? { transcriptId: e.transcriptId } : {}) }); // #261 seq→cycleSeq 缺席不铸
810
870
  };
811
871
  const handleTerminal = (e, m) => {
812
872
  // terminal: flip the row so subscribers SEE the terminal frame, then leave the fleet — and push the
@@ -818,6 +878,7 @@ export function fleetBackgroundChildPublisher(bus, log) {
818
878
  // + 100 界、缺席不铸。(recentSteps 不进行帧——通知卡语义,行卡不渲染步骤列表。)
819
879
  bus.publishTask({
820
880
  id: e.taskId, ...rowTags(m), status,
881
+ ...(typeof e.seq === "number" ? { cycleSeq: e.seq } : {}), // #261 §2:终帧带**被结算那一代**的代际号(缺席不铸)
821
882
  ...(e.stoppedBy ? { stoppedBy: e.stoppedBy } : {}),
822
883
  ...(e.resumable !== undefined ? { resumable: e.resumable } : {}),
823
884
  ...(e.transcriptId ? { transcriptId: e.transcriptId } : {}),
@@ -0,0 +1,149 @@
1
+ /**
2
+ * #261(docs/DESIGN-261-fleet-liveness-reconcile.md v1.2,四方零驳定稿)—— fleet 总线**存活对账腿**。
3
+ *
4
+ * ## 它修什么(§0 病灶机制链)
5
+ * fleet 总线是进程内纯内存聚合(`fleet-bus.ts` 头注自陈 NOT durable / NOT cross-replica),行的**唯一**
6
+ * 离场路径是发布方主动打 `onTerminal`/`onChildTerminal`。发布方没走到终态臂(run 事件环异常中断、引擎
7
+ * 被壳 reuse 而旧 run 对象已死、BCE 终态通知丢失),行就**永久僵在** `tasks` Map 里 —— 此后每个订阅方
8
+ * (含 snapshot 新连接)看到一条永远 "running" 的幽灵行。壳侧只能靠 overlay 防御(cli 1.0.74 过渡件)。
9
+ *
10
+ * ## 核心裁定:**不造第二个判死者**(§1)
11
+ * 本腿**不判死**。它只做两件事:
12
+ * (a) 副本本地活体登记面(`http/server.ts` 的 `inflightRuns` ∪ `steerableRuns`,经
13
+ * {@link FleetReconcilerDeps.isLiveOnThisReplica} 探针注入 —— **不搬 Map**)在场 ⇒ 行活,跳过;
14
+ * (b) 登记面无此 taskId ⇒ 读 durable run 行,把 durable **已有的判决**投影到总线上。
15
+ * 判死的属主始终是 durable 侧既有机制(心跳 → reaper finalize → 行翻终态);本腿是**投影腿**,
16
+ * 对 durable **恒只读**({@link FleetReconcileRunPort} 这只窄口连写方法都没有 —— 单写者不变量由类型执法)。
17
+ *
18
+ * `msSinceLastActivity` **显式排除**:它是「turn 卡死」诊断维,把它当退休输入会把真忙的后台 run 扫下
19
+ * 面板,方向错。卡死的 run 行**该**留着(它确实在占资源),诊断走 `GET /v1/runs/:id`。
20
+ *
21
+ * ## 对账矩阵(§1;每周期对 bus 的顶层 run 行逐行判)
22
+ * | 登记面 | durable 行 | 处置 |
23
+ * |---|---|---|
24
+ * | 在场 | 任意 | **跳过**(含 turn 卡死形) |
25
+ * | 无 | 终局 | **投影退休**:终态 `task` 帧(durable 真状态 + `retiredBy:"reconcile"` + 被退代际)→ `task_remove`,**结算级** |
26
+ * | 无 | `running`(本副本 claim / 他副本 claim) | **陈述级退场**:`task_remove` + `removeReason:"orphaned"`,**恒不造终态** |
27
+ * | 无 | 行不存在 | 陈述级退场(见下方"静默一周期"的收窄) |
28
+ *
29
+ * 🔴 **对"行不存在"这一格的收窄**(实施稿承重,设计稿 §3 的「拿不准的恒答最弱主张」同源):bus 上
30
+ * 一条 `parentId` 缺席的行**不一定**是顶层 run 行 —— BCE 车道的 `a*` 子代行在父链接尚未解析成功时
31
+ * 同样不发 `parentId`(`rowTags` 的 R6 臂),两者在总线上**结构同形**。而 `getRun(a*)` 必然 miss。
32
+ * 若照矩阵当周期就退场,一条**活着的**长寿 session-scoped bg 子代会被扫下面板。所以这一格额外要求
33
+ * 「**静默满一个对账周期**」(本腿订阅总线自记每行的最后帧时刻):还在出帧 = 活着的证据。
34
+ * durable 行**在场**的那三格没有这个歧义(`getRun` 命中 = 它就是一条 run 行),故照矩阵即时处置。
35
+ *
36
+ * ## 子代行(§1)
37
+ * 随父:父行退休/退场时**同扫**(与 `onTerminal` 的 removal 集同姿势)。子代**不读 durable** ——
38
+ * bg 子代的 durable 真源在 core 侧 registry,server 不越界重判。无父可循的孤儿子行(父行已不在 bus)
39
+ * 按「bus 内滞留超一个对账周期且无新帧」陈述级退场。
40
+ *
41
+ * ## workflow 行(§1)
42
+ * 只做同款「durable 终局投影」(#189 的 snapshot pull 腿 + reapers 的 workflow recover 腿已在场),
43
+ * **不加新判据**:活行恒不动,没有陈述级退场这一形。
44
+ *
45
+ * ## 五条界(任何一条失手都只是幽灵行多活一个周期 ⇒ 整体 F 类 fail-open,登记 tag,不静默)
46
+ * · 节律 {@link FLEET_RECONCILE_INTERVAL_MS} · 每周期读上限 {@link FLEET_RECONCILE_MAX_ROWS}
47
+ * · 读预算 {@link FLEET_RECONCILE_READ_BUDGET_MS} · 熔断冷却 {@link FLEET_RECONCILE_COOLDOWN_MS}
48
+ * · 待终局候补窗 {@link FLEET_RECONCILE_WATCH_MAX} × {@link FLEET_RECONCILE_WATCH_TTL_MS}
49
+ * 任何读错/超预算 ⇒ `recordFailOpen("server.fleet.reconcile-read-failed")` + 熔断 + 本周期跳过。
50
+ *
51
+ * ## 为什么整套常数都不做旋钮
52
+ * 与 `fleet-terminal-window.ts` 同一论证:两侧边界都由语义定死(比周期短 = 幽灵行仍在面板上,比周期长 =
53
+ * 拿对账腿当历史列表),旋钮只会让部署方去调一个没有正确取值的数;fleet 面现有零旋钮,从之。
54
+ */
55
+ import { type WorkflowRun, type WorkflowRunStore } from "@sema-agent/core";
56
+ import type { Logger } from "../observability/logger.js";
57
+ import type { Metrics } from "../observability/metrics.js";
58
+ import { type RunRecord } from "../plugins/store-contracts.js";
59
+ import { type FleetEventBus } from "./fleet-bus.js";
60
+ /** 对账节律 —— 60s 档。比它短 = 每分钟给 durable 加一批点读却换不来更快的面板真相(幽灵行的时间尺度
61
+ * 是"发布方已经死了",分钟级);比它长 = 壳的引擎温切门(cli engineSwapGate 读活行集)被幽灵行按住
62
+ * 太久。不做旋钮,理由见模块头。 */
63
+ export declare const FLEET_RECONCILE_INTERVAL_MS = 60000;
64
+ /** 每周期的**点读上限**。超出的行留到下一周期(顺序稳定 = bus 的插入序),不把一次扫描变成全表点读风暴。 */
65
+ export declare const FLEET_RECONCILE_MAX_ROWS = 200;
66
+ /** 一个周期的 durable 读**时间预算**。超时 = 放弃这一轮(记 F 类 fail-open),行留在面板上。
67
+ * 与 `FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS` 同值同理由:慢查询不该把整条腿钉死。 */
68
+ export declare const FLEET_RECONCILE_READ_BUDGET_MS = 2000;
69
+ /** 熔断冷却:一次读失败/超预算之后,这段时间内**不再发起任何读**。`RunStore`/`WorkflowRunStore` 契约
70
+ * 都没有取消面(发出去的查询撤不回),挂死的后端不该被每周期继续加压。冷却一过自动重探(自愈)。 */
71
+ export declare const FLEET_RECONCILE_COOLDOWN_MS = 60000;
72
+ /** 陈述级退场之后的**待终局候补窗**行数上限(§1 矩阵的「终局候 durable 翻转后由下一周期投影」半场:
73
+ * 行已不在 bus,故补发终态 `task` 帧 + remove 的**成对形**)。有界:超出即不再收新条目。 */
74
+ export declare const FLEET_RECONCILE_WATCH_MAX = 200;
75
+ /** 候补窗的存活时长。reaper 的孤儿宽限窗之外仍没翻终局的行 = 这条腿等不到了(它本来也只是投影),
76
+ * 丢弃并不损失真相:`GET /v1/runs/:id` 永远是那条 run 的真源。 */
77
+ export declare const FLEET_RECONCILE_WATCH_TTL_MS: number;
78
+ /** durable run 行的**只读**窄口(单写者不变量的类型执法面:这只接口上没有任何写方法,
79
+ * 所以"对账腿顺手改了一行"在本仓是编译期不可能,而不是靠注释约束)。 */
80
+ export interface FleetReconcileRunPort {
81
+ getRun(taskId: string): Promise<RunRecord | undefined>;
82
+ }
83
+ /** durable workflow 行的只读窄口(同上;`WorkflowRunStore` 的 `get` 一面。`null` 在返回类型里是因为
84
+ * core 契约的 `get` 用 `null` 表"没这一行",两种缺席一并容忍)。 */
85
+ export interface FleetReconcileWorkflowPort {
86
+ get(id: string): Promise<WorkflowRun | null | undefined>;
87
+ }
88
+ export interface FleetReconcilerDeps {
89
+ bus: FleetEventBus;
90
+ /**
91
+ * 副本本地**活体谓词**(§4 件1 的"探针注入形"):`taskId` 是否在本副本的在飞/可 steer 登记面里。
92
+ * 由 `http/server.ts` 交出 `(taskId) => boolean`,**不搬 Map** —— 登记面的属主仍是 server,
93
+ * 对账腿只问一个是非题(搬 Map 会造出第二份活性判据,而两份判据必漂移)。
94
+ */
95
+ isLiveOnThisReplica: (taskId: string) => boolean;
96
+ runs?: FleetReconcileRunPort;
97
+ workflows?: FleetReconcileWorkflowPort | WorkflowRunStore;
98
+ /** 本副本 id(与 `RunRecord.instanceId` 同域)。只进日志/读数 —— 两条 `running` 臂的**处置相同**
99
+ * (都是陈述级退场),分不分得出 claim 属谁不改变行为,故它不是判据。 */
100
+ instanceId?: string;
101
+ logger?: Logger;
102
+ metrics?: Metrics;
103
+ /** 测试缝:单调/可控时钟(默认 `Date.now`)。 */
104
+ now?: () => number;
105
+ }
106
+ /** 一个周期的读数(测试与遥测面;不上 wire)。 */
107
+ export interface FleetReconcileTally {
108
+ /** 本周期判过的顶层候选行数(含跳过的)。 */
109
+ scanned: number;
110
+ /** 登记面在场而跳过的行数。 */
111
+ skippedLive: number;
112
+ /** durable 说 park(suspended / needs_review)而跳过的行数 —— 行还活着,不是孤儿(codex R1-F1)。 */
113
+ skippedParked: number;
114
+ /** durable 终局 ⇒ 投影退休的行数(结算级)。 */
115
+ retired: number;
116
+ /** 陈述级退场的行数(顶层 + 孤儿子行)。 */
117
+ orphaned: number;
118
+ /** 随父同扫的子行数。 */
119
+ childrenSwept: number;
120
+ /** durable 终局 ⇒ 投影退休的 workflow 行数。 */
121
+ workflowsRetired: number;
122
+ /** 本周期实际发出的 durable 点读次数。 */
123
+ reads: number;
124
+ /** 本周期因熔断整轮未跑。 */
125
+ cooling: boolean;
126
+ /** 本周期踩了 F 类兜底(读失败/超预算)。 */
127
+ failOpen: boolean;
128
+ }
129
+ /** 对账腿的活对象(有状态:静默钟 + 候补窗 + 熔断 ⇒ `create*` 而非 `build*`)。 */
130
+ export interface FleetReconciler {
131
+ runOnce(nowMs: number): Promise<FleetReconcileTally>;
132
+ /** 解除对总线的订阅(收尾用;定时器由调用方 `clearInterval`)。 */
133
+ stop(): void;
134
+ }
135
+ export declare function createFleetReconciler(deps: FleetReconcilerDeps): FleetReconciler;
136
+ /**
137
+ * 起对账定时器(60s 档,`unref`)+ 重入守卫。返回 handle 供收尾段 `clearInterval`。
138
+ *
139
+ * ⚠️ 装配点必须在 `createHttpServer` **之后** —— 活体谓词是 server 造出来的
140
+ * ({@link FleetReconcilerDeps.isLiveOnThisReplica});这也是本腿不并进 `boot/reapers.ts` 那只 tick 的
141
+ * 唯一原因(reaper 段按「位置即契约」必须在 server 之前建)。
142
+ *
143
+ * 重入守卫照 reapers 的四腿先例:本腿时长随 bus 行数与库延迟增长,短周期下无守卫会逐周期叠罗汉压同一批行。
144
+ */
145
+ export declare function startFleetReconciler(deps: FleetReconcilerDeps): {
146
+ timer: NodeJS.Timeout;
147
+ reconciler: FleetReconciler;
148
+ };
149
+ //# sourceMappingURL=fleet-reconciler.d.ts.map
@@ -0,0 +1,385 @@
1
+ /**
2
+ * #261(docs/DESIGN-261-fleet-liveness-reconcile.md v1.2,四方零驳定稿)—— fleet 总线**存活对账腿**。
3
+ *
4
+ * ## 它修什么(§0 病灶机制链)
5
+ * fleet 总线是进程内纯内存聚合(`fleet-bus.ts` 头注自陈 NOT durable / NOT cross-replica),行的**唯一**
6
+ * 离场路径是发布方主动打 `onTerminal`/`onChildTerminal`。发布方没走到终态臂(run 事件环异常中断、引擎
7
+ * 被壳 reuse 而旧 run 对象已死、BCE 终态通知丢失),行就**永久僵在** `tasks` Map 里 —— 此后每个订阅方
8
+ * (含 snapshot 新连接)看到一条永远 "running" 的幽灵行。壳侧只能靠 overlay 防御(cli 1.0.74 过渡件)。
9
+ *
10
+ * ## 核心裁定:**不造第二个判死者**(§1)
11
+ * 本腿**不判死**。它只做两件事:
12
+ * (a) 副本本地活体登记面(`http/server.ts` 的 `inflightRuns` ∪ `steerableRuns`,经
13
+ * {@link FleetReconcilerDeps.isLiveOnThisReplica} 探针注入 —— **不搬 Map**)在场 ⇒ 行活,跳过;
14
+ * (b) 登记面无此 taskId ⇒ 读 durable run 行,把 durable **已有的判决**投影到总线上。
15
+ * 判死的属主始终是 durable 侧既有机制(心跳 → reaper finalize → 行翻终态);本腿是**投影腿**,
16
+ * 对 durable **恒只读**({@link FleetReconcileRunPort} 这只窄口连写方法都没有 —— 单写者不变量由类型执法)。
17
+ *
18
+ * `msSinceLastActivity` **显式排除**:它是「turn 卡死」诊断维,把它当退休输入会把真忙的后台 run 扫下
19
+ * 面板,方向错。卡死的 run 行**该**留着(它确实在占资源),诊断走 `GET /v1/runs/:id`。
20
+ *
21
+ * ## 对账矩阵(§1;每周期对 bus 的顶层 run 行逐行判)
22
+ * | 登记面 | durable 行 | 处置 |
23
+ * |---|---|---|
24
+ * | 在场 | 任意 | **跳过**(含 turn 卡死形) |
25
+ * | 无 | 终局 | **投影退休**:终态 `task` 帧(durable 真状态 + `retiredBy:"reconcile"` + 被退代际)→ `task_remove`,**结算级** |
26
+ * | 无 | `running`(本副本 claim / 他副本 claim) | **陈述级退场**:`task_remove` + `removeReason:"orphaned"`,**恒不造终态** |
27
+ * | 无 | 行不存在 | 陈述级退场(见下方"静默一周期"的收窄) |
28
+ *
29
+ * 🔴 **对"行不存在"这一格的收窄**(实施稿承重,设计稿 §3 的「拿不准的恒答最弱主张」同源):bus 上
30
+ * 一条 `parentId` 缺席的行**不一定**是顶层 run 行 —— BCE 车道的 `a*` 子代行在父链接尚未解析成功时
31
+ * 同样不发 `parentId`(`rowTags` 的 R6 臂),两者在总线上**结构同形**。而 `getRun(a*)` 必然 miss。
32
+ * 若照矩阵当周期就退场,一条**活着的**长寿 session-scoped bg 子代会被扫下面板。所以这一格额外要求
33
+ * 「**静默满一个对账周期**」(本腿订阅总线自记每行的最后帧时刻):还在出帧 = 活着的证据。
34
+ * durable 行**在场**的那三格没有这个歧义(`getRun` 命中 = 它就是一条 run 行),故照矩阵即时处置。
35
+ *
36
+ * ## 子代行(§1)
37
+ * 随父:父行退休/退场时**同扫**(与 `onTerminal` 的 removal 集同姿势)。子代**不读 durable** ——
38
+ * bg 子代的 durable 真源在 core 侧 registry,server 不越界重判。无父可循的孤儿子行(父行已不在 bus)
39
+ * 按「bus 内滞留超一个对账周期且无新帧」陈述级退场。
40
+ *
41
+ * ## workflow 行(§1)
42
+ * 只做同款「durable 终局投影」(#189 的 snapshot pull 腿 + reapers 的 workflow recover 腿已在场),
43
+ * **不加新判据**:活行恒不动,没有陈述级退场这一形。
44
+ *
45
+ * ## 五条界(任何一条失手都只是幽灵行多活一个周期 ⇒ 整体 F 类 fail-open,登记 tag,不静默)
46
+ * · 节律 {@link FLEET_RECONCILE_INTERVAL_MS} · 每周期读上限 {@link FLEET_RECONCILE_MAX_ROWS}
47
+ * · 读预算 {@link FLEET_RECONCILE_READ_BUDGET_MS} · 熔断冷却 {@link FLEET_RECONCILE_COOLDOWN_MS}
48
+ * · 待终局候补窗 {@link FLEET_RECONCILE_WATCH_MAX} × {@link FLEET_RECONCILE_WATCH_TTL_MS}
49
+ * 任何读错/超预算 ⇒ `recordFailOpen("server.fleet.reconcile-read-failed")` + 熔断 + 本周期跳过。
50
+ *
51
+ * ## 为什么整套常数都不做旋钮
52
+ * 与 `fleet-terminal-window.ts` 同一论证:两侧边界都由语义定死(比周期短 = 幽灵行仍在面板上,比周期长 =
53
+ * 拿对账腿当历史列表),旋钮只会让部署方去调一个没有正确取值的数;fleet 面现有零旋钮,从之。
54
+ */
55
+ import { isTerminalWorkflowStatus } from "@sema-agent/core";
56
+ import { recordFailOpen } from "../observability/fail-open.js";
57
+ import { isParkedRunStatus, isTerminalRunStatus } from "../plugins/store-contracts.js";
58
+ import { buildFleetWorkflowRow, fleetRunResiduals, runStatusToFleet } from "./fleet-bus.js";
59
+ import { isTerminalWorkflowRowStatus } from "./fleet-terminal-window.js";
60
+ /** 对账节律 —— 60s 档。比它短 = 每分钟给 durable 加一批点读却换不来更快的面板真相(幽灵行的时间尺度
61
+ * 是"发布方已经死了",分钟级);比它长 = 壳的引擎温切门(cli engineSwapGate 读活行集)被幽灵行按住
62
+ * 太久。不做旋钮,理由见模块头。 */
63
+ export const FLEET_RECONCILE_INTERVAL_MS = 60_000;
64
+ /** 每周期的**点读上限**。超出的行留到下一周期(顺序稳定 = bus 的插入序),不把一次扫描变成全表点读风暴。 */
65
+ export const FLEET_RECONCILE_MAX_ROWS = 200;
66
+ /** 一个周期的 durable 读**时间预算**。超时 = 放弃这一轮(记 F 类 fail-open),行留在面板上。
67
+ * 与 `FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS` 同值同理由:慢查询不该把整条腿钉死。 */
68
+ export const FLEET_RECONCILE_READ_BUDGET_MS = 2_000;
69
+ /** 熔断冷却:一次读失败/超预算之后,这段时间内**不再发起任何读**。`RunStore`/`WorkflowRunStore` 契约
70
+ * 都没有取消面(发出去的查询撤不回),挂死的后端不该被每周期继续加压。冷却一过自动重探(自愈)。 */
71
+ export const FLEET_RECONCILE_COOLDOWN_MS = 60_000;
72
+ /** 陈述级退场之后的**待终局候补窗**行数上限(§1 矩阵的「终局候 durable 翻转后由下一周期投影」半场:
73
+ * 行已不在 bus,故补发终态 `task` 帧 + remove 的**成对形**)。有界:超出即不再收新条目。 */
74
+ export const FLEET_RECONCILE_WATCH_MAX = 200;
75
+ /** 候补窗的存活时长。reaper 的孤儿宽限窗之外仍没翻终局的行 = 这条腿等不到了(它本来也只是投影),
76
+ * 丢弃并不损失真相:`GET /v1/runs/:id` 永远是那条 run 的真源。 */
77
+ export const FLEET_RECONCILE_WATCH_TTL_MS = 30 * 60_000;
78
+ const emptyTally = () => ({
79
+ scanned: 0, skippedLive: 0, skippedParked: 0, retired: 0, orphaned: 0, childrenSwept: 0, workflowsRetired: 0, reads: 0, cooling: false, failOpen: false,
80
+ });
81
+ /** 从 `cursor` 指的那一行开始的**环形**遍历(见 {@link createFleetReconciler} 里 `taskCursor` 的顶注)。
82
+ * `cursor` 缺席/已不在集合里 ⇒ 从头开始(诚实回落)。返回新数组,调用方照常按序 `for…of`。 */
83
+ function rotate(rows, cursor) {
84
+ if (cursor === undefined || rows.length === 0)
85
+ return rows;
86
+ const at = rows.findIndex((r) => r.id === cursor);
87
+ if (at <= 0)
88
+ return rows;
89
+ return [...rows.slice(at), ...rows.slice(0, at)];
90
+ }
91
+ /** 本周期该不该收这条行 —— 见模块头「对"行不存在"这一格的收窄」。 */
92
+ const SILENT_FOR_A_CYCLE = FLEET_RECONCILE_INTERVAL_MS;
93
+ export function createFleetReconciler(deps) {
94
+ const now = deps.now ?? (() => Date.now());
95
+ /**
96
+ * 每行的**最后帧时刻**。存在理由:「无新帧」是设计稿对孤儿子行(以及本实施稿对"durable 无行"格)
97
+ * 唯一诚实的活性证据 —— 长寿 session-scoped bg 子代在父 run 行离场之后照样出 tick,它必须留在面板上。
98
+ * 从总线订阅得到(而不是自己给行打时间戳):**行的单写者不变量**不许被观察者破坏。
99
+ */
100
+ const lastFrameAt = new Map();
101
+ /** 陈述级退场后的待终局候补窗(见 {@link FLEET_RECONCILE_WATCH_MAX})。 */
102
+ const watch = new Map();
103
+ let coolingUntil;
104
+ /**
105
+ * 🔴 轮转游标(codex 对抗复审 R1-F4 [medium],红先复现):读上限是**每周期**的,而扫描顺序是 bus 的
106
+ * 稳定插入序 —— 于是「超出的行留到下一周期」这句承诺在**行不会自己走**的形下是假的:200 条长期
107
+ * parked/活的行会把预算逐周期吃光,第 201 行往后**永远**读不到,它们的终局投影永不发生(而那正是本腿
108
+ * 存在的理由)。游标记住"上一周期停在哪一行",下一周期从它接着扫,一圈之后必然轮到每一行。
109
+ * 记 id(不是下标):行集会增删,下标会指到别的行上;id 找不到就从头开始(诚实回落,不猜)。
110
+ */
111
+ let taskCursor;
112
+ let wfCursor;
113
+ const unsubscribe = deps.bus.subscribe((frame) => {
114
+ if (frame.type === "task")
115
+ lastFrameAt.set(frame.row.id, now());
116
+ else if (frame.type === "task_remove")
117
+ lastFrameAt.delete(frame.id);
118
+ });
119
+ const wfGet = deps.workflows ? (id) => Promise.resolve(deps.workflows.get(id)) : undefined;
120
+ async function runOnce(nowMs) {
121
+ const tally = emptyTally();
122
+ if (coolingUntil !== undefined && nowMs < coolingUntil) {
123
+ tally.cooling = true;
124
+ return tally;
125
+ }
126
+ coolingUntil = undefined;
127
+ const deadline = now() + FLEET_RECONCILE_READ_BUDGET_MS;
128
+ /** 读闸:上限/预算任一到顶就停(**不是**熔断——本周期已做的处置全部有效,余下的行下周期再来)。 */
129
+ const budgetLeft = () => tally.reads < FLEET_RECONCILE_MAX_ROWS && now() < deadline;
130
+ /** 一次点读。抛错/超预算 ⇒ 抛 {@link ReadAborted},由 runOnce 统一收成 F 类 fail-open + 熔断。 */
131
+ const readRun = async (id) => {
132
+ tally.reads++;
133
+ const rec = await Promise.race([
134
+ deps.runs.getRun(id),
135
+ new Promise((_r, reject) => {
136
+ const t = setTimeout(() => reject(new ReadAborted(`fleet reconcile read exceeded ${FLEET_RECONCILE_READ_BUDGET_MS}ms`)), Math.max(0, deadline - now()));
137
+ if (typeof t.unref === "function")
138
+ t.unref();
139
+ }),
140
+ ]);
141
+ return rec;
142
+ };
143
+ const snap = deps.bus.snapshot();
144
+ // 静默钟的**首见播种**:本腿起动之前就已在 bus 上的行(重启/迟接线)没有帧记录,给它们记一个
145
+ // "从现在开始静默"的起点 —— 否则首轮就会把一屏历史行当成静默满一周期的幽灵。
146
+ for (const r of snap.tasks)
147
+ if (!lastFrameAt.has(r.id))
148
+ lastFrameAt.set(r.id, nowMs);
149
+ const idle = (id) => nowMs - (lastFrameAt.get(id) ?? nowMs) >= SILENT_FOR_A_CYCLE;
150
+ const present = new Set(snap.tasks.map((r) => r.id));
151
+ const kids = new Map();
152
+ for (const r of snap.tasks) {
153
+ if (r.parentId === undefined)
154
+ continue;
155
+ const bucket = kids.get(r.parentId);
156
+ if (bucket)
157
+ bucket.push(r);
158
+ else
159
+ kids.set(r.parentId, [r]);
160
+ }
161
+ /** 随父同扫(与 `fleetRunPublisher.onTerminal` 的 removal 集同姿势)。子代恒**陈述级** ——
162
+ * 我们对它们没有任何终局证据(子代不读 durable),说"离场"是诚实的,说"完成/失败"是编。 */
163
+ const sweepChildren = (parentId) => {
164
+ for (const c of kids.get(parentId) ?? []) {
165
+ deps.bus.removeTask(c.id, { removeReason: "orphaned", ...(c.cycleSeq !== undefined ? { cycleSeq: c.cycleSeq } : {}) });
166
+ lastFrameAt.delete(c.id);
167
+ tally.childrenSwept++;
168
+ }
169
+ };
170
+ /** 投影退休(结算级):终态 `task` 帧 → `task_remove`,**成对且有序**。 */
171
+ const retire = (row, rec) => {
172
+ // 残局键走既有的**同一个** {@link fleetRunResiduals} 映射(键名映射写第二遍必漂;[1839]① 案:
173
+ // 只带 {id,status} 的终态帧让 idle 观察者从行卡上拿不到终局)。源恒是 durable 那份 result,不编。
174
+ deps.bus.publishTask({
175
+ id: row.id,
176
+ status: runStatusToFleet(rec.status),
177
+ retiredBy: "reconcile",
178
+ ...fleetRunResiduals(rec.result),
179
+ });
180
+ sweepChildren(row.id);
181
+ deps.bus.removeTask(row.id, { ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
182
+ lastFrameAt.delete(row.id);
183
+ tally.retired++;
184
+ deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "retired" });
185
+ };
186
+ /** 陈述级退场:只撤行,恒不造终态。行进候补窗,等 durable 翻终局后由后续周期补发成对终帧。 */
187
+ const orphanExit = (row, why) => {
188
+ sweepChildren(row.id);
189
+ deps.bus.removeTask(row.id, { removeReason: "orphaned", ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
190
+ lastFrameAt.delete(row.id);
191
+ if (deps.runs && watch.size < FLEET_RECONCILE_WATCH_MAX)
192
+ watch.set(row.id, { at: nowMs, row });
193
+ tally.orphaned++;
194
+ deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "orphaned" });
195
+ deps.logger?.info("fleet_row_reconciled", { taskId: row.id, disposition: "orphaned", why, instanceId: deps.instanceId ?? null });
196
+ };
197
+ try {
198
+ // ── ① 候补窗:陈述级退场过的行,等 durable 翻终局 ─────────────────────────────────────
199
+ // 为什么要补这一拍:cli 的裁量是「渲**离场**不清行」([4044])—— 陈述级退场之后壳把行**保留**在
200
+ // 面板上(中性离场形、active 扣除、折叠排后),所以那条行后来真的翻了终局时,补发的成对终帧是壳
201
+ // 唯一能把"离场"升级成"真终局"的信号。行已不在 bus,故 publish 会**瞬时重铸**再撤(flip-through,
202
+ // 与 `onChildTerminal` 的合成终帧同姿势);带上退场时记住的整行,可见性字段(scope/sessionId)
203
+ // 才不会丢 —— 丢了就变成 scoped 订阅方一律看不见(fail-closed 到没人收得到)。
204
+ for (const [id, w] of [...watch]) {
205
+ if (nowMs - w.at > FLEET_RECONCILE_WATCH_TTL_MS) {
206
+ watch.delete(id);
207
+ continue;
208
+ }
209
+ // 行又出现在 bus 上 = **复活**(更大代际的首帧 = 正常出生):活发布方接管,候补窗让位。
210
+ if (present.has(id)) {
211
+ watch.delete(id);
212
+ continue;
213
+ }
214
+ if (!budgetLeft())
215
+ break;
216
+ const rec = await readRun(id);
217
+ if (!rec) {
218
+ watch.delete(id);
219
+ continue;
220
+ } // 行被 reap 掉了:没有终局可投影,诚实丢弃
221
+ if (!isTerminalRunStatus(rec.status))
222
+ continue; // 还没定局 —— 下个周期再看
223
+ deps.bus.publishTask({ ...w.row, status: runStatusToFleet(rec.status), retiredBy: "reconcile", ...fleetRunResiduals(rec.result) });
224
+ deps.bus.removeTask(id, { ...(w.row.cycleSeq !== undefined ? { cycleSeq: w.row.cycleSeq } : {}) });
225
+ lastFrameAt.delete(id);
226
+ watch.delete(id);
227
+ tally.retired++;
228
+ deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "retired_deferred" });
229
+ }
230
+ // ── ② 顶层 run 行 ────────────────────────────────────────────────────────────────────
231
+ for (const row of rotate(snap.tasks, taskCursor)) {
232
+ if (row.parentId !== undefined)
233
+ continue; // 子代行:随父(下面 ③ 收孤儿)
234
+ tally.scanned++;
235
+ if (deps.isLiveOnThisReplica(row.id)) {
236
+ // 登记面在场 ⇒ 活。**含 turn 卡死形** —— 诊断不驱逐(§1)。一次 durable 读都不发。
237
+ tally.skippedLive++;
238
+ continue;
239
+ }
240
+ if (!deps.runs)
241
+ continue; // 无 durable run 店的部署(memory 车道):没有可投影的判决,整格不动
242
+ if (!budgetLeft()) {
243
+ taskCursor = row.id;
244
+ break;
245
+ } // 轮转:下周期从这一行接着扫(见 rotate 顶注)
246
+ const rec = await readRun(row.id);
247
+ // 🔴 await 后**复核**(codex 对抗复审 R1-F3 [high],红先复现):`isLiveOnThisReplica` 在读之前只
248
+ // 问过一次,而 durable 读的那几十毫秒里 parked run 完全可能被 resume —— 它会重新登记进在飞面并
249
+ // 发出新行帧,而读回来的行还写着 `running`。不复核就等于把**刚复活**的顶层行删掉;顶层行按设计
250
+ // 恒无 `cycleSeq`(「无此概念」),总线那道代际条件删也救不了这一格,只能在这里问第二次。
251
+ if (deps.isLiveOnThisReplica(row.id)) {
252
+ tally.skippedLive++;
253
+ continue;
254
+ }
255
+ const nowRow = deps.bus.taskRow(row.id);
256
+ if (nowRow === undefined)
257
+ continue; // 行在读期间自己走了(发布方终于 settle 了):无事可做
258
+ if (nowRow.cycleSeq !== row.cycleSeq)
259
+ continue; // 代际翻过 = 已复活,活发布方接管
260
+ if (rec === undefined) {
261
+ // durable 无行。**必须**静默满一个周期才退场:`a*` 子代行在父链接未解析时同样无 parentId,
262
+ // 与顶层行结构同形,而 `getRun(a*)` 必然 miss(见模块头收窄段)。还在出帧 = 活着的证据。
263
+ if (idle(row.id))
264
+ orphanExit(row, "durable_row_absent");
265
+ continue;
266
+ }
267
+ if (isTerminalRunStatus(rec.status)) {
268
+ retire(row, rec);
269
+ continue;
270
+ }
271
+ // 🔴 **park 不是孤儿**(codex 对抗复审 R1-F1 [high],红先复现):`suspended` / `needs_review` 的行
272
+ // 还活着 —— `task_active` claim 还占着、checkpoint 还能被决议,而 fleet 契约(`fleetRunPublisher`
273
+ // 的 `onTerminal`)明写 park 态的行**留在面板上**(waiting / awaiting approval)。它们恰恰**不在**
274
+ // 任何在飞登记面里(park 的 run 没有活腿),所以上面那道活体谓词接不住它们 —— 不在这里显式跳过,
275
+ // 每一轮对账都会把一条合法的待审批任务从面板上摘掉,而用户看到的是"任务凭空消失"。
276
+ if (isParkedRunStatus(rec.status)) {
277
+ tally.skippedParked++;
278
+ continue;
279
+ }
280
+ // durable 还 `running` 而本副本没有它:发布方死了、durable 还没定局(reaper 宽限窗内),
281
+ // 或 run 活在别的副本上。两形处置相同 —— **不造终态**,只陈述离场。
282
+ orphanExit(row, rec.instanceId !== null && rec.instanceId !== (deps.instanceId ?? null) ? "durable_running_other_replica" : "durable_running_no_local_leg");
283
+ }
284
+ // ── ③ 无父可循的孤儿子行 ─────────────────────────────────────────────────────────────
285
+ // 判据里**没有** durable 读:bg 子代的真源在 core 侧 registry,server 不越界重判。
286
+ for (const row of snap.tasks) {
287
+ if (row.parentId === undefined || present.has(row.parentId))
288
+ continue;
289
+ if (!idle(row.id))
290
+ continue; // 还在出帧 = 长寿 session-scoped 子代活过了父 leg,留着
291
+ deps.bus.removeTask(row.id, { removeReason: "orphaned", ...(row.cycleSeq !== undefined ? { cycleSeq: row.cycleSeq } : {}) });
292
+ lastFrameAt.delete(row.id);
293
+ tally.orphaned++;
294
+ deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "orphaned_child" });
295
+ }
296
+ // ── ④ workflow 行:只做 durable 终局投影 ─────────────────────────────────────────────
297
+ if (wfGet) {
298
+ for (const wf of rotate(snap.workflows, wfCursor)) {
299
+ if (isTerminalWorkflowRowStatus(wf.status))
300
+ continue; // 已是终态行:活写路径自己会撤
301
+ if (!budgetLeft()) {
302
+ wfCursor = wf.id;
303
+ break;
304
+ }
305
+ tally.reads++;
306
+ const run = await Promise.race([
307
+ wfGet(wf.id),
308
+ new Promise((_r, reject) => {
309
+ const t = setTimeout(() => reject(new ReadAborted("fleet reconcile workflow read over budget")), Math.max(0, deadline - now()));
310
+ if (typeof t.unref === "function")
311
+ t.unref();
312
+ }),
313
+ ]);
314
+ if (!run || !isTerminalWorkflowStatus(run.status))
315
+ continue;
316
+ // 租户复核:`store.get` 是**无 scope 门**的裸读(带门的是 core 的 `getWorkflowRun`)——
317
+ // id 来自本进程的行,仍按真行的 scope 复核一次(同 fleet-terminal-window 的姿势)。
318
+ if (wf.scope !== undefined && run.scope !== wf.scope)
319
+ continue;
320
+ deps.bus.publishWorkflow(buildFleetWorkflowRow(wf.id, run));
321
+ deps.bus.removeWorkflow(wf.id);
322
+ tally.workflowsRetired++;
323
+ deps.metrics?.inc("fleet_rows_reconciled_total", { disposition: "workflow_retired" });
324
+ }
325
+ }
326
+ }
327
+ catch (err) {
328
+ // F 类:读不出来 ≠ 该退休。**只向留行一侧** fail-open —— 库抖一下就把一屏在跑的任务从面板上
329
+ // 抹掉,比多留一条幽灵行伤得多。本周期已经做完的处置全部有效(它们各自都有 durable 证据)。
330
+ coolingUntil = now() + FLEET_RECONCILE_COOLDOWN_MS;
331
+ tally.failOpen = true;
332
+ // detail 用 `err.message` 而不是 `String(err)`:后者会调用抛出值自己的 toString,而那本身可能再抛
333
+ // (逃出本兜底,把降级变成故障)。非 Error 抛出值就如实说"不是 Error",不去问它任何问题。
334
+ recordFailOpen("server.fleet.reconcile-read-failed", err instanceof Error ? err.message : "fleet reconcile read threw a non-Error value");
335
+ }
336
+ return tally;
337
+ }
338
+ return {
339
+ runOnce,
340
+ stop: unsubscribe,
341
+ };
342
+ }
343
+ /** 读预算到点的**具名**抛出值(与真实的店错误在日志里可分,且不会被误当成"店坏了"去查库)。 */
344
+ class ReadAborted extends Error {
345
+ }
346
+ /**
347
+ * 起对账定时器(60s 档,`unref`)+ 重入守卫。返回 handle 供收尾段 `clearInterval`。
348
+ *
349
+ * ⚠️ 装配点必须在 `createHttpServer` **之后** —— 活体谓词是 server 造出来的
350
+ * ({@link FleetReconcilerDeps.isLiveOnThisReplica});这也是本腿不并进 `boot/reapers.ts` 那只 tick 的
351
+ * 唯一原因(reaper 段按「位置即契约」必须在 server 之前建)。
352
+ *
353
+ * 重入守卫照 reapers 的四腿先例:本腿时长随 bus 行数与库延迟增长,短周期下无守卫会逐周期叠罗汉压同一批行。
354
+ */
355
+ export function startFleetReconciler(deps) {
356
+ const reconciler = createFleetReconciler(deps);
357
+ let inFlight = false;
358
+ const timer = setInterval(() => {
359
+ if (inFlight)
360
+ return;
361
+ inFlight = true;
362
+ void reconciler
363
+ .runOnce(Date.now())
364
+ .then((t) => {
365
+ if (t.retired > 0 || t.orphaned > 0 || t.workflowsRetired > 0) {
366
+ deps.logger?.info("fleet_reconcile_swept", { retired: t.retired, orphaned: t.orphaned, childrenSwept: t.childrenSwept, workflowsRetired: t.workflowsRetired, reads: t.reads });
367
+ }
368
+ }, (err) => {
369
+ // runOnce 自己已把读面的失败收成 fail-open;能到这里的只剩"对账腿自己有 bug"这一族。
370
+ // 观测绝不能变成故障(同 reapers 的 throttled catch 判据):整块 total/non-throwing。
371
+ try {
372
+ deps.logger?.warn("fleet_reconcile_failed", { err: err instanceof Error ? err.message : "non-Error throw" });
373
+ }
374
+ catch {
375
+ /* observability must never become the outage */
376
+ }
377
+ })
378
+ .finally(() => {
379
+ inFlight = false;
380
+ });
381
+ }, FLEET_RECONCILE_INTERVAL_MS);
382
+ timer.unref?.();
383
+ return { timer, reconciler };
384
+ }
385
+ //# sourceMappingURL=fleet-reconciler.js.map
@@ -14,11 +14,11 @@ export declare const RENDERED_ENTRY_TYPES: readonly ["message", "compaction", "c
14
14
  /**
15
15
  * 跳过的理由逐型给(「纯控制型」不是一句话,是逐个核过模型看不见):
16
16
  * thinking_level_change / model_change / label / session_info / leaf / prompt_epoch /
17
- * announced_listing / workspace_state —— 控制面元数据,core 折 messages 时不产出内容;
17
+ * announced_listing / workspace_state / git_announcement —— 控制面元数据,core 折 messages 时不产出内容;
18
18
  * custom —— data-only(session.js 折 messages 只认 message/custom_message 两型),模型看不见它的
19
19
  * data;把它渲出来反而是把模型没见过的东西冒充成证据,与 [2092] 是同一判据的反方向。
20
20
  */
21
- export declare const SKIPPED_ENTRY_TYPES: readonly ["thinking_level_change", "model_change", "label", "session_info", "leaf", "prompt_epoch", "announced_listing", "workspace_state", "custom"];
21
+ export declare const SKIPPED_ENTRY_TYPES: readonly ["thinking_level_change", "model_change", "label", "session_info", "leaf", "prompt_epoch", "announced_listing", "workspace_state", "git_announcement", "custom"];
22
22
  /** 渲染结果。**判别式**:证据不足时调用方拿不到 `text`,想用也用不了(见顶注)。 */
23
23
  export type BranchTranscript = {
24
24
  sufficient: true;
@@ -37,7 +37,7 @@ export const RENDERED_ENTRY_TYPES = ["message", "compaction", "custom_message"];
37
37
  /**
38
38
  * 跳过的理由逐型给(「纯控制型」不是一句话,是逐个核过模型看不见):
39
39
  * thinking_level_change / model_change / label / session_info / leaf / prompt_epoch /
40
- * announced_listing / workspace_state —— 控制面元数据,core 折 messages 时不产出内容;
40
+ * announced_listing / workspace_state / git_announcement —— 控制面元数据,core 折 messages 时不产出内容;
41
41
  * custom —— data-only(session.js 折 messages 只认 message/custom_message 两型),模型看不见它的
42
42
  * data;把它渲出来反而是把模型没见过的东西冒充成证据,与 [2092] 是同一判据的反方向。
43
43
  */
@@ -50,6 +50,11 @@ export const SKIPPED_ENTRY_TYPES = [
50
50
  "prompt_epoch",
51
51
  "announced_listing",
52
52
  "workspace_state",
53
+ // core 5.36.0(env-tail 迁移):git-status 帧的**已通告态**快照条目(`{kind,hash,entryId?,pending?}`)。
54
+ // 与上面两位同族、同理由 —— 它是控制面的「模型上次被展示的是哪一份 git 视图」记号,**不是**模型看见的
55
+ // 内容:真正被看见的那份帧正文骑在它 `entryId` 指的那条 `message` 上(已由渲染臂收下)。把这条快照渲进
56
+ // 证据面 = 把一串 sha256 摘要当成对话冒充给评估者,正是 [2092] 判据的反方向。
57
+ "git_announcement",
53
58
  "custom",
54
59
  ];
55
60
  /** 单次评估送进模型的 transcript 字符上限。超出保留**尾部**(最近的对话对"条件是否已达成"更相关)。 */
@@ -98,6 +98,11 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
98
98
  id: "keep", parentId: "keep", parentToolCallId: "keep", workflowRunId: "keep", status: "keep",
99
99
  startedAt: "keep", elapsedMs: "keep", tokens: "keep", queuedCount: "keep",
100
100
  awaitingPlanApproval: "keep", toolUses: "keep", usage: "keep", resumable: "keep", sourceLane: "keep",
101
+ // #261 §2:两键都是**结构事实**,零会话内容 —— `cycleSeq` 是 core registry 行的代际号
102
+ // (数值,消费端判「复活 vs 前代迟到帧」的唯一钥匙),`retiredBy` 说的是「这条终态是对账腿从
103
+ // durable 投影的、不是发布方亲报的」。降权投影里丢掉它们 = 未署名会话的行**判不了代**,而复活
104
+ // 与迟到帧在无代际号时不可区分(正是本 issue 要消灭的那类静默错判)。故 keep。
105
+ cycleSeq: "keep", retiredBy: "keep",
101
106
  name: "rewrite", scope: "strip", sessionId: "strip",
102
107
  description: "scrub", agentType: "scrub", agentName: "scrub", currentAction: "scrub",
103
108
  currentTool: "scrub", transcriptId: "scrub", stoppedBy: "scrub", editedFiles: "scrub",
@@ -125,6 +130,8 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
125
130
  ...(rest.usage !== undefined ? { usage: rest.usage } : {}),
126
131
  ...(rest.resumable !== undefined ? { resumable: rest.resumable } : {}),
127
132
  ...(rest.sourceLane !== undefined ? { sourceLane: rest.sourceLane } : {}),
133
+ ...(rest.cycleSeq !== undefined ? { cycleSeq: rest.cycleSeq } : {}), // #261:代际号是结构事实(见处置表)
134
+ ...(rest.retiredBy !== undefined ? { retiredBy: rest.retiredBy } : {}),
128
135
  };
129
136
  }
130
137
  return rest;
@@ -200,8 +207,17 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
200
207
  }
201
208
  break;
202
209
  case "task_remove":
203
- if (seenTasks.delete(frame.id))
204
- send("task_remove", { id: frame.id, ts: frame.ts });
210
+ // #261 §2③/§5 Q-d:两个 additive 键随帧透传。撤行帧本就**只投给见过这一行的连接**
211
+ // (`seenTasks.delete` 的返回值即判据)—— 这正是 Q-d 那条边界钉的服务端半场:不持行的
212
+ // 订阅方连撤行帧都收不到,自然没有"结算"义务。`removeReason` 缺席 = 终态之后的常规清场。
213
+ if (seenTasks.delete(frame.id)) {
214
+ send("task_remove", {
215
+ id: frame.id,
216
+ ts: frame.ts,
217
+ ...(frame.removeReason !== undefined ? { removeReason: frame.removeReason } : {}),
218
+ ...(frame.cycleSeq !== undefined ? { cycleSeq: frame.cycleSeq } : {}),
219
+ });
220
+ }
205
221
  break;
206
222
  case "workflow_remove":
207
223
  // 🔴 #189 / codex R2-M1:按窗**刻意保留**的历史行不受本连接的撤行帧影响。行在 durable 读
@@ -144,6 +144,51 @@ async function subagentReadGate(req, res, deps, runId) {
144
144
  }
145
145
  return { run, trusted };
146
146
  }
147
+ /**
148
+ * #261 §3 —— a\* 句柄读腿的 **404 三分**(TaskOutput / tail / task-handle output+stop 四条腿共用)。
149
+ *
150
+ * ## 病灶(cli 原票的另一半)
151
+ * 这些腿的 404 此前把三件互不相干的事说成同一句话:(i) handle 从未存在(打错字);(ii) **引擎重启后
152
+ * registry 失忆** —— 句柄活在进程内 registry 里,宿主 run 一终局/进程一重启它就没了;(iii) **打错副本**
153
+ * —— run 正在别的 replica 上跑,句柄在**那边**的 registry 里。壳只能猜(cli 四刀里「断流判据修正」
154
+ * 就是在替这个缺口打补丁)。
155
+ *
156
+ * ## 判别材料从哪来(**零新增读**,这点是承重的)
157
+ * 全部来自 {@link subagentReadGate} 已经读到手、且**已证明属于调用方**的那条**宿主 run 行**:
158
+ * · `isTerminalRunStatus(run.status)` ⇒ 宿主 run 已终局 ⇒ 它的进程内 registry 子代必然已随之离场
159
+ * ⇒ `not_found.task_owner_settled`;
160
+ * · `run.status === "running"` 且 `run.instanceId` 指向**别的**副本 ⇒ 它的句柄都在那边的 registry 里
161
+ * ⇒ `not_found.task_owner_elsewhere`;
162
+ * · 其余(本副本在跑 / park 中 / `instanceId` 为 null 的前列行)⇒ **最弱主张**,码与文案逐字不变。
163
+ *
164
+ * 🔴 **两个新码陈述的是「属主」,不是「这个句柄」**(codex 对抗复审 R1-F5 [medium],验真后采纳)。
165
+ * 走到这里的 `not_found` 同样可能来自**拼错的句柄**、错的 kind、或别的 run 的句柄 —— 本函数手上
166
+ * **没有**任何目标级证据能证明这个句柄存在过。首版的码名(`task_retired`)与文案(「它存在过,已终局」)
167
+ * 因此是**越权断言**:一个自动客户端会据此停止查找,而真相可能只是它把 id 打错了。现在两个码逐字只说
168
+ * 属主 run 的**状态与位置**(那是调用方本就有权读到的东西,`GET /v1/runs/:id` 直答),句柄本身仍是
169
+ * 那句最弱主张 "no such background task handle here" —— 判别性给到了(壳能分清「registry 失忆」与
170
+ * 「打错副本」),存在性一个字都没多说。
171
+ *
172
+ * 🔴 **A-041 反枚举不变式原样保留**:三分**只对自己人开**。跨 principal 的调用方在上面那道门就已经
173
+ * 被 `not_found.run` 挡掉(与 unknown 臂同码同文,#262 oracle 封口),根本走不到这里 —— 所以这三分
174
+ * 泄露的全部信息是「**你自己**这条 run 的状态」,而那本来就是 `GET /v1/runs/:id` 直答的东西。
175
+ *
176
+ * 🔴 **拿不准的恒答最弱主张**(§3 逐字):本函数不为判别性去造任何新证据 —— 不查 registry、不查
177
+ * durable background_agent 行。`instanceId === null`(建列之前的行)是"不知道"而不是"在本机",故落最弱臂。
178
+ */
179
+ function sendHandleMiss(res, run, thisInstanceId) {
180
+ if (isTerminalRunStatus(run.status)) {
181
+ sendError(res, 404, "not_found.task_owner_settled", "no such background task handle here. Routing hint about its OWNER (not about this handle): the run has already settled, and background handles live in the replica-local registry for the parent's lifetime, so none of its handles are readable any more. Read the run's own outcome via GET /v1/runs/:id");
182
+ return true;
183
+ }
184
+ // `instanceId` 缺席(null,或建列之前/替身行上的 undefined)= **不知道它在哪**,不是"在别处" ——
185
+ // 落最弱主张。同理 `thisInstanceId` 缺席时本副本连自己是谁都说不上,更不能断言别人。
186
+ if (run.status === "running" && typeof run.instanceId === "string" && run.instanceId.length > 0 && thisInstanceId !== undefined && run.instanceId !== thisInstanceId) {
187
+ sendError(res, 404, "not_found.task_owner_elsewhere", "no such background task handle here. Routing hint about its OWNER (not about this handle): the run is executing on another replica, and this one's registry holds none of its handles. Ask the replica that owns the run, or read the run's own outcome via GET /v1/runs/:id");
188
+ return true;
189
+ }
190
+ return false;
191
+ }
147
192
  async function streamRunEvents(req, res, deps, runStore, taskId, staleMs) {
148
193
  await streamSseLog(req, res, {
149
194
  // #151 车3 §5.1:durable tail 腿的开流重放 —— 本 run 的未决审批卡按 taskId 读回并投成
@@ -1242,7 +1287,10 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1242
1287
  // agent rows are OBSERVATION identities (never in the registry) — their read face is the workflow
1243
1288
  // journal (GET /v1/workflows/:id/journal), and they land in the not_found arm here by construction.
1244
1289
  if (details?.error === "not_found" || details?.type !== "background_agent") {
1245
- sendError(res, 404, "not_found.subagent", `no background agent "${target}" under this run (unknown handle, not this run's child, or already reaped — bg children live in the replica-local registry for the parent's lifetime; wa… workflow-agent rows are read via the workflow journal, not this verb)`);
1290
+ // #261 §3:同 principal 前提下的 404 三分(材料全部来自已证属主的宿主 run 行,零新增读)
1291
+ // #261 §3:同 principal 前提下的 404 三分(材料全部来自已证属主的宿主 run 行,零新增读)。
1292
+ if (!sendHandleMiss(res, run, deps.instanceId))
1293
+ sendError(res, 404, "not_found.subagent", `no background agent "${target}" under this run (unknown handle, not this run's child, or already reaped — bg children live in the replica-local registry for the parent's lifetime; wa… workflow-agent rows are read via the workflow journal, not this verb)`);
1246
1294
  return;
1247
1295
  }
1248
1296
  // Pass the registry's honest projection through: status/retrieval_status/partial flags verbatim,
@@ -1287,7 +1335,9 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1287
1335
  const probeDetails = probe.details;
1288
1336
  if (probeDetails?.error === "not_found" || probeDetails?.type !== "background_agent") {
1289
1337
  void it.return?.();
1290
- sendError(res, 404, "not_found.subagent", `no background agent "${target}" under this run (unknown handle, not this run's child, or already reaped — bg children live in the replica-local registry for the parent's lifetime; wa… workflow-agent rows are read via the workflow journal, not this verb)`);
1338
+ // #261 §3 三分( output 面同判据同码)
1339
+ if (!sendHandleMiss(res, run, deps.instanceId))
1340
+ sendError(res, 404, "not_found.subagent", `no background agent "${target}" under this run (unknown handle, not this run's child, or already reaped — bg children live in the replica-local registry for the parent's lifetime; wa… workflow-agent rows are read via the workflow journal, not this verb)`);
1291
1341
  return;
1292
1342
  }
1293
1343
  sseHeaders(res);
@@ -1400,7 +1450,9 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1400
1450
  : await deps.taskHandleOutput(target, access);
1401
1451
  const details = out.details;
1402
1452
  if (details?.error === "not_found") {
1403
- sendError(res, 404, "not_found.task_handle", `no background task "${target}" under this run (unknown handle, not this run's task, already reaped — handles live in the replica-local registry for the parent's lifetime — or a workflow handle: workflow rows read via GET /v1/workflows/:id/journal; stopping a workflow is not on this wire)`);
1453
+ // #261 §3 三分(GET output POST stop 共这一处)
1454
+ if (!sendHandleMiss(res, run, deps.instanceId))
1455
+ sendError(res, 404, "not_found.task_handle", `no background task "${target}" under this run (unknown handle, not this run's task, already reaped — handles live in the replica-local registry for the parent's lifetime — or a workflow handle: workflow rows read via GET /v1/workflows/:id/journal; stopping a workflow is not on this wire)`);
1404
1456
  return;
1405
1457
  }
1406
1458
  // [1499] codex R3: a stop whose kill did NOT land must not read as success. Core keeps the handle
@@ -529,6 +529,7 @@ export declare function validateUserSkills(skills: unknown): string | null;
529
529
  */
530
530
  export declare function createHttpServer(rawDeps: ServiceDeps): http.Server & {
531
531
  denyExpiredApprovals: (now: number) => Promise<void>;
532
+ isRunLiveOnThisReplica: (taskId: string) => boolean;
532
533
  };
533
534
  /** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
534
535
  * guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
@@ -3080,9 +3080,18 @@ export function createHttpServer(rawDeps) {
3080
3080
  if (parkedSkipped > 0)
3081
3081
  deps.logger?.info?.("deny_sweep_parked_skipped", { count: parkedSkipped });
3082
3082
  }
3083
+ /**
3084
+ * #261 §1(a):**副本本地活体谓词** —— 这条 taskId 现在是不是本副本手上的活腿。
3085
+ *
3086
+ * 交出的是一个 `(taskId) => boolean` **探针**,不是 Map:登记面的属主仍是本模块(它们是 cancel /
3087
+ * steer / preempt 的权威表),对账腿只问一个是非题。搬 Map 会造出第二份活性判据,而两份判据必漂移。
3088
+ * 并集口径与 `drainState.inflight` 逐字同源(`inflightRuns` = 在飞 durable 腿、`steerableRuns` =
3089
+ * 持活 core 流的腿;一条 resume 腿同时在两表里),所以「本副本认为它活着」在两条消费线上是同一句话。
3090
+ */
3091
+ const isRunLiveOnThisReplica = (taskId) => inflightRuns.has(taskId) || steerableRuns.has(taskId);
3083
3092
  // Augment the http.Server with the D-D deny-sweep handle (back-compat: all 12 callers keep using the return
3084
3093
  // value AS an http.Server — listen/close/etc. — while main.ts's reaper reads server.denyExpiredApprovals).
3085
- return Object.assign(server, { denyExpiredApprovals });
3094
+ return Object.assign(server, { denyExpiredApprovals, isRunLiveOnThisReplica });
3086
3095
  }
3087
3096
  const WORKER_SWAP_REDEEMABLE = {
3088
3097
  version_newer: true,
package/dist/main.js CHANGED
@@ -43,6 +43,7 @@ import { createConfigCenterRuntime } from "./boot/config-center.js";
43
43
  import { createResolveSpec } from "./boot/resolve-spec.js";
44
44
  import { createParkedReviveInheritedGate } from "./boot/parked-revive-gate.js";
45
45
  import { startReapers } from "./boot/reapers.js";
46
+ import { startFleetReconciler } from "./fleet/fleet-reconciler.js";
46
47
  import { openStores } from "./boot/stores.js";
47
48
  import { runAdoptionBootScan } from "./boot/adoption.js";
48
49
  import { auditDormantPermissionRules } from "./boot/permission-rules-audit.js";
@@ -1102,6 +1103,19 @@ async function main() {
1102
1103
  // D-D SLA-timer: wire the server's deny-sweep into the reaper holder declared above (the reaper is defined
1103
1104
  // before the server, so it calls through this late-bound reference).
1104
1105
  runDenySweep = server.denyExpiredApprovals;
1106
+ // #261:fleet 总线存活对账腿。⚠️ 位置即契约 —— 必须在 `createHttpServer` **之后**:活体谓词
1107
+ // (`inflightRuns` ∪ `steerableRuns` 的探针)是 server 造出来的,而 reaper 段按其自身的位置契约必须在
1108
+ // server 之前起,所以本腿独立起表(60s 档,unref;判据/五条界全在 fleet-reconciler.ts)。
1109
+ // 零写 durable:窄口 `getRun`/`get` 两个读方法,判死属主仍是 reaper。
1110
+ const fleetReconcile = startFleetReconciler({
1111
+ bus: fleetBus,
1112
+ isLiveOnThisReplica: server.isRunLiveOnThisReplica,
1113
+ ...(runStore ? { runs: runStore } : {}),
1114
+ ...(workflowRunStore ? { workflows: workflowRunStore } : {}),
1115
+ instanceId,
1116
+ logger,
1117
+ metrics,
1118
+ });
1105
1119
  // [1934]:绑址旋钮 + 无鉴权自收窄(理由见 resolveBindHost 顶注)。undefined = Node 默认全接口。
1106
1120
  const bindHost = resolveBindHost(config);
1107
1121
  // [2062]③ HOST 继承暗通道告警:zsh 常把 HOST 设成机器名,`{...process.env}` 起底的壳会静默继承给
@@ -1232,7 +1246,7 @@ async function main() {
1232
1246
  // design/158 A10:收尾段搬到 src/boot/shutdown.ts(逐字)。⚠️ 调用点必须留在 listen 之后 —— 位置即契约,
1233
1247
  // 理由(信号注册时点/三信号相对次序/clearInterval 先于 server.close)见该文件头注。
1234
1248
  installShutdownHandlers({
1235
- config, logger, server, reaper, otelExporter, breakerState, costQuota, rateLimiter,
1249
+ config, logger, server, reaper, fleetReconcile, otelExporter, breakerState, costQuota, rateLimiter,
1236
1250
  runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState,
1237
1251
  storeLiveProbe, configCenter, // #131-2:两个漏网的进程级后台环进收尾链
1238
1252
  });
@@ -69,6 +69,10 @@ export declare const FAIL_OPEN_TAGS: {
69
69
  readonly cls: "F";
70
70
  readonly note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。";
71
71
  };
72
+ readonly "server.fleet.reconcile-read-failed": {
73
+ readonly cls: "F";
74
+ readonly note: "#261 fleet 存活对账腿的一次 durable 读失败/超预算(2s)⇒ **本周期整轮跳过**并开熔断,不做任何投影。放行的最坏后果 = 幽灵行(发布方已死、行僵在内存 Map 里)在面板上多活几个周期,与本腿存在之前的行为等同(那时它永远活着),不是新损失;真源不受影响(`GET /v1/runs/:id` 照常)。方向刻意 fail-open 且**只向留行一侧**:读不出来 ≠ 该退休——库抖一下就把一屏在跑的任务从面板上抹掉,比多留一条幽灵行伤得多。必须留痕:静默吞掉之后「对账腿为什么一直没生效」在遥测里没有任何痕迹,而本腿的全部价值就是「幽灵行会自己走」。";
75
+ };
72
76
  readonly "server.fleet.run-status-unmapped": {
73
77
  readonly cls: "F";
74
78
  readonly note: "A-032 P1-④:`runStatusToFleet` 收到**词表外**的 run 状态词(闭集签名之外的唯一来路 = `rowToRun` 把无约束的 status 文本列裸 cast,滚动升级里更新的副本写的新词被旧副本读到)⇒ 折成 `idle`。放行的最坏后果 = 该行在 fleet 面板上**永久滞留**(removal 集 {completed,failed,killed} 收不到 idle),纯展示面、不影响 run 本身与任何执法判据,故 F 类。方向刻意不改(把 miss 折成终局会把一条还在跑的任务从面板上摘掉,误摘比多一行更伤)。留痕是承重的:2026-06-28 on-box e2e 逮到的 `blocked` 泄漏当年只能靠人眼在面板上发现——计数让下一次词表漂移在遥测里当场显形。编译期一侧已由闭集入参 + `never` 臂执法。";
@@ -92,6 +92,10 @@ export const FAIL_OPEN_TAGS = {
92
92
  cls: "F",
93
93
  note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。",
94
94
  },
95
+ "server.fleet.reconcile-read-failed": {
96
+ cls: "F",
97
+ note: "#261 fleet 存活对账腿的一次 durable 读失败/超预算(2s)⇒ **本周期整轮跳过**并开熔断,不做任何投影。放行的最坏后果 = 幽灵行(发布方已死、行僵在内存 Map 里)在面板上多活几个周期,与本腿存在之前的行为等同(那时它永远活着),不是新损失;真源不受影响(`GET /v1/runs/:id` 照常)。方向刻意 fail-open 且**只向留行一侧**:读不出来 ≠ 该退休——库抖一下就把一屏在跑的任务从面板上抹掉,比多留一条幽灵行伤得多。必须留痕:静默吞掉之后「对账腿为什么一直没生效」在遥测里没有任何痕迹,而本腿的全部价值就是「幽灵行会自己走」。",
98
+ },
95
99
  "server.fleet.run-status-unmapped": {
96
100
  cls: "F",
97
101
  note: "A-032 P1-④:`runStatusToFleet` 收到**词表外**的 run 状态词(闭集签名之外的唯一来路 = `rowToRun` 把无约束的 status 文本列裸 cast,滚动升级里更新的副本写的新词被旧副本读到)⇒ 折成 `idle`。放行的最坏后果 = 该行在 fleet 面板上**永久滞留**(removal 集 {completed,failed,killed} 收不到 idle),纯展示面、不影响 run 本身与任何执法判据,故 F 类。方向刻意不改(把 miss 折成终局会把一条还在跑的任务从面板上摘掉,误摘比多一行更伤)。留痕是承重的:2026-06-28 on-box e2e 逮到的 `blocked` 泄漏当年只能靠人眼在面板上发现——计数让下一次词表漂移在遥测里当场显形。编译期一侧已由闭集入参 + `never` 臂执法。",
@@ -159,6 +159,7 @@ export declare function taskProgressEventData(ev: {
159
159
  parentToolCallId?: string;
160
160
  workflowRunId?: string;
161
161
  workflowAgentLabel?: string;
162
+ seq?: number;
162
163
  }): Record<string, unknown>;
163
164
  /** The `task_notification` payload (core 1.202 design/115 P2: a background task finished — this event is the
164
165
  * LEDGER/display projection of the OBSERVED terminal state, emitted the moment core observes it. It does NOT
@@ -196,6 +196,13 @@ export function toolEndEventData(ev) {
196
196
  export function taskProgressEventData(ev) {
197
197
  return {
198
198
  taskId: ev.taskId,
199
+ // core 5.36.0(#258,[4066] 提货①):这一拍所报的 registry 行**代际号**(fresh spawn = 1,每次
200
+ // 复活翻转 +1)—— 与 `TaskNotificationPayload.seq` / `BackgroundChildEvent.seq` **同一条轴**,
201
+ // core 原话「不是第三种拼法」。它回答的是消费端在帧迟到时otherwise判不了的那一个问题:
202
+ // 「这是我已知那一代的迟到首帧(同值),还是一次我还没折叠的复活(更大)?」
203
+ // 🔴 **缺席是事实不是缺口**:没有 `a*` registry 行的 run(同步委派子代 / workflow `wa*` / 顶层 run)
204
+ // 根本没有代际概念,缺席**禁**读作 cycle 1。core-mint 数值,无用户内容 ⇒ verbatim,缺席不铸键。
205
+ ...(typeof ev.seq === "number" ? { seq: ev.seq } : {}),
199
206
  // core 5.14.0([2854] 六车批②):委派种类判别键(core `DelegationTaskType` =
200
207
  // "background_agent" | "workflow")—— 消费端据此路由一条 progress 帧,不必再从 id 形状猜。
201
208
  // 形参取 `string` 而非闭枚举:该类型**没有**从 core 包根导出(亲验 index.d.ts 零命中,与发车帖
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.24.0",
3
+ "version": "7.25.0-rc.1",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "^5.35.0",
57
+ "@sema-agent/core": "^5.36.0",
58
58
  "@sema-agent/registry-core": "^0.16.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",