@sema-agent/server 7.63.0 → 7.64.0

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.
Files changed (55) hide show
  1. package/MIGRATION.md +8 -0
  2. package/USAGE.md +17 -15
  3. package/deploy/sema-up/smoke.sh +2 -2
  4. package/dist/approval-ask-audit-store.d.ts +149 -3
  5. package/dist/approval-ask-audit-store.js +11 -4
  6. package/dist/bench/s1/arms.d.ts +2 -2
  7. package/dist/bench/s1/arms.js +20 -15
  8. package/dist/bench/s1/live-deps.js +3 -1
  9. package/dist/bench/s1/run-firm.js +2 -2
  10. package/dist/degenerate-instrument.d.ts +0 -33
  11. package/dist/degenerate-instrument.js +2 -1
  12. package/dist/fleet/fleet-bus.d.ts +9 -4
  13. package/dist/fleet/fleet-bus.js +2 -1
  14. package/dist/host-decision.d.ts +39 -0
  15. package/dist/host-decision.js +6 -0
  16. package/dist/http/route-ctx.d.ts +8 -5
  17. package/dist/http/routes/a2a-serve.js +4 -2
  18. package/dist/http/routes/approvals-assistant.d.ts +0 -13
  19. package/dist/http/routes/approvals-assistant.js +2 -1
  20. package/dist/http/routes/runs.js +3 -2
  21. package/dist/http/routes/tasks.js +33 -29
  22. package/dist/http/routes/workflows.d.ts +0 -11
  23. package/dist/http/routes/workflows.js +18 -14
  24. package/dist/http/server.js +50 -46
  25. package/dist/leader/fanout.d.ts +6 -23
  26. package/dist/leader/fanout.js +8 -19
  27. package/dist/leader/leader.d.ts +13 -10
  28. package/dist/leader/leader.js +30 -22
  29. package/dist/leader/repair-wire.d.ts +4 -1
  30. package/dist/leader/repair-wire.js +7 -5
  31. package/dist/leader/wire.js +5 -4
  32. package/dist/main.js +5 -3
  33. package/dist/observability/fail-open.d.ts +8 -0
  34. package/dist/observability/fail-open.js +8 -0
  35. package/dist/observability/metrics.js +1 -1
  36. package/dist/observability/run-terminal-log.d.ts +25 -7
  37. package/dist/observability/run-terminal-log.js +15 -4
  38. package/dist/observability/tool-trace.d.ts +13 -12
  39. package/dist/observability/tool-trace.js +8 -13
  40. package/dist/parked-decide.d.ts +8 -4
  41. package/dist/parked-decide.js +1 -1
  42. package/dist/plugins/file-run-store.js +12 -21
  43. package/dist/plugins/memory-run-store.js +3 -2
  44. package/dist/plugins/run-store-sql.js +3 -2
  45. package/dist/run-local.js +6 -4
  46. package/dist/runs.d.ts +4 -19
  47. package/dist/runs.js +49 -47
  48. package/dist/terminal.d.ts +100 -0
  49. package/dist/terminal.js +55 -0
  50. package/dist/trace/core-keyset-guard.d.ts +20 -4
  51. package/dist/trace/ledger-sink.d.ts +2 -1
  52. package/dist/trace/ledger-sink.js +14 -7
  53. package/dist/trace/project.d.ts +9 -12
  54. package/dist/trace/project.js +54 -9
  55. package/package.json +4 -4
package/MIGRATION.md CHANGED
@@ -49,6 +49,14 @@ file/local 存储形(未配 SQL 后端)的部署不受本节任何条目影响
49
49
 
50
50
  这些是 core 的行为翻转,server 通过版本 pin 传导给你的部署。滚版前请读对应条目。
51
51
 
52
+ ### 终局因由 / 门结算记录 / 分类器标签(core 7.6.x,server 7.64.0 起)
53
+
54
+ - **`TaskResult` 只留 `terminal`**(同步 200 体、SSE `done.result`、`GET /v1/runs/:id` 的 `result` blob):`status` / `errorCode` / `errorMessage` / `blockedReason` / `checkpointToken` / `checkpointId` / `checkpointGate` / `workspaceRestoreMode` 八键删,换 `terminal: {kind:"completed"} | {kind:"failed", code?, message?, nestedPause?} | {kind:"blocked", reason} | {kind:"paused", gate, checkpointId?, restoreMode?}`(wire 上 `paused` 不带 `token`)。客户端改读 `terminal.kind`;run 行的 `status`/`errorCode` **列**、`/decide` 200 体、fleet 终态词不变。
55
+ - **`tool_end` 帧四个结算词删**(`settledBy` / `resolution` / `autoDenied` / `approver`),换一条 `gate: {disposition, settlement?, origin?}`;`permission_denied_total` 的标签 `source` → `deniedBy`(仪表盘/告警要改)。
56
+ - **`TaskSpec.checkpointStore`**:关断从 `null` 改 `"disabled"`,`null` 被 400 `config.invalid_checkpoint_store` 拒。
57
+ - **升级前必做:把待决审批决完或取消**(`GET /v1/approvals` 逐条 `/v1/approvals/:sessionId/decide`)。core 7.6.0 起,升级前铸的待决审批行(无 `origin` 词)在第一次 `/decide` 上会被拒并把那张卡孤儿化(core 7.6.2 补 `reason` 词后改为响亮拒,卡仍需手工取消)。
58
+ - **单向迁移面**:7.63.0 写下的 workflow resume journal 行在 7.64.0 下恢复被响亮拒(`workflow.journal_incompatible`,零派发),新起 run 不受影响;历史 `tool_end` 的四个归因词在冷回放里读不出来(core 词表已删,server 不代折)。
59
+
52
60
  ### 后台 bash 会话驻留(core 1.269.0,server 1.169.0 起)
53
61
  - **变更**:后台 `run_in_background` bash **不再随父 run 结束被杀**——改为 session 驻留;终止锚点=
54
62
  session 收尾(server 的 E21 DELETE wiring)+ 引擎 hardShutdown 末端全量收割。
package/USAGE.md CHANGED
@@ -210,8 +210,8 @@ MODEL_EXTRA_BODY='{"top_k":40}' # JSON 逃生口(top_k / logit_bias 等;penalt
210
210
  ```
211
211
  - **brain 拥有的键永远赢**:`temperature`/`max_tokens` 等放进 `extraBody` 会被 strip + core 警告(`phase:"config"`),不会静默改;auth/content-type/version 头硬锁不可顶替(1.60 安全修复)。
212
212
  - **必须静态**:`extraBody` 在 boot 时按固定 env 建一次(稳定键序),**不可逐任务变**,否则破前缀缓存。未设 → 请求字节级不变。
213
- - **退化打捞**:模型尾部循环退化时 core 切断,任务 `status:"failed"` + `errorCode:"output.degenerate"`,`salvagedOutput` 带回那一退化 turn 的文本,**尾部已由 core 在 brain 流层裁掉**(逐字节重复只留一份重复单元 —— 声明为**有损**归一:不丢唯一字节,但重复**次数**不保真,「输出 N 份」这类语义拿回来只有一份)。
214
- 🔴 **调用方读法(core 官方配方,别拿 `status` 三元代替)**:`const out = r.result || r.salvagedOutput || "";`
213
+ - **退化打捞**:模型尾部循环退化时 core 切断,任务 `terminal:{kind:"failed", code:"output.degenerate"}`,`salvagedOutput` 带回那一退化 turn 的文本,**尾部已由 core 在 brain 流层裁掉**(逐字节重复只留一份重复单元 —— 声明为**有损**归一:不丢唯一字节,但重复**次数**不保真,「输出 N 份」这类语义拿回来只有一份)。
214
+ 🔴 **调用方读法(core 官方配方,别拿 `terminal.kind` 三元代替)**:`const out = r.result || r.salvagedOutput || "";`
215
215
  —— 先读 `result`,空了才回落。理由:`salvagedOutput` 只在**一张七员闭集**的失败终局上在场(逐字取自 core 的 `SALVAGE_ELIGIBLE_TERMINALS`:`output.degenerate`、`limits.max_tokens_exceeded`、`limits.max_cost_exceeded`、`limits.max_turns_exceeded`、`limits.max_walltime_exceeded`、`env.lifetime_expired`、`usage.window_exhausted`),**闭集之外**的失败终局(模型/供应商错 `provider.error`、`conflict`、未铸码的失败……)它**整键缺席**,而最终助手文本仍在 `result` 里;闭集**之内**它又与 `result` 取自**同一条** final message 文本。两头都指向同一条读法:先 `result`。只读 `salvagedOutput` 会在闭集外的每一条失败路径上把真产出当成"没有输出"丢掉。penalty 是预防、这是兜底。
216
216
 
217
217
  **可选 — OTLP/HTTP 指标导出(core 1.37 可观测)**
@@ -832,23 +832,25 @@ invalid_using_default` 警告 + 退回缺省值」这条臂随词表放宽一并
832
832
  | `repo` / `council` / `debate` | — | `repo` 为 `code-review`/`scan` 必填;`council`/`debate` 仅 `code-review`,见 §5 |
