@sema-agent/client-core 0.74.2 → 0.74.3

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.
package/CHANGELOG.md CHANGED
@@ -49,6 +49,25 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.74.3(2026-09-19)
53
+
54
+ > 主题:**面板子代身份归一**(CC-70;cli L-407 / test 定谳:同一后台子代在 wire 上两把身份)+ **run 记录取消现场读口与成因闭集**(CC-71;cli L-212 (a))+ **workflow 列表行 run 级 errorCode**(CC-72;core 7.23.5 #932)+ **reopen 族第二臂两件**(CC-73;cli L-56)。**patch**:型面纯 additive(+2 常量 +7 函数,公面 1052 → 1061;测试钩 50 → 51;`EngineAgentPanelEvent` 三臂各加可选键;零 wire 新键;peer 不动)。接入面 §71。
55
+
56
+ ### Added
57
+
58
+ - **面板事件的子代身份归一(CC-70)**:`publishEngineAgentPanelEvent` 成为三条 lane 的唯一漏斗 —— `fleet-row` 登记两把钥匙(`transcriptId` 主 / `parentToolCallId` 退路),`tick` / `end` 上的 `task_progress` UUID 查得到就改写成 fleet 行 id 尾段(= 注册表 task_id = 宿主本地 agentId),原词进新键 `wireTaskId`,`taskIdOrigin:'fleet-row'`;查不到:绑卡的 tick(新提示键 `cardBound`,同步委派形)直发 `taskIdOrigin:'wire'`,未绑的 tick **缓一拍**等行帧(同 UUID 的 fleet-row 到 ⇒ 先行帧再放 tick;第二条 tick ⇒ 新的原样发旧的丢;`end` ⇒ 先 tick 后 end;满 64 条 ⇒ 最旧的原样发;别的 fleet-row / end / sweep 过了 `MAX_HELD_WIRE_TICK_BEATS`(16)拍 ⇒ 原样发;`sweep` 本身不放 —— 没行可结)。钥匙带周期身份:归一后的 `end` 自己不带 `cycleSeq` / `startedAt` 时借登记它的那条 fleet-row 的,复活后迟到的 `end{旧 UUID}` 因此带旧代际、`isStaleEngineAgentPanelEnd` 判得出;经退路键归一过的 UUID 记进主表,随后不带 `parentToolCallId` 的 `end{UUID}` 同样归一(三条皆异源对抗复审 R1 所抓)。常驻标随归一从 UUID 迁到尾段(此前 UUID 记、17 位清 ⇒ 永不清)。新读口 `resolveEnginePanelTaskId(wireTaskId, parentToolCallId?)`(带钥匙上的周期身份);`fleet-row` 事件与 `FleetAgentRowLike` +`parentToolCallId?`;tick 臂 +`parentToolCallId?` / `cardBound?`。**宿主零改**:面板按 `ev.taskId` 落表即可,同一子代不再长成两行。
59
+ - **`src/runCancelContext.ts`(CC-71;server ≥7.87.3 `PersistedTaskResult.cancelContext` 四座,sdk 无型 ⇒ 结构读)**:`readRunCancelContext(record)` —— 缺席 ⇒ `not_reported`(**不折成「未取消」**);四座各自可缺席各自防御读(有 age 无 kind 合法;`elapsedMs` 绝不折 0;`lastBrainStatus` 原样带出);坏形 ⇒ `undefined`。`classifyRunAbortCause(record)` 成因闭集四词:`cancelled`(码 `cancelled`,带现场)/ `engine_error`(别的码,原码开集不枚举)/ `run_still_live`(记录仍非终态 —— 断连是客户端侧事实,不是 run 的成因)/ `unknown`(不猜)。常量 `RUN_CANCELLED_CODE`。
60
+ - **`FleetWorkflowView.errorCode?`(CC-72)**:workflow 列表行的 run 级 errorCode 原词逐字过境(开集);缺席 = 无码,**不是成功也不是失败**;sdk 未声明前按结构读(空串 / 非串 ⇒ 缺席)。
61
+ - **`lastFlagValue` 上公面(CC-80;归层)**:argv 末次旗值扫描原语(`--name v` / `--name=v`、裸 `--` 之后不扫、重复旗末次胜、缺值策略 `invalidate` / `latch-previous` 调用方选)—— 包内 limits / sandbox / scenario / headlessPermissionMode 四处早已收编,端侧仍在手写同一循环;现在从 `index` 导出,`FlagValue` / `OnMissingFlagValue` 型同出。
62
+ - **reopen 族第二臂(CC-73)**:① `DecideReceiptView` +`errorCode?` / `retriable?`(`/decide` 200 **终局形**才有;在场逐字,`retriable` 严格布尔)+ `decideReceiptReopen(receipt)` —— 与 `resumeReopenFromError` **同一只**判官(`status === 'failed'` ∧ 码 ∈ `RESUME_REOPEN_CODES` ∧ `retriable === true`,缺一件 ⇒ null);② `suspendedReopenOf(ev)` 读 durable `suspended.reopened` 三态(`reopened{code}` / `not_reopened`(显式 null)/ `unstated`(键缺席 —— 不冒充 null));`HitlBridge.currentGateReopen()` 把它挂在挂起座上,每条 `suspended` 重读、随座清。
63
+
64
+ ### Fixed
65
+
66
+ - **异源对抗复审两轮抓的六条(CC-70 / CC-71)**:① 经退路键归一的 tick 之后 `end{UUID}` 归不了一(行永不结、报告丢)⇒ 归一时把 UUID → 尾段记进主表;② 复活后迟到的 `end{旧 UUID}` 不带周期 ⇒ 钥匙带周期身份、`end` 借用;③ 缓着的 tick 无时限 ⇒ 节拍上限 `MAX_HELD_WIRE_TICK_BEATS`(别的 tick 不算拍);④ 钥匙表 FIFO 会把仍在飞的子代挤掉 ⇒ LRU 续命、上限 2048;⑤ 第 16 拍恰是自己的行帧(退路键)时先记拍会让它过期 ⇒ 行帧先登记先放再记拍;⑥ 终态 tick / 通知的清标只清 UUID ⇒ 两把键都清;⑦(R3)前代 UUID 的终态清掉复活行的常驻标 ⇒ 下一次 sweep 假结它 ⇒ 清标走 `clearEnginePanelTaskResidentByWire`:归一键只在终态属当代时才清(判据与 `end` 陈旧判据同一只)。`classifyRunAbortCause`:result 已终态而外层 `status` 仍在飞词 = 证据矛盾 ⇒ `unknown`(带终态词),不说「还活着」。
67
+ - **0.74.2 补审(六只能力位读器)**:`projectDeviceExecutorManagementCapability` 外层 `deviceExecutor` 此前 undefined 判与取值各读一次 ⇒ 变化的 getter 把第一次观测到的「车道缺席」丢掉,现在只读一次;六只 doctor 的 `unobserved` 句此前说「还没收到 caps 回体」—— 坏形删格后也这么说 = 把解析失败说成没启动,改成「本进程没有可用的读数(没收到,或最后一次读不懂)」(措辞变化,消费方不接线零影响)。
68
+ - 面板常驻标「UUID 记、17 位清」永不清(CC-70 同根):tick 臂现在把常驻标记在归一后的键下(`noteResidency(taskId, residentKey)`),通知车道按尾段清得掉。
69
+ - `classifyRunAbortCause` 对平面 `running` / `queued` 记录:平面读器对在飞词答 `unknown{word}`,那不是「读不出」而是「还没结」⇒ 先看 `status` 再落 `unknown`。
70
+
52
71
  ## 0.74.2(2026-09-19)
53
72
 
54
73
  > 主题:**`capabilities.deviceExecutor.management` 四态读口**(CC-69;server 7.88.0 点名本包)。**patch**:型面纯 additive(+6 函数,公面 1046 → 1052;测试钩 49 → 50;零 wire 新键;peer 不动)。
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.74.2
38
+ **Version:** 0.74.3
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -314,6 +314,9 @@ public-surface guard checks that last one).
314
314
  | `scripts/run-mcp-panel-projection-test.mjs` | The `GET /v1/sessions/:id/mcp` panel reader (`projectMcpPanel`; server >=7.77.0 adds the optional `lastLegMcp` key) and the single wording mint for its "last leg" line. Absence of `lastLegMcp` is one literal sentence that never blames the engine version (a new session, a leg outside the retention window, a leg without a manifest and an older engine all look the same on the wire); a key that is present but unreadable is a different sentence plus a `lastLegMcpUnreadable: true` mark, never folded into absence. The `mcp[]` roster goes through the same reader as the live `wiring_manifest` third section, so a replayed roster and a live one have one shape. The two faces of the panel (`servers[]` and the last-leg roster) may legitimately differ, so the view carries no agreement flag and none of the five sentences mentions `servers`. Required keys are pinned to the SDK `openapi.yaml` component bytes **0.69.0:** `fetchMcpPanel` fetches the panel through the SDK client's own `sessions.mcp` call (same transport and auth as every other read) and projects it; transport failure, an unreadable body and an empty session id all come back as `undefined`, never as a fabricated empty panel 0.71.0 adds section K: `mcpEngineLegPresence(view)` — the engine-side MCP presence tri-state read only off the panel view (`unknown` when the view could not be read, never rendered as "no MCP configured") |