833
833
  | ~~model / tools / prompt~~ | 🚫 | **不接受**——服务端注入 |
834
834
 
835
- **`TaskResult`(同步 / `done` 事件里拿到的):**
835
+ **`TaskResult`(同步 / `done` 事件里拿到的;7.64.0 起终局是**一条** `terminal` 因由,不再有 `status`/`errorCode`/`errorMessage`/`blockedReason` 四个平面键):**
836
836
  ```json
837
- { "taskId":"…", "sessionId":"0190…", "status":"completed",
837
+ { "taskId":"…", "sessionId":"0190…",
838
+ "terminal": { "kind": "completed" },
838
839
  "result":"最终回答文本",
839
- "blockedReason": null, "errorMessage": null, "errorCode": null,
840
840
  "stats": { "turns": 1, "tokens": 123 } }
841
841
  ```
842
+ `terminal` 四臂:`{kind:"completed"}` / `{kind:"failed", code?, message?, nestedPause?}` / `{kind:"blocked", reason}` /
843
+ `{kind:"paused", gate, checkpointId?, restoreMode?}`(wire 上**不带** `token`——那是 resume 凭据,永不出服务边界;用 `checkpointId` 对账、走 `/v1/approvals/:sessionId/decide`)。
842
844
  | 字段 | 用途 |
843
845
  |---|---|
844
846
  | `result` | 答案文本 |
845
847
  | `sessionId` | 续聊用——下次带回 |
846
- | `status` | `completed` / `blocked` / `failed`(终局闭集;`timeout` 已于 core 5.8.0/server 6.0.0 退役,不会再出现——见 §9) |
847
- | `errorCode` | 程序化分支:如 `"conflict"`(乐观锁丢失,可重试) |
848
- | `blockedReason` | `status=blocked` 时:为什么做不了(缺信息/权限) |
848
+ | `terminal.kind` | `completed` / `blocked` / `failed` / `paused`(闭集;`timeout` 已于 core 5.8.0/server 6.0.0 退役——见 §9) |
849
+ | `terminal.code` | `failed` 臂的程序化分支:如 `"conflict"`(乐观锁丢失,可重试) |
850
+ | `terminal.reason` | `blocked` 臂:为什么做不了(缺信息/权限) |
849
851
 
850
- > ⚠️ **任务成败以 `status`/`errorCode` 为准,勿以 HTTP 状态码判**:同步腿 200=提交受理成功(任务可能
851
- > `failed`+`errorCode`);`POST /v1/runs` 202=已受理(执行期结局落在终态 run 记录/`done` 帧)。执行期
852
+ > ⚠️ **任务成败以 `terminal.kind`/`terminal.code` 为准,勿以 HTTP 状态码判**:同步腿 200=提交受理成功(任务可能
853
+ > `terminal.kind:"failed"`+`code`);`POST /v1/runs` 202=已受理(执行期结局落在终态 run 记录/`done` 帧)。执行期
852
854
  > 错误结构上无法用 HTTP 状态码承载——流的响应头在 run 开跑前就发完了(详见
853
855
  > `docs/ASSISTANT-WIRE-CONTRACT.md` 附录 A)。
854
856
 
@@ -1205,17 +1207,17 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
1205
1207
  ## 9. 状态 / 错误码速查
1206
1208
  | 你看到 | 含义 | 怎么办 |
1207
1209
  |---|---|---|
1208
- | HTTP `200` + `status:"completed"` | 成功 | 取 `result` |
1209
- | `status:"blocked"` | agent 主动报卡住 | 看 `blockedReason`,补信息再发 |
1210
- | `status:"failed"` + `errorCode:"conflict"` | 跨实例乐观锁丢失 | 直接重试(幂等) |
1211
- | `status:"failed"` + `errorCode:"limits.max_walltime_exceeded"` | 墙钟到限(6.0.0 起 `status:"timeout"` 退役) | 拆小任务 / 提高 `limits.maxWalltimeMs`(毫秒) |
1210
+ | HTTP `200` + `terminal.kind:"completed"` | 成功 | 取 `result` |
1211
+ | `terminal.kind:"blocked"` | agent 主动报卡住 | 看 `terminal.reason`,补信息再发 |
1212
+ | `terminal:{kind:"failed", code:"conflict"}` | 跨实例乐观锁丢失 | 直接重试(幂等) |
1213
+ | `terminal:{kind:"failed", code:"limits.max_walltime_exceeded"}` | 墙钟到限(6.0.0 起 `status:"timeout"` 退役;7.64.0 起平面 `status` 键退役) | 拆小任务 / 提高 `limits.maxWalltimeMs`(毫秒) |
1212
1214
  | HTTP `401` | 缺 `Authorization` / 缺 `x-agent-principal`(要求时) | 补头 |
1213
1215
  | HTTP `403` / `404`(session/run) | 不是该 principal 的资源 | 用正确身份 |
1214
1216
  | HTTP `409`(`/v1/runs`) | 同 session 已有活跃 run | 等它完成 / 用返回的 `activeTaskId` |
1215
1217
  | HTTP `429` | 限流 | 看 `Retry-After` 退避 |
1216
1218
  | HTTP `429` + `errorCode:"limit.cost_quota_exceeded"` | per-principal 累计成本配额越顶(`MAX_PRINCIPAL_COST_USD`;**进场门**,不打断在跑的 run) | 看 `Retry-After` / 体 `retryAfterSec` 退避;窗滚过或调高上限后放行 |
1217
1219
  | HTTP `429` + `errorCode:"usage.window_exhausted"` | 部署级治理窗耗尽(`USAGE_WINDOWS`,token 或 $ 天花板先满者) | 看响应体 `retryAfterSec` 退避;窗滑动/桶到期后放行 |
1218
- | `status:"failed"` + `errorCode:"usage.window_exhausted"` | 已受理的 run 在 **turn 边界**撞上治理窗且无法 durable 挂起 | 同上退避后重投;配好 checkpoint 基建则改为 `suspended` 等窗自动续跑 |
1220
+ | `terminal:{kind:"failed", code:"usage.window_exhausted"}` | 已受理的 run 在 **turn 边界**撞上治理窗且无法 durable 挂起 | 同上退避后重投;配好 checkpoint 基建则改为 `suspended` 等窗自动续跑 |
1219
1221
  | HTTP `501`(`/v1/runs`) | 内存模式不支持异步 | 配 MySQL 协议存储(`SESSION_BACKEND=mysql`) |