315
315
  | `scripts/run-absence-fold-census-test.mjs` | A package-wide census of the "absence folded into a positive outcome" defect shape, so that fixing the six sites this release does not merely move the shape somewhere else. The defect is defined by position, not syntax: a fallback position (the unconditional tail return, the `default:` arm, the literal minted when there is nothing to pass on, the value returned from an error path) may only say `unknown` or stay absent, never a positive word. Detection walks the syntax tree of every source file, so comments, strings and multi-line spellings cannot hide or fake a hit, and covers five forms: the right arm of `??` / `||`, the else arm of a ternary, the first return of an explicit `default:`, a `catch` block or `.catch(() => …)` arrow returning a healthy value, and a function whose last statement returns a positive word after other returns. Every remaining hit must be registered with a written reason, an unregistered hit fails the gate naming the file and line, the registered count must equal the real count so a cleared site cannot leave a spare allowance behind, and the gate proves its own teeth behind a fence (a failed self-proof refuses to report any count): each form injected into an in-memory copy must add exactly one hit, two correct spellings are pinned as non-hits, and samples inside comments or strings do not count. It also pins the headline site: the fleet panel projection no longer mints an `end` with `isError: false` on absence |
316
316
  | `scripts/run-device-executor-management-capability-test.mjs` | The engine's device-management self-description (`capabilities.deviceExecutor.management`, engine ≥7.88.0), read the same four-state way as its five sibling capability readers: an absent `management` key is reported as not reported (never folded into `false`; an older engine really ships the lane object without it), an absent `deviceExecutor` key is likewise not reported, `deviceExecutor: false` is the lane being absent, presence is judged by own-property not truthiness, the value must be a strict boolean, the tee never throws and drops stale generations, and the package owns the verdict on whether the `/v1/devices` management verbs are usable (`yes` only when present and true, `no` when present-false or lane-absent, otherwise `unknown`) |
317
+ | `scripts/run-run-cancel-context-test.mjs` | The run record's `cancelContext` side-note (engine ≥7.87.3) read structurally, and the cause of a `turn_aborted{engine_error}` classified from machine-readable evidence only: `cancelled` (code `cancelled`, with the cancel-time context when present) / `engine_error` (any other failure code, passed through verbatim) / `run_still_live` (the record is not terminal — a dropped stream is a client-side fact, not the run's cause) / `unknown` (never guessed). An absent `cancelContext` reads as *not reported*, never as "not cancelled"; `elapsedMs` is never folded to 0. |
318
+ | `scripts/run-suspended-reopen-projection-test.mjs` | The durable `suspended` event's `reopened` key read as three distinct states — `reopened` (with the engine's code, verbatim), `not_reopened` (an explicit `null`), `unstated` (key absent or unreadable) — and carried on the HITL bridge's active gate (`currentGateReopen()`), re-read on every `suspended` and cleared with the gate. |
319
+ | `scripts/run-panel-identity-normalization-test.mjs` | One background subagent has two ids on the wire — the fleet row id tail and the `task_progress` task id (its transcript id). Every panel event goes through one funnel that rewrites the `tick` / `end` task id onto the fleet row's id once a `fleet-row` has registered the key (`transcriptId` first, `parentToolCallId` as the fallback), carrying the original as `wireTaskId` and marking `taskIdOrigin`; an unbound tick whose row has not arrived yet waits one beat (bounded) and is released verbatim on the next tick / `end`, when the buffer is full, or after `MAX_HELD_WIRE_TICK_BEATS` other fleet-row / end / sweep events (a `sweep` itself leaves it alone: there is no row to settle yet); a normalized `end` that carries no cycle identity borrows the registering row's, so a late close of a revived task is recognized as stale; the key table is an LRU (a task that keeps ticking is never evicted by newer registrations); the residency mark migrates with the id and both keys are cleared on settle — except that a stale (previous-cycle) terminal never clears the revived row's mark — so the notification lane can clear it. |
317
320
  | `scripts/run-approval-card-retract-test.mjs` | The approval card's **decision-free retraction** and the in-stream frame leg's **outcome hand-back**: a host that must withdraw a card that no longer has a decision channel (session switch, engine switch, a tracker reporting the ask gone) answers `{ kind: 'retracted' }` and the package sends nothing on any of the three legs (in-stream frame, suspended ask, durable park), reporting `decision: 'unresolved'` with a `retracted` flag; `aborted` / `failed` / `deny` keep their meaning (a real deny is still posted), and `onToolApprovalOutcome` hands every in-stream outcome back to the host exactly once, tolerating a throwing or rejecting callback |
318
321
  | `scripts/run-panel-cycle-identity-test.mjs` | The **cycle identity** on agent-panel events and the fleet ledger's **departure read-out**: a background agent may be revived under the same id, so `fleet-row` and `end` events now carry the wire's own `cycleSeq` / `startedAt` when present (absent means the row has no notion of generations, never "generation one"), `isStaleEngineAgentPanelEnd` is the single rule for ignoring a late `end` from a previous cycle (only when both sides carry a comparable identity; absence never drops a real terminal), a changed `cycleSeq` is a new cycle for usage stickiness and buffer coalescing, the notification lane carries `cycleSeq` only when the wire really sent `seq`, and `task_remove` frames reach the host through `onTaskRemoved` with `removeReason` / `cycleSeq` verbatim, a stale previous-generation removal leaving the newer row in place |