1220
1222
 
1221
1223
  **机器码**:每个 4xx/5xx 响应体都带一个 `errorCode`(与人类文案 `error` 并列),这是**唯一**该拿来做
@@ -86,13 +86,13 @@ if [ -n "$URL" ]; then
86
86
  # ⑥ 真任务 1-turn(同时是模型面探针:402=余额,401/403=key,超时=网关不通——欠费实验收编)
87
87
  R=$(curl -m 120 -s -X POST "$URL/v1/tasks" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"objective":"请只回答:ok"}')
88
88
  ST=$(printf '%s' "$R" | python3 -c 'import json,sys
89
- try: d=json.load(sys.stdin); print(d.get("status",""), "|", str(d.get("error") or d.get("result"))[:120])
89
+ try: d=json.load(sys.stdin); t=d.get("terminal") or {}; print(t.get("kind",""), "|", str((t.get("code") or "") + " " + (d.get("error") or d.get("result") or ""))[:120])
90
90
  except Exception: print("parse-fail |", "")' 2>/dev/null)
91
91
  case "$ST" in
92
92
  completed*) ok "真任务 1-turn(模型面通)" ;;
93
93
  *nsufficient*|*402*) fail "真任务" "模型 API 余额耗尽(402)" "充值或换 MODEL_API_KEY;余额恢复后重跑 smoke" ;;
94
94
  *401*|*403*) fail "真任务" "模型 API key 无效" "检查 MODEL_API_KEY" ;;
95
- *) fail "真任务" "status=${ST:-无响应}" "docker logs 看 done 帧 errorMessage(引擎会全文上流)" ;;
95
+ *) fail "真任务" "terminal=${ST:-无响应}" "docker logs 看 done 帧 terminal.message(引擎会全文上流)" ;;
96
96
  esac
97
97
  fi
98
98
  fi
@@ -1,3 +1,137 @@
1
+ /**
2
+ * [ref] 裁 (c) **审计半场**([ref]①;设计稿 = sema-internal
3
+ * `server/designs/2026-08-31-s38-local-stream-approval-v1.md`,v1 三臂 + v1.1 §6 两件增益)——
4
+ * local 车道(`DB_BACKEND=local`)的 **File ask 审计店**。SINGLE-FILE 店([ref] A12 单文件店纪律,
5
+ * 形照 `plugins/memory-optout-grant-store-sql.ts`:一个实现文件、数据形/偏离/坏值姿态全部成文在头注;
6
+ * 崩溃安全机制照 `orchestration/workflow-notify-journal.ts` 的 File 形:append-only JSONL、fsync 逐行、
7
+ * 重放容忍撕尾行)。
8
+ *
9
+ * ## 它治的病([ref] / [ref]① 车定界钉)
10
+ * local 车道 ask 账进程内易失(`InMemoryApprovalAskStore`),活卡窗内崩溃 = **零痕迹**:重启后
11
+ * `/v1/approvals` 两数组空,那次审批连一条审计行都没有。本店把 ask **铸造 / 决议 / 腿闭**三类事件
12
+ * append 进磁盘账本(挂线点 = `ToolApprovalCoordinator`,见 `askAudit` ctor opt),boot 时收敛器把
13
+ * 孤儿行标 `crashed_before_park` 终态成因注,`GET /v1/approvals` 的 additive 键 `crashConverged` 供
14
+ * 操作员追溯。
15
+ *
16
+ * ## 它**不**做的事(裁 (c) 的边界,与稿 §1 (a) 臂的语义论证同源)
17
+ * - **不翻 `streamApproval` 能力位**:该位在 SQL 车道隐含「durable park 兜底(窗到期 park 可赎)」,
18
+ * local 给不了(park 凭据由引擎铸 checkpoint 时发,进程死后 run 不可复活)—— 同一位两义 = 消费方
19
+ * 按位渲染「稍后可批」时做出错误承诺。`resolveStreamApprovalGate` 的 `volatile_ask_ledger` 臂
20
+ * 一字不动(钉:`test/approval-ask-audit.test.ts` [ref]-⑧ + e2e 定界格新形)。
21
+ * - **不复活 run**:审计行不是 park 行,崩掉的 ask 不进 pending/livePending。恢复闭环真形 =
22
+ * core resume 既有补偿腿(悬空 tool_use 闭合「中断,工具未执行」⇒ 模型自然重发重弹卡),读面用
23
+ * `resumeSafe` 位指路(v1.1 §6 第二件)。
24
+ *
25
+ * ## 账本形(closed set,`kind` 判别;每行一个 JSON 对象 + '\n',fsync 逐行)
26
+ * `ask` 铸造点(呈卡帧 register 后、emit 前):wire approvalId / 出处 taskId / owner /
27
+ * toolName / `legTracked`(出处腿是否在协调器 `runWithContext` 视野里 —— decided
28
+ * 臂的收敛前提,见下)/ 窗死线 / 铸造时刻。
29
+ * `settle` 决议点(`settle()` 公共咽喉,五臂全过):outcome(allowed/denied/expired)+
30
+ * `parkRouted`(窗到期/断连等 park 路由臂如实标注 —— 该行的续命在 durable park 面,
31
+ * 收敛器**不**把它当孤儿)+ 宿主自报(在场才带)。
32
+ * `leg_closed` 腿闭点(`runWithContext` finally;只为铸过审计行的 taskId 落,防账本膨胀):
33
+ * 「决议之后腿有没有闭」是 decided 臂唯一的机械证据面 —— 没有它,「批完跑完」与
34
+ * 「批了、执行窗内崩」在账本字节上同形,收敛器只能二选一地撒谎。
35
+ * `crash_converged` 收敛记录(boot 收敛器落,幂等锚):orphanState 两值分臂。
36
+ * `late_settle` **迟到人工结算点**([ref];两条 HTTP decide 腿在**权威受理之后**落,受理谓词 =
37
+ * `HTTP 200 ∧ errorCode ≠ "cancelled"`,[ref] 件②起 —— 见下):
38
+ * 窗到期已转 durable park 的那条 ask,人**事后**在
39
+ * `POST /v1/approvals/:sessionId/decide`(或 [ref] 赎回席)上按下的允许/拒绝。
40
+ * 身份 = 从 checkpoint 锚反查到的 `approvalId`(反查不唯一 ⇒ `null` + `unresolved`
41
+ * 词,**不静默**);行恒携 checkpoint 锚(`sessionId` / `toolCallId` /
42
+ * `checkpointTokenHash`)供取证 join。判据见下段。
43
+ * 🔴 **落行时机**(codex R1-F1 采):不是 CAS 挂点,而是那条腿**返回 200 且
44
+ * `errorCode ≠ "cancelled"`** 之后([ref] 件② 把「只判 200」收窄成这条谓词)——
45
+ * CAS 只证明「行归我了」,core 的 pre-CAS 守卫(D-1 绑定 / answer 语义 / preflight)
46
+ * 与 checkpoint resolve CAS 都在其后,赎回腿更要等 `execute` 才知道 drive 有没有真
47
+ * 发起。挂 CAS 点 ⇒ 一次被拒、什么都没放行的 decide 也留下「人批了且生效了」=
48
+ * 伪造审计事实。判据属主 = `http/route-ctx.ts` 的 `DriveResumeArgs.acceptEarly` 顶注。
49
+ * 🔴 **这条谓词是有已知失准的近似,取证时别把它读成充要**([ref] R2 改真话;判据
50
+ * 全文在 `http/server.ts` `noteLateSettleIfAccepted` 顶注):core `checkpoint.resume_aborted`
51
+ * 有 pre-CAS / post-CAS 两半铸点,wire 上同为 `200 cancelled`,分不开 ⇒ 本店取
52
+ * 「宁漏勿伪」,**post-CAS 的取消(决定已提交)也不落行**(契约格 [ref]-R2-d)。
53
+ * ⇒ 「没有 `late_settle` 行」只等于「本账本没记」,**不等于**「引擎没受理」——
54
+ * 要与 run 面/事件流对读,单凭缺行不得推出「没人批过」。根治在 core([ref])。
55
+ *
56
+ * ## `expired ∧ parkRouted` ⇔ **窗到期已转 durable,非终局**([ref] 成文;[ref] 裁定①)
57
+ * `settle` 行上的 `outcome:"expired"` 是**流窗**结算词(那一刻活卡的等待结束了),不是「这次调用被拒」;
58
+ * 与它并存的 `parkRouted:true` 说的是「这条 ask 本身已转 durable park、行还在 `GET /v1/approvals` 的
59
+ * `pending[]` 里、`/decide` 回决得了」([ref] R-13 起的成文设计;WIRE 契约的锚 = `outcome: "expired"` 那一条 + `crashConverged` 段第 5 条。
60
+ * ⚠️ 锚**刻意不写行号**:[ref] 低危顺修查实原先的 `§915/§1209` 两个指针都已漂移——§1209 今天落在
61
+ * workflow `agents[]` 段,与本条毫无关系。行号指针在活文档里必漂,写锚文本)。两位并存的行因此是
62
+ * **非终局**的。后来会发生什么,账本能记的只有一半:
63
+ * · 人**迟到**决议且被引擎**受理** ⇒ 本店的 `late_settle` 行(允许/拒绝 + 出处);
64
+ * · 无人再来、durable 行被 reaper 收割 ⇒ checkpoint `pending→expired` CAS + run `failed('approval.expired')`
65
+ * (`file-run-store.ts` / SQL 孪生;本店**不**记这一支 —— 它不经协调器,痕迹在 run 行/事件流上)。
66
+ * 🔴 **`late_settle` 说的是「受理」,不是「从此结束」**(codex [ref]-R2-F1 部分采,验真后改口):受理点
67
+ * 之后那条腿仍可能以 reopen 类失败告终(`resume.tool_unavailable` 等 ⇒ core 已消费 checkpoint 又重开
68
+ * park,HTTP 仍回 `200 {retriable:true}`,卡可以再决)。那种情况下**本行照留**(那次决定真的发生过、也
69
+ * 真的被引擎收下了),而下一次决议会**再追加一行** —— append-only 账本上同一 `approvalId` 出现多条
70
+ * `late_settle` 是合法且有信息的形,不是重复。腿本身的成败读 run 面/事件流,本店不答那个问题。
71
+ * 读账本的人据此不得把 `expired ∧ parkRouted` 读成「这次审批结束了」;没有后继 `late_settle` 也**不**
72
+ * 等于没人批过(部署可能根本没装本店 —— SQL 车道恒无),只等于**本账本**没有那条人工痕迹。
73
+ *
74
+ * ## boot 收敛判据(v1.1 §6 两分臂;行序 = 语义序,append-only 保证)
75
+ * - 有 `ask` 无 `settle` ⇒ **pending 臂**:审批门在执行之前 ⇒ 工具零执行可机械证明 ⇒
76
+ * `resumeSafe:true`(壳可默认自动续跑)。
77
+ * - `settle.outcome === "allowed"` ∧ `ask.legTracked` ∧ 决议行之后无同 taskId 的 `leg_closed` ⇒
78
+ * **decided 臂**:批了、执行窗内崩,工具可能有半截副作用 ⇒ `resumeSafe:false`(留人工确认)。
79
+ * 判据方向刻意**保守**:腿闭是「本进程对该腿的守护结束」,晚于工具真完成 —— 误差方向是「把可能
80
+ * 干净的批注成待人工确认」,绝不反向。
81
+ * - **腿闭反证门**(codex R1-F1 部分采):有 `ask`、无 `settle`、却有本 taskId 的后继 `leg_closed` ⇒
82
+ * 这不是崩溃孤儿(settle 恒先于 ask() 返回、腿闭恒在其后),只能是「settle 行写丢在一条跑完了的
83
+ * 腿上」的审计断档组 —— **不收敛**(标 pending = 把可能已执行的批注成 resumeSafe:true,方向反;
84
+ * 标 decided = 凭空断言人批了),`inconsistent` 计数进 boot 日志。
85
+ * - 其余(denied / expired / park 路由 / 已收敛)⇒ 不收敛。三条成文残留(fail-safe 方向,宁漏不误):
86
+ * ① `legTracked:false` 的 decided 行拿不到腿闭信号 ⇒ decided 臂对它不收(pending 臂不受此限)。
87
+ * 两形同标 false:bg 腿(出处腿不在本协调器 runWithContext 视野)与**委派/bg 子代**(codex
88
+ * R1-F2:继承链闭包携宿主身份,origin.taskId = 宿主 run,宿主腿退场的 leg_closed 会把「批了、
89
+ * 子代执行中崩」误反证成「跑完了」—— 铸造点 `!child` 合取如实标 false);
90
+ * ② settle(expired, parkRouted) 与引擎真铸出 checkpoint 之间的毫秒窗内崩 ⇒ 行停在 park 路由态、
91
+ * durable 面无行 —— 审计痕迹仍在(比修前的零痕迹强一个量级),自动判别需 checkpoint 店 join,
92
+ * 误判方向是把活的 park 行标成崩溃,比漏标更坏,故不做;
93
+ * ③b **[ref] 同族残留**(codex [ref]-R1-F2 采后如实登记):`ask` / `settle(expired,parkRouted)` 的
94
+ * append 写丢时,那条事实**不进** late 反查索引(行的每一句断言都必须能被同一个账本文件复核)
95
+ * ⇒ 审计盘坏窗内发生的迟到人工决议**无痕**。取舍方向与本店一贯一致:宁可少一行,不可写一条
96
+ * 盘上查无前置事实的「人批过」——那条行会被当证据引。坏窗本身由 append-failed 三件套显形。
97
+ * ⚠️ 由此带来一处**运行期/重放期不对称**(codex [ref]-R2-F2 验真,方向判为可接受故只成文不改):
98
+ * 字节全落、只有 `fsync` 抛错的那一形,本进程按失败处理(不进索引),而那行事实上可能随下一次
99
+ * 成功的 fsync 一起落盘 —— 重启后重放会认它。⇒ 同一份账本「重启后比重启前认得更多前置事实」。
100
+ * 两边都**不会**写出无据的行(重放只认文件里真有的行),差别只在保守程度,故不为它引入提交协议
101
+ * (那要改动 append 这条被五种记录共用的路,风险大于收益);
102
+ * 不对称本身登记 [ref];
103
+ * ③ **双故障窗**(codex R1-F1 的不可约核):settle(allowed) 的 append 写丢**且**进程在腿闭落盘前
104
+ * 崩 ⇒ 账本只剩 ask 行,pending 臂会把一只可能已执行的批收成 resumeSafe:true。腿闭反证门收掉
105
+ * 「盘恢复、腿跑完」的形;「盘持续坏 + 崩」的形在不阻塞审批于审计盘的前提下**不可约**(阻塞 =
106
+ * 裁 (c) 明禁的行为面受伤),补偿 = append-failed 三件套让坏窗在遥测显形 + wire 契约明写
107
+ * 「resumeSafe 以账本完整为前提」。三条均登记 [ref]。
108
+ * 收敛记录本身 append + fsync ⇒ 幂等跨 boot(第二次 boot 读到 `crash_converged` 即不重收)。
109
+ * **`late_settle` 对收敛判据零影响**([ref] 硬约束):它被重放解析器**接受**(不计 `corruptLines`),
110
+ * 但**不**进 {@link ReplayedGroup}(`settle` 取首条那条判据一个字不动)—— 一条 `expired ∧ parkRouted`
111
+ * 的组无论后面跟不跟 `late_settle`,收敛结果逐字相同(两者都落在「其余 ⇒ 不收敛」那一支)。理由:
112
+ * 收敛器答的是「上一世崩没崩、崩时工具跑没跑」,而 `late_settle` 答的是「人后来批没批」—— 把后者喂进
113
+ * 前者会让一条**活得好好的** park 行因为有人决过而改变孤儿判定。
114
+ *
115
+ * ## `crashed_before_park` 与 FAIL-OPEN census 的关系(稿未闭环②,施工时裁定,成文于此)
116
+ * **不入词表**:census 词表登记的是「走了兜底而外面看不出」的**臂**;`crashed_before_park` 是一条
117
+ * **终态成因注**(收敛器 fail-closed 地把孤儿收成 DENIED 同码并在读面显形),它的存在恰恰是响亮化
118
+ * 本身,不是静默降级。本店唯一的静默降级臂 = 运行期 append 失败(审批可用性不押在审计盘上),已按
119
+ * [ref] 律走 `recordFailOpen("server.approval-ask-audit.append-failed")`(census 登记行同批)。
120
+ * boot 期(mkdir/重放)失败则**拒启**(与 File 店族同律:`FileStorageBackend` ctor 同 root 同姿态)——
121
+ * 一台「审计写不进却继续批」的机器 = [ref] 换个皮回来。
122
+ *
123
+ * ## 装配(为什么不在 `plugins/store-backend.ts`)
124
+ * 本店是协调器侧的审计 sink,不是跨后端 store 契约的一员(SQL 车道有真 ask 店,永不装本店)——
125
+ * 装配点在 `boot/coordinators.ts`(判据:`toolApprovalEnabled` ∧ `backend.kind === "local"` ∧
126
+ * `config.localDataRoot` 在场;真实 boot 恒铸 `localDataRoot`,缺席只发生在 stub-harness 上,判据
127
+ * 理由照 `StreamApprovalGateInput.streamApprovalEnabled` 顶注的同款论证)。root 与 `LocalBackend`
128
+ * 同源(`store-backend.ts:780` 的 `config.localDataRoot`),数据落 `<root>/approval-ask-audit/`。
129
+ *
130
+ * ## 体量与保留
131
+ * 只在真出卡时写(人批一次 = 3 行),量级 = 人的手速;账本只增不删,压缩是未来件(workflow-notify
132
+ * journal 同款成文)。读面列表按收敛时刻新前旧后、上限 {@link CRASH_CONVERGED_LIST_MAX}(有界 wire)。
133
+ */
134
+ import type { HostDecision } from "./host-decision.js";
1
135
  /** 数据子目录名(挂在 local 数据根下,与 `workflow-notify/` 同层同形)。 */