319
322
  | `scripts/run-workflow-size-warning-test.mjs` | The **workflow size warning** verdict shared by every host footer / panel: a three-state result (`warn` / `ok` / `unknown`) read off the optional fleet view keys, where an unknown size is never reported as a normal one (absent `totalCount` / `tokens` without positive over-cap evidence is `unknown`, naming the missing keys), positive evidence on either axis wins regardless of absent keys, the per-agent denominator uses the engine's started count only when it is not below done+failed (a smaller value is a stale reading), otherwise falls back to the done+failed lower bound only when both keys are present — and a lower-bound denominator only yields an upper bound of the projection, which can prove *within cap* (`ok`, flagged) but never *over cap* (`unknown`, with the upper bound exposed) — and the prior is used only when the engine itself reports zero started agents; cap precedence env > explicit guideline > default, prototype keys never act as a guideline, the env reader is pure and does not fall through to the second name on a bad first value; caps, guideline table, env names and the three copy variants are single-sourced |
@@ -10,6 +10,7 @@ import { readAsyncLaunchedAgentReceipt } from '../toolResult.js';
10
10
  import { isWorkflowAgentTaskId, recordWorkflowAgentTaskId } from '../workflow.js';
11
11
  // L-215+(0.65.0;core [6908]):终局状态词的单铸谓词(三处同形一次改齐)。
12
12
  import { isTerminalStatus } from '../runTerminal.js';
13
+ import { resolveEnginePanelTaskId } from '../engineAgentPanelStore.js';
13
14
  import { chrome, MAIN, transcript, messageIdentityOf } from './ids.js';
14
15
  import { CANCEL_MESSAGE, decisionOf, flattenWireOutput, REJECT_MESSAGE, sanitizeToolUseBlock, shortTaskLabel, TASK_TOOL_NAMES, WORKFLOW_TOOL_NAMES, } from './wireShapes.js';
15
16
  // ══ M2/M1 —— assistant(tool_use 半场 + durable 文本/思考半场)══════════════════════════════════
@@ -831,7 +832,9 @@ const taskProgressArm = function* (m, { ctx, cards, panel, inst }) {
831
832
  yield* panel.start(taskId, agentType);
832
833
  }
833
834
  // #6 常驻台账的两个写口整臂让位给 M3(module store 不许两个模块各写各的)。
834
- panel.noteResidency(taskId);
835
+ // 0.74.3 CC-70:常驻标记在**归一后的键**下(fleet 尾段 = 通知车道清标用的那把);此前 UUID 记、17 位清 ⇒ 永不清。
836
+ const identity = resolveEnginePanelTaskId(taskId, explicitParent);
837
+ panel.noteResidency(taskId, identity.taskId);
835
838
  panel.markLive(taskId);