2
136
  export declare const APPROVAL_ASK_AUDIT_DIR = "approval-ask-audit";
3
137
  /** 崩溃收敛行的读面上限(新前旧后截断;有界 wire —— 读面不许随月份线性长)。 */
@@ -44,9 +178,21 @@ export interface AuditLateSettleInput {
44
178
  checkpointTokenHash?: string;
45
179
  /** 这次人工结算的结果(approve/deny 逐字映射;`expired` 不是本记录的合法值 —— 它是流窗的词)。 */
46
180
  outcome: "allowed" | "denied";
47
- /** core `ApprovalSettledBy` 逐字(调用方给,店**不派生**:集中推导正是「窗到期被报成另一个人拒绝」
48
- * 的成因,[ref] 件6③ 同律)。今天两条腿恒 `"human"`,但真值以调用方为准。 */
49
- settledBy: string;
181
+ /**
182
+ * **谁结束了这次等待** —— 调用方陈述的事实,店**不派生**(集中推导正是「窗到期被报成另一个人拒绝」
183
+ * 的成因,[ref] 件6③ 同律)。`"person"` = 运维经审批通道做的决定;`"sla_timeout"` = 宿主自己的 SLA 扫
184
+ * 在行的 deadline 上判的。今天两条 HTTP decide 腿恒 `"person"`,但真值以调用方为准。
185
+ *
186
+ * 🔴 S-136(core 7.6.0 [ref] S6-A):这一格此前记的是 core 的**结算词**(`ApprovalSettledBy`,
187
+ * 已删)。改记**事实**而不是词,理由是 core 的 `@contract settlement.single_mint` 逐字写着「宿主永不
188
+ * 铸词」—— 账本要是记一个自己拼出来的 `human_refused`,server 就成了同一套词表的**第二个铸点**,
189
+ * 而两个铸点的分歧一定是静默的。词的属主留在 core,取证要词就去读行的 `resolvedOutcome.gateOutcome`
190
+ * 或那次调用的 `tool_end.gate`(同一条记录的两张脸);本账本记的是**本服务观察到的那半件事**。
191
+ */
192
+ decidedBy: HostDecision["decidedBy"];
193
+ /** 审批通道自报的**结算方标识**(登录名/邮箱/账号 id)。缺席 = 通道没报名字 —— **永不**读成
194
+ * 「没人批」或「有人批」。调用方已按 core 的 `screenApproverAttribution` 筛过形。 */
195
+ approver?: string;
50
196
  /** 记录时刻 = 那条腿**权威受理**的时刻(不是请求到达的时刻)—— 落行点在 200 之后,见记录种类表。 */
51
197
  settledAtMs: number;
52
198
  }
@@ -84,10 +84,14 @@ function readLedgerRecord(u) {
84
84
  const approvalId = typeof idV === "string" ? idV : idV === null ? null : undefined;
85
85
  const outcomeV = o.outcome;
86
86
  const outcome = outcomeV === "allowed" || outcomeV === "denied" ? outcomeV : undefined;
87
- const settledBy = s("settledBy");
87
+ const decidedByV = o.decidedBy;
88
+ const legacySettledByV = decidedByV === undefined ? o.settledBy : undefined;
89
+ const legacySettledBy = legacySettledByV === "human" || legacySettledByV === "timeout" || legacySettledByV === "aborted" ? legacySettledByV : undefined;
90
+ const decidedBy = decidedByV === "person" || decidedByV === "sla_timeout" ? decidedByV : legacySettledBy !== undefined ? "person" : undefined;
91
+ const approver = s("approver");
88
92
  const settledAtMs = num("settledAtMs");
89
93
  const sessionId = s("sessionId");
90
- if (approvalId === undefined || outcome === undefined || settledBy === undefined || settledAtMs === undefined || sessionId === undefined)
94
+ if (approvalId === undefined || outcome === undefined || decidedBy === undefined || settledAtMs === undefined || sessionId === undefined)
91
95
  return undefined;
92
96
  if (bool("lateAfterExpiry") !== true)
93
97
  return undefined;
@@ -106,7 +110,9 @@ function readLedgerRecord(u) {
106
110
  kind: "late_settle",
107
111
  approvalId,
108
112
  outcome,
109
- settledBy,
113
+ decidedBy,
114
+ ...(legacySettledBy !== undefined ? { legacySettledBy } : {}),
115
+ ...(approver !== undefined ? { approver } : {}),
110
116
  lateAfterExpiry: true,
111
117
  settledAtMs,
112
118
  sessionId,
@@ -294,7 +300,8 @@ export class FileApprovalAskAuditStore {
294
300
  kind: "late_settle",
295
301
  approvalId: only ?? null,
296
302
  outcome: input.outcome,
297
- settledBy: input.settledBy,
303
+ decidedBy: input.decidedBy,
304
+ ...(input.approver !== undefined ? { approver: input.approver } : {}),
298
305
  lateAfterExpiry: true,
299
306
  settledAtMs: input.settledAtMs,
300
307
  sessionId: input.sessionId,
@@ -28,7 +28,7 @@
28
28
  * REAL arm logic against INJECTABLE profile seams (`ProfileDeps`) so the deterministic smoke drives it with a MOCK
29
29
  * (no real E2B/DeepSeek/TiDB). The live make-real proof injects the real core entrypoints.
30
30
  */
31
- import type { TaskResult, VerificationResult, RepairResult, CheckpointToken, ResumeOutcome, Checkpoint, CheckpointStore } from "@sema-agent/core";
31
+ import type { TaskResult, TaskStatus, VerificationResult, RepairResult, CheckpointToken, ResumeOutcome, Checkpoint, CheckpointStore } from "@sema-agent/core";
32
32
  import type { MergeResult } from "../../leader/merge.js";
33
33
  import type { WorkerReport } from "../../leader/fanout.js";
34
34
  import { type RunnerCtx } from "./runner-ctx.js";
@@ -133,7 +133,7 @@ type EmittedStats = CoreStatsSubset & {
133
133
  * measurement was reward-hackable and never trustworthy) is ALSO non-scorable → excluded, never a withhold-credit.
134
134
  */
135
135
  export declare function classifyRunStatus(input: {
136
- status?: VerificationResult["status"];
136
+ status?: TaskStatus;
137
137
  repairTerminal?: string;
138
138
  }): RunStatus;
139
139
  /**
@@ -1,3 +1,5 @@
1
+ import { terminalProjection } from "@sema-agent/core";
2
+ import { pausedCauseOf } from "../../terminal.js";
1
3
  import { leafBudgetFields, s1DebugGit } from "./runner-ctx.js";
2
4
  import {} from "./tasks.js";
3
5
  import { decideApproval, decidePlan } from "./reviewer.js";
@@ -77,14 +79,14 @@ export async function driveSupSuspendResume(initial, deps, implSpec, trap, advan
77
79
  let exitedViaBreak = false;
78
80
  for (let leg = 0; leg < MAX_LEGS; leg++) {
79
81
  foldCost(vr.stats);
82
+ const legPlane = terminalProjection(vr.terminal);
80
83
  if (process.env.S1_DEBUG_SUPCOST)
81
- console.error(`[SUPCOST leg=${leg}] status=${vr.status} reason=${vr.verification?.unverifiedReason} c1µ=${vr.stats?.costBreakdown?.llmRootMicroUsd} accC1µ=${c1Acc.llmRootMicroUsd} hrGates=${vr.stats?.humanReview?.gates?.length}`);
82
- const gateKind = vr.checkpointGate?.kind;
83
- const pauseReason = vr.verification?.unverifiedReason;
84
- const toolSuspend = (vr.status === "suspended" || pauseReason === "suspended") && Boolean(vr.checkpointToken);
85
- const reviewSuspend = (vr.status === "needs_review" || pauseReason === "needs_review") && Boolean(vr.checkpointToken);
86
- if (toolSuspend) {
87
- const cp = await deps.getCheckpoint(implSpec.checkpointStore, vr.checkpointToken, implSpec.durableApprovalScope);
84
+ console.error(`[SUPCOST leg=${leg}] status=${legPlane.status} reason=${vr.verification?.unverifiedReason} c1µ=${vr.stats?.costBreakdown?.llmRootMicroUsd} accC1µ=${c1Acc.llmRootMicroUsd} hrGates=${vr.stats?.humanReview?.gates?.length}`);
85
+ const paused = pausedCauseOf(vr.terminal);
86
+ const gateKind = paused?.gate.kind;
87
+ const parkWord = paused === undefined ? undefined : terminalProjection(paused).status;
88
+ if (parkWord === "suspended") {
89
+ const cp = await deps.getCheckpoint(implSpec.checkpointStore, paused.token, implSpec.durableApprovalScope);
88
90
  const binding = bindingFromCheckpoint(cp);
89
91
  if (!binding) {
90
92
  exitedViaBreak = true;
@@ -99,10 +101,11 @@ export async function driveSupSuspendResume(initial, deps, implSpec, trap, advan
99
101
  boundInputHash: binding.boundInputHash,
100
102
  decision: decision.action,
101
103
  reason: decision.reason,
104
+ hostDecision: { decidedBy: "person" },
102
105
  };
103
- vr = await deps.resumeWithVerification(vr.checkpointToken, outcome, implSpec);
106
+ vr = await deps.resumeWithVerification(paused.token, outcome, implSpec);
104
107
  }
105
- else if (reviewSuspend) {
108
+ else if (parkWord === "needs_review") {
106
109
  const plan = decidePlan({ task: trap.reviewer });
107
110
  advanceClock(plan.thinkMs);
108
111
  const outcome = gateKind === "needs_review"
@@ -110,7 +113,7 @@ export async function driveSupSuspendResume(initial, deps, implSpec, trap, advan
110
113
  { gate: "dry_run_review", decision: plan.action === "reject" ? "reject" : "approve" }
111
114
  :
112
115
  { gate: "plan_review", decision: plan.action, ...(plan.editedPlan !== undefined ? { editedPlan: plan.editedPlan } : {}) };
113
- vr = await deps.resumeWithVerification(vr.checkpointToken, outcome, implSpec);
116
+ vr = await deps.resumeWithVerification(paused.token, outcome, implSpec);
114
117
  }
115
118
  else {
116
119
  exitedViaBreak = true;
@@ -176,8 +179,9 @@ export async function runArm(arm, trap, seed, ctx, deps, supStore) {
176
179
  finishedAt: ctx.clock.now(),
177
180
  });
178
181
  }
182
+ const soloPlane = terminalProjection(vr.terminal);
179
183
  if (s1DebugGit())
180
- console.error("[S1 SOLO vr.status]", vr.status, "| stats:", JSON.stringify(vr.stats)?.slice(0, 200));
184
+ console.error("[S1 SOLO vr.status]", soloPlane.status, "| stats:", JSON.stringify(vr.stats)?.slice(0, 200));
181
185
  const oracle = pickOracleVerdicts(await deps.runOracle());
182
186
  return assembleRow({
183
187
  arm,
@@ -187,7 +191,7 @@ export async function runArm(arm, trap, seed, ctx, deps, supStore) {
187
191
  stats: captureStats(vr.stats),
188
192
  oracle,
189
193
  withheld: false,
190
- runStatus: classifyRunStatus({ status: vr.status }),
194
+ runStatus: classifyRunStatus({ status: soloPlane.status }),
191
195
  startedAt,
192
196
  finishedAt: ctx.clock.now(),
193
197
  });
@@ -201,7 +205,7 @@ export async function runArm(arm, trap, seed, ctx, deps, supStore) {
201
205
  try {
202
206
  if (trap.supDriver === "repair") {
203
207
  const rr = await deps.runRepairLoop(implSpec);
204
- repairTerminal = rr.terminal;
208
+ repairTerminal = rr.repairTerminal;
205
209
  vr = rr;
206
210
  }
207
211
  else {
@@ -222,10 +226,11 @@ export async function runArm(arm, trap, seed, ctx, deps, supStore) {
222
226
  finishedAt: ctx.clock.now(),
223
227
  });
224
228
  }
229
+ const supPlane = terminalProjection(vr.terminal);
225
230
  if (s1DebugGit())
226
- console.error(`[S1 SUP vr.status driver=${trap.supDriver}]`, vr.status, "| repairTerminal:", repairTerminal, "| verdict:", vr.verification?.verdict, "| unverifiedReason:", vr.verification?.unverifiedReason, "| checkpointGate:", JSON.stringify(vr.checkpointGate), "| error:", vr.error, "| stats:", JSON.stringify(vr.stats)?.slice(0, 180));
231
+ console.error(`[S1 SUP vr.status driver=${trap.supDriver}]`, supPlane.status, "| repairTerminal:", repairTerminal, "| verdict:", vr.verification?.verdict, "| unverifiedReason:", vr.verification?.unverifiedReason, "| checkpointGate:", JSON.stringify(pausedCauseOf(vr.terminal)?.gate), "| error:", vr.error, "| stats:", JSON.stringify(vr.stats)?.slice(0, 180));
227
232
  const oracle = pickOracleVerdicts(await deps.runOracle());
228
- const runStatus = classifyRunStatus({ status: vr.status, repairTerminal });
233
+ const runStatus = classifyRunStatus({ status: supPlane.status, repairTerminal });
229
234
  const lastGate = vr.stats?.humanReview?.gates?.at(-1);
230
235
  const withholdTrigger = runStatus === "infra-failed"
231
236
  ? undefined
@@ -1,3 +1,4 @@
1
+ import { terminalProjection } from "@sema-agent/core";
1
2
  import { Runner, runWithVerification, resumeWithVerification, runRepairLoop, combinePolicies, createDurableQuestionPolicy, QUESTION_AWAITS_RESUME, } from "@sema-agent/core";
2
3
  import { loadConfig } from "../../config.js";
3
4
  import { createBrain } from "../../brain.js";
@@ -208,7 +209,8 @@ export function buildLiveDeps(rt, trap, seed, cellId) {
208
209
  runWithVerificationSup: async (implSpec) => {
209
210
  const { runner } = await ensureWorker();
210
211
  const result = await runner.runTask(toSupTaskSpec(baseSpec, implSpec, trap));
211
- const unverifiedReason = result.status === "needs_review" ? "needs_review" : result.status === "suspended" ? "suspended" : "no_verdict";
212
+ const pausePlane = result.terminal.kind === "paused" ? terminalProjection(result.terminal).status : undefined;
213
+ const unverifiedReason = pausePlane === "needs_review" ? "needs_review" : pausePlane === "suspended" ? "suspended" : "no_verdict";
212
214
  return { ...result, verification: { verdict: "unverified", unverifiedReason, rounds: 0, findings: [] } };
213
215
  },
214
216
  resumeWithVerification: async (token, outcome, implSpec) => {
@@ -216,7 +216,7 @@ function dryRunMockFactory() {
216
216
  const vr = {
217
217
  taskId: trap.id,
218
218
  sessionId: `dry-${String(seed)}`,
219
- status: "completed",
219
+ terminal: { kind: "completed" },
220
220
  result: "DONE",
221
221
  stats: { turns: 3, tokens: 1200, costMicroUsd: 50_000, nested: { tokens: 0, turns: 0, tasks: 0, costMicroUsd: 0 }, ...stats },
222
222
  verification: { verdict: green ? "PASS" : "FAIL", rounds: 1, findings: [] },
@@ -225,7 +225,7 @@ function dryRunMockFactory() {
225
225
  runWithVerification: async () => vr,
226
226
  runWithVerificationSup: async () => vr,
227
227
  resumeWithVerification: async () => vr,
228
- runRepairLoop: async () => ({ ...vr, terminal: "candidate_only", oracleCostMicroUsd: 0, bundle: { failureTrace: "", diagnostics: [], rejectedHypotheses: [], attemptCount: 1, oracleTier: "trusted_hidden" } }),
228
+ runRepairLoop: async () => ({ ...vr, repairTerminal: "candidate_only", oracleCostMicroUsd: 0, bundle: { failureTrace: "", diagnostics: [], rejectedHypotheses: [], attemptCount: 1, oracleTier: "trusted_hidden" } }),
229
229
  getCheckpoint: async () => null,
230
230
  runLeaderTask: async () => ({ ok: true, reports: [], merge: undefined, workerBudgetsUsd: [0.5, 0.5] }),
231
231
  runOracle: async () => oracle,
@@ -1,36 +1,3 @@
1
- /**
2
- * Degenerate-repetition instrument (a/b classifier).
3
- *
4
- * When a task fails with `errorCode === "output.degenerate"` (core 1.59), core hands back
5
- * `salvagedOutput` = the degenerate turn's text — **already tail-TRIMMED at the brain stream layer**.
6
- * 🔴 [ref] correction (this note used to claim "the **whole** text, good head + looped garbage tail";
7
- * that was true of core 1.59 and false of every engine that ships `trimDegenerateTail` — verified against
8
- * the installed dist, not against JSDoc): on the main path `assemble-result` fills the seat from the same
9
- * final message the brain already trimmed, so what arrives here is `head + exactly ONE copy of the
10
- * repeating unit` (unit ≤ `MAX_PERIOD` = 100 chars). Untrimmed text only reaches us on the narrow
11
- * fallbacks — the cut landed on the reasoning face, or `trimDegenerateTail` bailed (`reps < 2`, empty
12
- * unit, unit-loop period mismatch). Consequence for the numbers below: {@link repetitionTail} is a
13
- * **no-op** on the main-path input (one copy is not ≥ {@link MIN_REPEATS}), so `uniquePrefixLen`
14
- * collapses onto `salvagedLen` and the a/unknown split degrades to a pure length test. The a-vs-b
15
- * decision is unaffected (it is decided by `priorChars`, independently); the residual bias on a/unknown
16
- * is bounded by one unit ≤ 100 chars. Pin: the "已裁形" case in `test/degenerate-instrument.test.ts`
17
- * drives the REAL `inspectDegenerate` + `trimDegenerateTail` and asserts the tail measures 0.
18
- * Core's *salvage ②* — recovering the "last substantive turn" instead of the current one — is being
19
- * gated on REAL data: how often is the useful answer actually in an EARLIER turn vs. in the degenerate
20
- * turn itself? This instrument answers that, per event, without changing any behaviour.
21
- *
22
- * Classification (since the last user message = this task's own turns):
23
- * - **a** good+garbage SAME turn — the degenerate turn carries a substantive unique head before the
24
- * loop. Whole-turn salvage ① + a tail-trim is enough; ② would add nothing.
25
- * - **b** good EARLIER / degenerate LATER — a substantive assistant turn already exists before the
26
- * degenerate one. ② ("last-substantive-turn") would recover it; ① (current turn) throws it away.
27
- * - **unknown** — degenerate from the start, nothing substantive anywhere (neither ① nor ② helps).
28
- *
29
- * Output is measurement only: a `degenerate_total{class}` counter (→ `/metrics/summary`) plus one
30
- * structured log line carrying the raw signals. The aggregate a/b/unknown frequencies are what gets
31
- * reported back to the core AI to decide whether ② is worth building. The repetition-boundary estimate
32
- * here is for *measuring* the unique head — it is NOT the production tail-trim (that lives in core).
33
- */
34
1
  import { type TaskResult } from "@sema-agent/core";
35
2
  import type { Pool } from "mysql2/promise";
36
3
  import type { Metrics } from "./observability/metrics.js";
@@ -1,3 +1,4 @@
1
+ import { failureCodeOf } from "./terminal.js";
1
2
  import { StoredSession } from "@sema-agent/core";
2
3
  import { TiDBSessionStorage } from "./plugins/tidb-session-storage.js";
3
4
  const SUBSTANTIVE_CHARS = 200;
@@ -95,7 +96,7 @@ async function readPriorTurns(pool, sessionId, salvaged) {
95
96
  }
96
97
  export function makeDegenerateInstrument(pool, metrics, logger) {
97
98
  return (result) => {
98
- if (result.errorCode !== "output.degenerate")
99
+ if (failureCodeOf(result.terminal) !== "output.degenerate")
99
100
  return;
100
101
  void (async () => {
101
102
  const salvaged = result.salvagedOutput ?? "";
@@ -1,4 +1,4 @@
1
- import type { TaskNotificationPayload, WorkflowRun } from "@sema-agent/core";
1
+ import type { TaskNotificationPayload, TerminalCause, WorkflowRun } from "@sema-agent/core";
2
2
  import type { RunRecord } from "../plugins/store-contracts.js";
3
3
  /** [ref] 幽灵行案的单源判别:一条 `task_notification` 只有在 **agent 族 × 终态** 时才允许打
4
4
  * `onChildTerminal`(fleet「subagent 树」只渲 agent 子代)。`background_bash`/`monitor`/`external`
@@ -546,11 +546,16 @@ export declare function fleetRunPublisher(bus: FleetEventBus | undefined, run: {
546
546
  export type FleetRunPublisher = ReturnType<typeof fleetRunPublisher>;
547
547
  /** [ref]①/(a) 案:TaskResult → 终态行帧残局键映射(sync/background/resume 三条腿各自 settle,共用一处
548
548
  * 映射——键名映射写三遍必漂移)。usage 与 {@link BgNotification.usage} 同形:stats.tokens→totalTokens、
549
- * stats.toolCalls→toolUses(名对齐,消费端一个形状)。errorCode "cancelled" ⇒ stoppedBy:"user"(与
550
- * markStopSource 的归因值同源);transcriptId 顶层 = run 的 sessionId([ref]§二拍)。缺源的键不铸。 */
549
+ * stats.toolCalls→toolUses(名对齐,消费端一个形状)。失败码 "cancelled" ⇒ stoppedBy:"user"(与
550
+ * markStopSource 的归因值同源);transcriptId 顶层 = run 的 sessionId([ref]§二拍)。缺源的键不铸。
551
+ *
552
+ * 🔴 S-136(core 7.6.0 D-8):入参从**结构性可缺席** `errorCode?: string` 改成 `terminal?: TerminalCause`。
553
+ * 留着旧形不会红 —— 那正是危险所在:`TaskResult` 上已经没有 `errorCode` 这个键了,可结构性可选参数照样
554
+ * 编译通过,于是 `stoppedBy:"user"` 会**静默地永远不再铸**(取消的 run 在舰队行上从此看不出是被人杀的)。
555
+ * 终局事实一律走因由,`failureCodeOf` 是本仓读「这次失败带的是哪个码」的唯一形。 */
551
556
  export declare function fleetRunResiduals(result: {
552
557
  sessionId?: string;
553
- errorCode?: string;
558
+ terminal?: TerminalCause;
554
559
  stats?: {
555
560
  turns?: number;
556
561
  tokens?: number;
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { deriveAgentDisplayStatus, isDelegatedAgentTerminal } from "@sema-agent/core";
3
+ import { persistedPlaneOf } from "../terminal.js";
3
4
  import { recordFailOpen } from "../observability/fail-open.js";
4
5
  import { redactSecrets } from "../trace/redact.js";
5
6
  export function isFleetAgentTerminalNotification(n) {
@@ -341,7 +342,7 @@ export function fleetRunResiduals(result) {
341
342
  : {};
342
343
  return {
343
344
  ...(Object.keys(usage).length > 0 ? { usage } : {}),
344
- ...(result.errorCode === "cancelled" ? { stoppedBy: "user" } : {}),
345
+ ...(persistedPlaneOf(result).errorCode === "cancelled" ? { stoppedBy: "user" } : {}),
345
346
  ...(result.sessionId ? { transcriptId: result.sessionId } : {}),
346
347
  };
347
348
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * **宿主的决策事实** —— 一条耐久 park 被结算时,本服务能如实陈述的那半件事:谁结束了这次等待,以及
3
+ * 审批通道自报的归属。一个叶子模块(只依赖 `@sema-agent/core`),四个消费点共用一个定义。
4
+ *
5
+ * ## 为什么是「事实」而不是「结算词」(core 7.6.0 / [ref] S6-A,BREAKING)
6
+ *
7
+ * 旧形里 server 要往 `ResumeOutcome` 上写 core 的**结算词**(`ApprovalSettledBy` = `"human"` /
8
+ * `"timeout"` / `"aborted"`),于是「哪个词配哪个 decision」这条相容规则得由 server 自己记住 —— 而记错的
9
+ * 方向恰恰是这条规则存在的理由:**「窗到期」被报成「另一个人拒绝了」**。7.6.0 起 core 的
10
+ * `@contract settlement.single_mint` 逐字写着「宿主永不铸词」:宿主交事实,core 在 resume 入口铸
11
+ * `human_allowed` / `human_refused` / `park_sla_expired`,写进行的 `resolvedOutcome.gateOutcome`,再投影到
12
+ * 那次调用的 `tool_end.gate`。
13
+ *
14
+ * ⇒ 本仓的规则从「记住一张词×决定的相容表」变成「说出你知道的那两件事」。相容性也不再靠记忆:一次
15
+ * sweep 不可能 `allow`,core pre-CAS 拒。
16
+ */
17
+ import { type ResumeOutcome } from "@sema-agent/core";
18
+ /**
19
+ * 谁结束了这次等待,以及通道自报的归属。
20
+ * · `decidedBy: "person"` —— 运维经审批通道做的决定(两条 HTTP `/decide` 腿);
21
+ * · `decidedBy: "sla_timeout"` —— 宿主自己的 SLA 扫在行的 deadline 上判的(FACET A;**只有** sweep 能这么说,
22
+ * 而 sweep 结构上不能 `allow`)。
23
+ * · `approver` —— 通道自报的结算方标识串。**缺席永不读成**「没人批」或「有人批」,只意味着通道没报名字。
24
+ */
25
+ /** 直接取 core `ResumeOutcome` policy_ask 臂的 `hostDecision` 型 —— 本仓不复述那两个词(第二铸点)。 */
26
+ export type HostDecision = Extract<ResumeOutcome, {
27
+ gate: "policy_ask";
28
+ }>["hostDecision"];
29
+ /**
30
+ * 一次**人为** decide 的事实。`principal` = 那条腿已经验过身份的决策者(缺席 = 匿名档的部署,通道确实
31
+ * 没有名字可报)。
32
+ *
33
+ * 归属串走 core 的 `screenApproverAttribution` **先筛后带**:core 对形不合的归属是 pre-CAS 响亮拒
34
+ * (`checkpoint.invalid_outcome`),而一个奇形怪状的 principal 把一次合法的人工决策变成 400 是纯回归。
35
+ * 筛不过 ⇒ **不带这一格**(而不是带一个被改造过的串):归属是转录,不是可以「修一修」的东西 —— 修出来的
36
+ * 名字既不是通道报的那个,也不是「通道没报」,两头都不真。
37
+ */
38
+ export declare function personDecision(principal: string | undefined): HostDecision;
39
+ //# sourceMappingURL=host-decision.d.ts.map
@@ -0,0 +1,6 @@
1
+ import { screenApproverAttribution } from "@sema-agent/core";
2
+ export function personDecision(principal) {
3
+ const screened = screenApproverAttribution(principal);
4
+ return { decidedBy: "person", ...(screened.approver !== undefined ? { approver: screened.approver } : {}) };
5
+ }
6
+ //# sourceMappingURL=host-decision.js.map