836
839
  yield chrome({
837
840
  kind: 'panel_task',
@@ -843,6 +846,9 @@ const taskProgressArm = function* (m, { ctx, cards, panel, inst }) {
843
846
  ...(description !== undefined ? { description } : {}),
844
847
  ...(prompt !== undefined ? { prompt } : {}),
845
848
  ...(currentAction !== undefined ? { currentAction } : {}),
849
+ // 0.74.3 CC-70:身份归一的两把钥匙 —— 退路键 + 发布方提示(绑卡 = 同步委派,漏斗不用等 fleet 行)。
850
+ ...(explicitParent !== undefined ? { parentToolCallId: explicitParent } : {}),
851
+ ...(panel.isLiveBound(taskId) ? { cardBound: true } : {}),
846
852
  // 🔴 这里的 `: 0` 与 `fleetAgentPanelProjection` 那句「绝不在本层 `?? 0`」**不矛盾**,
847
853
  // 因为不是同一条 lane 的同一种供给形(两层的分层实情写在 `engineAgentPanelStore.ts`
848
854
  // 的 `PANEL_TOOLUSES_LANE_POLICY` 上,两条 lane 的可选性由编译钉锁住):
@@ -59,7 +59,8 @@ export interface PanelTaskLedger {
59
59
  /** 登记「本 turn 发布过面板行」。 */
60
60
  markLive(taskId: string): void;
61
61
  /** #6 常驻台账:live-bound ⇒ 清常驻;inert/unbound ⇒ 记常驻(module store 的**唯一**写口之一)。 */
62
- noteResidency(taskId: string): void;
62
+ /** 0.74.3 CC-70:`residentKey` = 常驻标该记在哪把键下(归一后的 fleet 尾段);缺省 = taskId。绑卡判定仍按 tick 的 taskId。 */
63
+ noteResidency(taskId: string, residentKey?: string): void;
63
64
  /** SubagentStart(类型恒记,fire 一生一次)。 */
64
65
  start(taskId: string, agentType: string): Generator<AdapterOutput>;
65
66
  /** 关卡 settle(cardId)或 turn 末/abort 防御 sweep(cardId===null)。 */
@@ -1,4 +1,4 @@
1
- import { clearEnginePanelTaskResident, markEnginePanelTaskResident } from '../engineAgentPanelStore.js';
1
+ import { clearEnginePanelTaskResident, clearEnginePanelTaskResidentByWire, markEnginePanelTaskResident } from '../engineAgentPanelStore.js';
2
2
  import { chrome, MAIN } from './ids.js';
3
3
  import { SUBAGENT_TOOL_NAMES } from './wireShapes.js';
4
4
  export function createPanelTaskLedger(ctx, cards, inst) {
@@ -16,6 +16,8 @@ export function createPanelTaskLedger(ctx, cards, inst) {
16
16
  * 与 unbound 行是 **session 常驻**:终态只认 task_notification / bg_notification。
17
17
  */
18
18
  const liveBoundPanelTasks = new Set();
19
+ /** 0.74.3 CC-70(异源复审 R2 [medium] / R3 [high]):常驻标可能记在归一键(fleet 尾段)下 —— 两把键都清,但前代终态不清当代的尾段键(判据在 store,单源)。 */
20
+ const clearResidencyBothKeys = (taskId) => clearEnginePanelTaskResidentByWire(taskId);
19
21
  /** taskId → 子代类型(Stop hook 查它;cli 同为 turn 局部)。 */
20
22
  const subagentTypeById = new Map();
21
23
  const laneOf = (taskId) => {
@@ -98,11 +100,11 @@ export function createPanelTaskLedger(ctx, cards, inst) {
98
100
  },
99
101
  // #6 session 常驻台账:live-bound(卡本 turn 开着)= 本 turn 生命周期,turn 末可 sweep;
100
102
  // inert/unbound(跨 turn bg 子代形)= 常驻,两处 sweep 都放行,终态只认通知帧。
101
- noteResidency: (taskId) => {
103
+ noteResidency: (taskId, residentKey = taskId) => {
102
104
  if (liveBoundPanelTasks.has(taskId))
103
- clearEnginePanelTaskResident(taskId);
105
+ clearEnginePanelTaskResident(residentKey);
104
106
  else
105
- markEnginePanelTaskResident(taskId);
107
+ markEnginePanelTaskResident(residentKey);
106
108
  },
107
109
  /** SubagentStart(cli fireSubagentStartHook 的臂化):类型恒记,fire 一生一次。 */
108
110
  *start(taskId, agentType) {
@@ -173,7 +175,7 @@ export function createPanelTaskLedger(ctx, cards, inst) {
173
175
  *settleFromNotification(taskId, isError) {
174
176
  // #6 通知-settle 边(推送半场):session 常驻的 bg 子代行不再被 turn sweep 假结,
175
177
  // 真终态在此落行(failed/killed ⇒ isError 真归因)。
176
- clearEnginePanelTaskResident(taskId);
178
+ clearResidencyBothKeys(taskId);
177
179
  if (endedPanelTasks.has(taskId))
178
180
  return;
179
181
  endedPanelTasks.add(taskId);
@@ -209,7 +211,7 @@ export function createPanelTaskLedger(ctx, cards, inst) {
209
211
  if (endedPanelTasks.has(taskId))
210
212
  return;
211
213
  endedPanelTasks.add(taskId);
212
- clearEnginePanelTaskResident(taskId);
214
+ clearResidencyBothKeys(taskId);
213
215
  const lane = laneOf(taskId);
214
216
  const card = taskCardBinding.get(taskId);
215
217
  if (card !== undefined) {
@@ -74,7 +74,7 @@ export function livePendingNeedsReconcile(reading) {
74
74
  export function approvalsStreamLiveDoctorDetail(reading) {
75
75
  switch (reading.kind) {
76
76
  case 'unobserved':
77
- return 'live approval push not observed — the engine reports it on /v1/capabilities; this process has not received a capabilities response yet, so suspended asks are pulled by the reconcile cadence';
77
+ return 'live approval push not observed — the engine reports it on /v1/capabilities; this process has no usable capabilities reading cached for it (none received, or the last one was unreadable), so suspended asks are pulled by the reconcile cadence';
78
78
  case 'not_reported':
79
79
  return 'live approval push not reported by this engine — only newer engines advertise it; this does not say whether it pushes, so suspended asks are pulled by the reconcile cadence';
80
80
  case 'present':
@@ -32,6 +32,7 @@
32
32
  * 消费端渲卡时:**在场**才说「执行时被放行/被否」,缺席一个字都不说。
33
33
  */
34
34
  import { type GateOutcomeView } from './gateOutcome.js';
35
+ import { type ResumeReopenDetail } from './resumeRefusalCopy.js';
35
36
  /** 一次 `/decide` 200 的读数。**每一位都可能缺席**(四种形没有共同保证的键)。 */
36
37
  export interface DecideReceiptView {
37
38
  /** 回体上的 `status`(开集透传;受理形与 parked 回执上是 `"resuming"`,回放形上**没有**这一位)。 */
@@ -60,6 +61,13 @@ export interface DecideReceiptView {
60
61
  * 🔴 **缺席 = 未知,禁读成 allowed**(见模块顶注的两处上游逐字)。
61
62
  */
62
63
  executionOutcome?: GateOutcomeView;
64
+ /**
65
+ * 0.74.3 CC-73:**终局形**才有的两位(契约:`verify:true` / 无 run store 的同步长调用在 200 体上回
66
+ * `{ status:"failed", errorCode, retriable:true }`;受理形刻意不带)。在场逐字,缺席不落键;`retriable` 严格布尔,
67
+ * `false` 是 server 说的照带。判「这是不是 reopen 类失败」走 {@link decideReceiptReopen},别在端上凭码猜。
68
+ */
69
+ errorCode?: string;
70
+ retriable?: boolean;
63
71
  }
64
72
  /**
65
73
  * `/decide` 的 200 体 → {@link DecideReceiptView};不是一只对象 / 一位都读不出 ⇒ `undefined`,
@@ -70,6 +78,13 @@ export interface DecideReceiptView {
70
78
  * 🔴 **不补默认、不折缺席**:读不出 `status` 不等于终局,读不出 `executionOutcome` 不等于 allowed。
71
79
  */
72
80
  export declare function readDecideReceipt(result: unknown): DecideReceiptView | undefined;
81
+ /**
82
+ * 0.74.3 CC-73(cli L-56 ①):`/decide` 200 **终局形**是不是 reopen 类失败 —— 三件合取,与 {@link resumeReopenFromError}
83
+ * **同一只**判官(码 ∈ `RESUME_REOPEN_CODES` ∧ `retriable === true`),外加终局形自己的 `status === 'failed'`。
84
+ * 任一件缺 ⇒ `null`:受理形带同码不算(它不是终局)、`retriable` 缺席 / false 不算(凭码不说「卡仍 pending」)。
85
+ * 读数在场 ⇒ 卡仍 pending、决定没被消费、可再决;文案走 `resumeReopenContent(detail)`。
86
+ */
87
+ export declare function decideReceiptReopen(receipt: DecideReceiptView | undefined): ResumeReopenDetail | null;
73
88
  /**
74
89
  * 🔴 **这次 decide 的 200 有没有把门解决掉** —— 唯一的诚实答案是「**说不出来**」。
75
90
  *
@@ -32,6 +32,7 @@
32
32
  * 消费端渲卡时:**在场**才说「执行时被放行/被否」,缺席一个字都不说。
33
33
  */
34
34
  import { gateOutcomeOf } from './gateOutcome.js';
35
+ import { resumeReopenFromError } from './resumeRefusalCopy.js';
35
36
  import { DECIDE_WORKFLOW_HOST_UNKNOWN, DECIDE_WORKFLOW_LANE_CODES, DECIDE_WORKFLOW_REMEMBER_UNSUPPORTED, } from './engineErrorCodes.js';
36
37
  /** 受理形/parked 回执上那个**唯一**表示「受理了、还没做完」的状态词(sdk README §9.0.0 逐字)。 */
37
38
  const STATUS_RESUMING = 'resuming';
@@ -63,11 +64,15 @@ export function readDecideReceipt(result) {
63
64
  const executionOutcome = Object.hasOwn(r, 'executionOutcome')
64
65
  ? gateOutcomeOf({ gate: r.executionOutcome })
65
66
  : undefined;
67
+ const errorCode = Object.hasOwn(r, 'errorCode') ? str(r.errorCode) : undefined;
68
+ const retriable = Object.hasOwn(r, 'retriable') && typeof r.retriable === 'boolean' ? r.retriable : undefined;
66
69
  if (status === undefined &&
67
70
  taskId === undefined &&
68
71
  sessionId === undefined &&
69
72
  idempotent === undefined &&
70
- executionOutcome === undefined) {
73
+ executionOutcome === undefined &&
74
+ errorCode === undefined &&
75
+ retriable === undefined) {
71
76
  return undefined;
72
77
  }
73
78
  return {
@@ -77,8 +82,23 @@ export function readDecideReceipt(result) {
77
82
  ...(idempotent !== undefined ? { idempotent } : {}),
78
83
  ...(handoffTaskId !== undefined ? { handoffTaskId } : {}),
79
84
  ...(executionOutcome !== undefined ? { executionOutcome } : {}),
85
+ ...(errorCode !== undefined ? { errorCode } : {}),
86
+ ...(retriable !== undefined ? { retriable } : {}),
80
87
  };
81
88
  }
89
+ /** 200 终局形上「这是不是 reopen 类失败」的判据词(契约:reopen 类失败仍 `suspended` 且锁在手,200 体 `status:"failed"`)。 */
90
+ const STATUS_FAILED = 'failed';
91
+ /**
92
+ * 0.74.3 CC-73(cli L-56 ①):`/decide` 200 **终局形**是不是 reopen 类失败 —— 三件合取,与 {@link resumeReopenFromError}
93
+ * **同一只**判官(码 ∈ `RESUME_REOPEN_CODES` ∧ `retriable === true`),外加终局形自己的 `status === 'failed'`。
94
+ * 任一件缺 ⇒ `null`:受理形带同码不算(它不是终局)、`retriable` 缺席 / false 不算(凭码不说「卡仍 pending」)。
95
+ * 读数在场 ⇒ 卡仍 pending、决定没被消费、可再决;文案走 `resumeReopenContent(detail)`。
96
+ */
97
+ export function decideReceiptReopen(receipt) {
98
+ if (receipt === undefined || receipt.status !== STATUS_FAILED)
99
+ return null;
100
+ return resumeReopenFromError(receipt);
101
+ }
82
102
  /**
83
103
  * 🔴 **这次 decide 的 200 有没有把门解决掉** —— 唯一的诚实答案是「**说不出来**」。
84
104
  *
@@ -23,9 +23,13 @@ export function projectDeviceExecutorManagementCapability(caps) {
23
23
  if (caps === null || typeof caps !== 'object')
24
24
  return undefined;
25
25
  const c = caps;
26
- if (!Object.hasOwn(c, 'deviceExecutor') || c.deviceExecutor === undefined)
26
+ if (!Object.hasOwn(c, 'deviceExecutor'))
27
27
  return { kind: 'not_reported', why: 'capability_absent' };
28
+ // 🔴 外层也只读一次(0.74.2 补审 [medium]):hasOwn 之后 `deviceExecutor` 读进局部量,undefined / false / 对象三判都对同一次读;
29
+ // 此前 undefined 判与取值各读一次,变化的 getter 会把第一次观测到的「车道缺席」丢掉。
28
30
  const d = c.deviceExecutor;
31
+ if (d === undefined)
32
+ return { kind: 'not_reported', why: 'capability_absent' };
29
33
  if (d === false)
30
34
  return { kind: 'lane_absent' };
31
35
  if (d === null || typeof d !== 'object' || Array.isArray(d))
@@ -92,7 +96,7 @@ export function deviceManagementVerbsAvailable(reading) {
92
96
  export function deviceExecutorManagementDoctorDetail(reading) {
93
97
  switch (reading.kind) {
94
98
  case 'unobserved':
95
- return 'device management face not observed — the engine reports it on /v1/capabilities (deviceExecutor.management); this process has not received a capabilities response yet';
99
+ return 'device management face not observed — the engine reports it on /v1/capabilities (deviceExecutor.management); this process has no usable capabilities reading cached for it (none received, or the last one was unreadable)';
96
100
  case 'not_reported':
97
101
  return reading.why === 'management_absent'
98
102
  ? 'device management face not reported by this engine — the device lane is advertised but only newer engines say whether the /v1/devices management verbs exist; this does not say whether they exist, probe them directly'
@@ -37,6 +37,27 @@ export type EngineAgentPanelEvent = {
37
37
  currentAction?: string;
38
38
  toolUses: number;
39
39
  totalTokens: number;
40
+ /**
41
+ * 0.74.3 CC-70:tick 上带的委派卡 id(wire `task_progress.parentToolCallId`,在场才带)—— 身份归一的**退路键**
42
+ * (`transcriptId` 查不到时按它反查 fleet 尾段)。
43
+ */
44
+ parentToolCallId?: string;
45
+ /**
46
+ * 0.74.3 CC-70(**发布方提示键**,消费端忽略):`true` = 这条 tick 绑在本 turn 一张仍开着的委派卡上(同步委派形)。
47
+ * 身份归一的漏斗据此决定「查不到 fleet 尾段时直发还是缓一拍」:绑卡的子代不会有 fleet 行要等。
48
+ */
49
+ cardBound?: true;
50
+ /**
51
+ * 0.74.3 CC-70:**归一后**才有 —— 这条事件在 wire 上原来的 `taskId`(引擎 UUIDv7 = transcriptId);
52
+ * `taskId` 已改写成 fleet 行 id 尾段。在场 = 改写过;缺席 = 原样。
53
+ */
54
+ wireTaskId?: string;
55
+ /**
56
+ * 0.74.3 CC-70:这条 tick 的 `taskId` 是哪一把身份 —— `'fleet-row'` = 已归一到 fleet 尾段(与 fleet-row / 通知 end /
57
+ * 壳本地 agentId 同键);`'wire'` = 反查不到,原样发(同步委派子代 / 老 server 行帧无 transcriptId / fleet 行还没到)。
58
+ * tick 恒带(它只有一个铸点 = task_progress);`end` 只在归一时带。
59
+ */
60
+ taskIdOrigin?: 'fleet-row' | 'wire';
40
61
  } | {
41
62
  /**
42
63
  * 行帧 lane 投影(2026-07-26 「后台 Agent 子代不进 Background 面板」案)。
@@ -100,6 +121,11 @@ export type EngineAgentPanelEvent = {
100
121
  * 🔴 缺席 = 这一行**没有代际概念**(同步委派子代 / workflow agent / 顶层 run / 老引擎),**不是第一代**。
101
122
  */
102
123
  cycleSeq?: number;
124
+ /**
125
+ * 0.74.3 CC-70:行帧上的委派卡 id(sdk `FleetTaskRow.parentToolCallId`,在场才带)—— 与 tick 的同名键是同一把钥匙,
126
+ * 身份归一的退路(`transcriptId` 缺席时凭它把 task_progress 的 UUID 反查到本行)。
127
+ */
128
+ parentToolCallId?: string;
103
129
  } | {
104
130
  kind: 'end';
105
131
  taskId: string;
@@ -116,6 +142,10 @@ export type EngineAgentPanelEvent = {
116
142
  * explicit close arm carries it; the defensive sweeps settle without one). The consumer persists it
117
143
  * on the row (task.result) + appends it to a live-viewed transcript (拍1 查看态 seed). */
118
144
  report?: string;
145
+ /** 0.74.3 CC-70:同 tick 臂 —— 归一后才有(流内 close 臂 / 终态 tick 的 `end` 带的是 UUID,改写成 fleet 尾段时记原词)。 */
146
+ wireTaskId?: string;
147
+ /** 0.74.3 CC-70:只在归一时带 `'fleet-row'`;通知车道 / fleet 投影铸的 `end` 本来就是 fleet 尾段,不带。 */
148
+ taskIdOrigin?: 'fleet-row';
119
149
  } | {
120
150
  /** Exception-path safety net (published by seamQuery after every turn, throw or return):
121
151
  * the consumer settles ALL rows it owns that are still running. Idempotent. */
@@ -146,6 +176,30 @@ export interface EngineAgentPanelCycleIdentity {
146
176
  * 陈旧的 `end`:忽略(不 settle、不写 report、不摘所有权)。
147
177
  */
148
178
  export declare function isStaleEngineAgentPanelEnd(end: EngineAgentPanelCycleIdentity | null | undefined, current: EngineAgentPanelCycleIdentity | null | undefined): boolean;
179
+ /** 缓一拍的节拍上限:别的 fleet-row / end / sweep 每过一条算一拍(别的 tick 不算)。 */
180
+ export declare const MAX_HELD_WIRE_TICK_BEATS = 16;
181
+ /** 身份归一的读数:`taskId` = 该用的键;`origin` = 它是哪一把。 */
182
+ export interface EnginePanelTaskIdentity {
183
+ readonly taskId: string;
184
+ readonly origin: 'fleet-row' | 'wire';
185
+ /** 钥匙上记的周期身份(登记它的那条 fleet-row 带的);`origin:'wire'` 时恒缺席。 */
186
+ readonly cycleSeq?: number;
187
+ readonly startedAt?: number;
188
+ }
189
+ /**
190
+ * 0.74.3 CC-70:把 wire 上的子代 id(task_progress 的 UUID)反查成 fleet 行 id 尾段;`transcriptId` 表优先,退 `parentToolCallId` 表;
191
+ * 都查不到 ⇒ 原样(`origin:'wire'`)。fleet 尾段本身传进来也原样(它就是目标键)。
192
+ */
193
+ export declare function resolveEnginePanelTaskId(wireTaskId: string, parentToolCallId?: string): EnginePanelTaskIdentity;
194
+ /**
195
+ * 0.74.3 CC-70(异源复审 R3 [high]):按 wire 上的子代 id 清常驻标 —— 原键照清;归一到 fleet 尾段的那把键**只在这条终态属当代时**才清:
196
+ * 复活后迟到的前代终态(旧 UUID 的流内 close / 终态 tick)归一后带旧代际,清了尾段键 = 撤掉复活行的 sweep 保护 ⇒ 下一次 sweep 假结它。
197
+ * 判据与 `end` 的陈旧判据同一只(`isStaleEngineAgentPanelEnd`,拿钥匙上的周期对最近一条行帧的周期);判不出 ⇒ 照清(不因缺席留下永久常驻)。
198
+ */
199
+ export declare function clearEnginePanelTaskResidentByWire(wireTaskId: string): void;
200
+ /** 测试钩:清两张钥匙表、缓一拍与周期水位。 */
201
+ export declare function __resetEngineAgentPanelIdentityForTests(): void;
202
+ /** 宿主的唯一发布口(三条 lane 都经这里):先归一身份,再进主通道(订阅者 / 缓冲)。 */
149
203
  export declare function publishEngineAgentPanelEvent(ev: EngineAgentPanelEvent): void;
150
204
  /** Single consumer (the panel hook). Replays any buffered events, returns an unsubscribe. */
151
205
  export declare function subscribeEngineAgentPanel(fn: Listener): () => void;
@@ -88,7 +88,227 @@ export function isStaleEngineAgentPanelEnd(end, current) {
88
88
  return es < cs;
89
89
  return false;
90
90
  }
91
+ // ── 0.74.3 CC-70:子代身份归一(所有 lane 的唯一漏斗)────────────────────────────────────────
92
+ //
93
+ // 病(cli L-407 / test 定谳,三端同撞):同一个后台子代在 wire 上有**两把身份** —— fleet 行 id 尾段(17 位,= 注册表 task_id
94
+ // = 壳本地 agentId,core #948 按构造)与主流 `task_progress.taskId`(引擎 UUIDv7 = transcriptId = childSessionId)。本包的
95
+ // `fleet-row` / 通知 `end` 用前者,tick / 流内 close 臂的 `end` 用后者 ⇒ 壳面板按 `ev.taskId` 落 Map 长成两行;包内常驻标也是
96
+ // 「UUID 记、17 位清」永不清。修法 = 在这一只漏斗里把 tick / end 的 `taskId` 归一到 fleet 尾段(消费端零改):
97
+ // · 钥匙两把,fleet-row 一到就登记:`transcriptId → 尾段`(主)、`parentToolCallId → 尾段`(退路);
98
+ // · 查得到 ⇒ 改写 `taskId`,原词进 `wireTaskId`,`taskIdOrigin:'fleet-row'`;常驻标若记在 UUID 下随之迁移;
99
+ // · 查不到:绑卡的 tick(同步委派,`cardBound`)直发;未绑的 tick **缓一拍**等 fleet-row(常态 fleet-row 先到几十秒,但非保证)——
100
+ // 触发 = 同 UUID 的 fleet-row 到(归一发)/ 同 UUID 第二条 tick(新的原样发,旧的丢)/ 同 UUID `end`(先 tick 后 end 原样发)/
101
+ // 缓冲满(最旧的原样发);原样发的带 `taskIdOrigin:'wire'`。🔴 `sweep` **不放**缓着的 tick:那条 tick 还没有行,sweep 没有东西可结;
102
+ // 在「tick 先到、父 turn 先结、行帧后到」的窗里放出去反而把 UUID 行造出来、接着行帧再造第二行 —— 正是要修的病。
103
+ // · 两张钥匙表与缓一拍都有界(常量),溢出先进先出 —— 上限是内存约束不是语义,超出只退化成「原样发」;
104
+ // · 缓一拍还按**节拍**有界(异源复审 R1 [medium]):别的 fleet-row / end / sweep 每过一条算一拍(别的 tick 不算 —— 它说明不了行帧车道
105
+ // 活不活,一阵子代同时起步时也不该互相挤),过 MAX_HELD_WIRE_TICK_BEATS 拍还没等到自己的行帧 ⇒ 原样放(store 无计时器,拍是唯一可数的时间);
106
+ // · 钥匙带**周期身份**(复审 R1 [high]):行帧登记时连 cycleSeq / startedAt 一起记,归一后的 end 若自己不带周期就借钥匙上的
107
+ // (流内 close 臂的 end 恒不带)—— 复活后旧 transcriptId 的钥匙仍在,迟到的 end{旧 UUID} 归一后带着旧代际,
108
+ // isStaleEngineAgentPanelEnd 才判得出它是前代;
109
+ // · tick 经退路键(parentToolCallId)归一时,顺手把 UUID → 尾段 记进主表(复审 R1 [high]):随后的 end{UUID} 不带 parentToolCallId,
110
+ // 没有这一步它永远归不了一。
111
+ /** 钥匙表上限(LRU:命中即续命,真在飞的子代不会被一阵新登记挤掉;超出只退化成「原样发」)。 */
112
+ const MAX_IDENTITY_KEYS = 2048;
113
+ const MAX_HELD_WIRE_TICKS = 64;
114
+ /** 缓一拍的节拍上限:别的 fleet-row / end / sweep 每过一条算一拍(别的 tick 不算)。 */
115
+ export const MAX_HELD_WIRE_TICK_BEATS = 16;
116
+ const fleetIdByTranscriptId = new Map();
117
+ const fleetIdByParentToolCallId = new Map();
118
+ const heldWireTicks = new Map();
119
+ /** 每个 fleet 尾段最近一条行帧带的周期身份(在场才记):清常驻标时用它判「这条终态是不是前代的」(异源复审 R3 [high])。 */
120
+ const latestCycleByFleetId = new Map();
121
+ function remember(map, key, alias) {
122
+ map.delete(key);
123
+ map.set(key, alias);
124
+ if (map.size > MAX_IDENTITY_KEYS) {
125
+ const oldest = map.keys().next().value;
126
+ if (oldest !== undefined)
127
+ map.delete(oldest);
128
+ }
129
+ }
130
+ /**
131
+ * 0.74.3 CC-70:把 wire 上的子代 id(task_progress 的 UUID)反查成 fleet 行 id 尾段;`transcriptId` 表优先,退 `parentToolCallId` 表;
132
+ * 都查不到 ⇒ 原样(`origin:'wire'`)。fleet 尾段本身传进来也原样(它就是目标键)。
133
+ */
134
+ export function resolveEnginePanelTaskId(wireTaskId, parentToolCallId) {
135
+ const byTranscript = fleetIdByTranscriptId.get(wireTaskId);
136
+ if (byTranscript !== undefined) {
137
+ remember(fleetIdByTranscriptId, wireTaskId, byTranscript); // LRU 续命(异源复审 R2 [high]:FIFO 会把仍在飞的子代挤掉)
138
+ return identityOf(byTranscript);
139
+ }
140
+ const byParent = parentToolCallId !== undefined ? fleetIdByParentToolCallId.get(parentToolCallId) : undefined;
141
+ if (byParent !== undefined) {
142
+ remember(fleetIdByParentToolCallId, parentToolCallId, byParent);
143
+ return identityOf(byParent);
144
+ }
145
+ return { taskId: wireTaskId, origin: 'wire' };
146
+ }
147
+ function identityOf(alias) {
148
+ return {
149
+ taskId: alias.fleetId,
150
+ origin: 'fleet-row',
151
+ ...(alias.cycleSeq !== undefined ? { cycleSeq: alias.cycleSeq } : {}),
152
+ ...(alias.startedAt !== undefined ? { startedAt: alias.startedAt } : {}),
153
+ };
154
+ }
155
+ /** 经退路键归一的 tick:把 UUID → 尾段 记进主表,随后不带 parentToolCallId 的 end{UUID} 才归得了一。 */
156
+ function rememberResolvedWire(wireTaskId, id) {
157
+ if (fleetIdByTranscriptId.has(wireTaskId))
158
+ return;
159
+ remember(fleetIdByTranscriptId, wireTaskId, {
160
+ fleetId: id.taskId,
161
+ ...(id.cycleSeq !== undefined ? { cycleSeq: id.cycleSeq } : {}),
162
+ ...(id.startedAt !== undefined ? { startedAt: id.startedAt } : {}),
163
+ });
164
+ }
165
+ /** 别的事件过了一拍:缓着的 tick 各记一拍,过上限的原样放出(先进先出)。 */
166
+ function ageHeld(except) {
167
+ for (const [wireId, held] of [...heldWireTicks]) {
168
+ if (wireId === except)
169
+ continue;
170
+ held.beats += 1;
171
+ if (held.beats >= MAX_HELD_WIRE_TICK_BEATS) {
172
+ heldWireTicks.delete(wireId);
173
+ deliverEngineAgentPanelEvent(asIsTick(held.ev));
174
+ }
175
+ }
176
+ }
177
+ function migrateResidency(wireTaskId, fleetId) {
178
+ if (wireTaskId !== fleetId && residentTaskIds.has(wireTaskId)) {
179
+ residentTaskIds.delete(wireTaskId);
180
+ residentTaskIds.add(fleetId);
181
+ }
182
+ }
183
+ function normalizedTick(ev, fleetId) {
184
+ const { cardBound: _cardBound, ...rest } = ev;
185
+ void _cardBound;
186
+ migrateResidency(ev.taskId, fleetId);
187
+ return { ...rest, taskId: fleetId, wireTaskId: ev.taskId, taskIdOrigin: 'fleet-row' };
188
+ }
189
+ function asIsTick(ev) {
190
+ const { cardBound: _cardBound, ...rest } = ev;
191
+ void _cardBound;
192
+ return { ...rest, taskIdOrigin: 'wire' };
193
+ }
194
+ function flushHeld(wireTaskId) {
195
+ const held = heldWireTicks.get(wireTaskId);
196
+ if (held === undefined)
197
+ return;
198
+ heldWireTicks.delete(wireTaskId);
199
+ const id = resolveEnginePanelTaskId(held.ev.taskId, held.ev.parentToolCallId);
200
+ if (id.origin === 'fleet-row')
201
+ rememberResolvedWire(held.ev.taskId, id);
202
+ deliverEngineAgentPanelEvent(id.origin === 'fleet-row' ? normalizedTick(held.ev, id.taskId) : asIsTick(held.ev));
203
+ }
204
+ /**
205
+ * 0.74.3 CC-70(异源复审 R3 [high]):按 wire 上的子代 id 清常驻标 —— 原键照清;归一到 fleet 尾段的那把键**只在这条终态属当代时**才清:
206
+ * 复活后迟到的前代终态(旧 UUID 的流内 close / 终态 tick)归一后带旧代际,清了尾段键 = 撤掉复活行的 sweep 保护 ⇒ 下一次 sweep 假结它。
207
+ * 判据与 `end` 的陈旧判据同一只(`isStaleEngineAgentPanelEnd`,拿钥匙上的周期对最近一条行帧的周期);判不出 ⇒ 照清(不因缺席留下永久常驻)。
208
+ */
209
+ export function clearEnginePanelTaskResidentByWire(wireTaskId) {
210
+ residentTaskIds.delete(wireTaskId);
211
+ const id = resolveEnginePanelTaskId(wireTaskId);
212
+ if (id.taskId === wireTaskId)
213
+ return;
214
+ if (isStaleEngineAgentPanelEnd(id, latestCycleByFleetId.get(id.taskId)))
215
+ return;
216
+ residentTaskIds.delete(id.taskId);
217
+ }
218
+ /** 测试钩:清两张钥匙表、缓一拍与周期水位。 */
219
+ export function __resetEngineAgentPanelIdentityForTests() {
220
+ fleetIdByTranscriptId.clear();
221
+ fleetIdByParentToolCallId.clear();
222
+ heldWireTicks.clear();
223
+ latestCycleByFleetId.clear();
224
+ }
225
+ /** 宿主的唯一发布口(三条 lane 都经这里):先归一身份,再进主通道(订阅者 / 缓冲)。 */
91
226
  export function publishEngineAgentPanelEvent(ev) {
227
+ // 行帧先登记、先放自己匹配的 tick,再给别的缓着的 tick 记拍(异源复审 R2 [high]:先记拍会让第 16 拍那条正好是自己行帧的 tick 过期);
228
+ // end / sweep 没有「自己的」匹配 ⇒ 先记拍。
229
+ if (ev.kind === 'end' || ev.kind === 'sweep')
230
+ ageHeld(ev.kind === 'sweep' ? undefined : ev.taskId);
231
+ switch (ev.kind) {
232
+ case 'fleet-row': {
233
+ const alias = {
234
+ fleetId: ev.taskId,
235
+ ...(ev.cycleSeq !== undefined ? { cycleSeq: ev.cycleSeq } : {}),
236
+ ...(ev.startedAt !== undefined ? { startedAt: ev.startedAt } : {}),
237
+ };
238
+ if (alias.cycleSeq !== undefined || alias.startedAt !== undefined) {
239
+ latestCycleByFleetId.delete(ev.taskId);
240
+ latestCycleByFleetId.set(ev.taskId, { ...(alias.cycleSeq !== undefined ? { cycleSeq: alias.cycleSeq } : {}), ...(alias.startedAt !== undefined ? { startedAt: alias.startedAt } : {}) });
241
+ if (latestCycleByFleetId.size > MAX_IDENTITY_KEYS) {
242
+ const oldest = latestCycleByFleetId.keys().next().value;
243
+ if (oldest !== undefined)
244
+ latestCycleByFleetId.delete(oldest);
245
+ }
246
+ }
247
+ if (ev.transcriptId !== undefined && ev.transcriptId.length > 0)
248
+ remember(fleetIdByTranscriptId, ev.transcriptId, alias);
249
+ if (ev.parentToolCallId !== undefined && ev.parentToolCallId.length > 0)
250
+ remember(fleetIdByParentToolCallId, ev.parentToolCallId, alias);
251
+ // 先发行帧再放缓着的 tick:tick 一到 fleet lane 就永久让位,行帧说的话(transcriptId / startedAt)得先落在行上。
252
+ deliverEngineAgentPanelEvent(ev);
253
+ for (const [wireId, held] of [...heldWireTicks]) {
254
+ if (wireId === ev.transcriptId || (held.ev.parentToolCallId !== undefined && held.ev.parentToolCallId === ev.parentToolCallId))
255
+ flushHeld(wireId);
256
+ }
257
+ ageHeld(undefined);
258
+ return;
259
+ }
260
+ case 'tick': {
261
+ const id = resolveEnginePanelTaskId(ev.taskId, ev.parentToolCallId);
262
+ if (id.origin === 'fleet-row') {
263
+ heldWireTicks.delete(ev.taskId);
264
+ rememberResolvedWire(ev.taskId, id);
265
+ deliverEngineAgentPanelEvent(normalizedTick(ev, id.taskId));
266
+ return;
267
+ }
268
+ if (ev.cardBound === true) {
269
+ deliverEngineAgentPanelEvent(asIsTick(ev));
270
+ return;
271
+ }
272
+ if (heldWireTicks.has(ev.taskId)) {
273
+ // 第二拍还没等到 fleet-row ⇒ 不再等:新的原样发,旧的丢(progress 合并语义,旧拍没有信息)。
274
+ heldWireTicks.delete(ev.taskId);
275
+ deliverEngineAgentPanelEvent(asIsTick(ev));
276
+ return;
277
+ }
278
+ if (heldWireTicks.size >= MAX_HELD_WIRE_TICKS) {
279
+ const oldest = heldWireTicks.keys().next().value;
280
+ if (oldest !== undefined)
281
+ flushHeld(oldest);
282
+ }
283
+ heldWireTicks.set(ev.taskId, { ev, beats: 0 });
284
+ return;
285
+ }
286
+ case 'end': {
287
+ const id = resolveEnginePanelTaskId(ev.taskId);
288
+ if (id.origin === 'fleet-row' && id.taskId !== ev.taskId) {
289
+ flushHeld(ev.taskId); // 同 UUID 还缓着一条 tick ⇒ 先归一放出(行先有最后一拍的数字与名字),再结
290
+ migrateResidency(ev.taskId, id.taskId);
291
+ deliverEngineAgentPanelEvent({
292
+ ...ev,
293
+ taskId: id.taskId,
294
+ wireTaskId: ev.taskId,
295
+ taskIdOrigin: 'fleet-row',
296
+ // end 自己不带周期身份(流内 close 臂恒不带)⇒ 借钥匙上的:复活后迟到的 end{旧 UUID} 由此带着旧代际,消费端按 isStaleEngineAgentPanelEnd 挡掉
297
+ ...(ev.cycleSeq === undefined && id.cycleSeq !== undefined ? { cycleSeq: id.cycleSeq } : {}),
298
+ ...(ev.startedAt === undefined && id.startedAt !== undefined ? { startedAt: id.startedAt } : {}),
299
+ });
300
+ return;
301
+ }
302
+ flushHeld(ev.taskId); // 缓着的 tick 先落行,终态才有行可结
303
+ deliverEngineAgentPanelEvent(ev);
304
+ return;
305
+ }
306
+ default:
307
+ // sweep:缓着的 tick 不放(见头注)—— 原样进主通道。
308
+ deliverEngineAgentPanelEvent(ev);
309
+ }
310
+ }
311
+ function deliverEngineAgentPanelEvent(ev) {
92
312
  // 同 taskId 的任何主通道事实(tick / fleet-row = 行在场;end = 真终态)都让**缓冲里**那条缺席过期 —— 迟订阅的消费者
93
313
  // 不许先恢复了行、再被一条旧缺席标成 unknown(已递达订阅者的缺席由订阅者按契约在这些事件上撤标)。
94
314
  if (ev.kind !== 'sweep')
@@ -111,7 +111,7 @@ export function executionLaneDoctorDetail(reading) {
111
111
  switch (reading.kind) {
112
112
  case 'unobserved':
113
113
  return ('execution lane not observed — the engine reports it on /v1/capabilities; ' +
114
- 'this process has not received a capabilities response from the engine yet');
114
+ 'this process has no usable capabilities reading cached for it (none received, or the last one was unreadable)');
115
115
  case 'not_reported':
116
116
  return ('execution lane not reported by this engine — only newer engines advertise it; ' +
117
117
  'this does not say where tools run, so the client keeps inferring it the way it did before');
@@ -140,6 +140,11 @@ export interface FleetWorkflowView {
140
140
  failedCount?: number;
141
141
  /** 🔴 缺席 ≠ 0:0 = 引擎说「一个都没起」,undefined = 老引擎「不知道」(退 done+failed 下界)。 */
142
142
  startedCount?: number;
143
+ /**
144
+ * 0.74.3 CC-72(core 7.23.5 #932;cli L-235 ②):run 级 errorCode **原词逐字**(开集,不枚举)。缺席 = 无码 ——
145
+ * **不是成功也不是失败**,终态仍看 `status`;server 透传 / sdk 声明之前按结构读(sdk 9.7.1 `FleetWorkflowRow` 未声明)。
146
+ */
147
+ errorCode?: string;
143
148
  }
144
149
  /**
145
150
  * SDK `FleetTaskStatus` 的终态词汇 —— 非终态即在飞(未知未来值按在飞算,保守)。