@sema-agent/server 7.62.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 (69) hide show
  1. package/MIGRATION.md +8 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/USAGE.md +48 -15
  5. package/deploy/sema-up/smoke.sh +2 -2
  6. package/dist/approval-ask-audit-store.d.ts +149 -3
  7. package/dist/approval-ask-audit-store.js +11 -4
  8. package/dist/bench/s1/arms.d.ts +2 -2
  9. package/dist/bench/s1/arms.js +20 -15
  10. package/dist/bench/s1/live-deps.js +3 -1
  11. package/dist/bench/s1/run-firm.js +2 -2
  12. package/dist/boot/runner-deps.d.ts +2 -2
  13. package/dist/boot/runner-deps.js +1 -0
  14. package/dist/config-catalog.js +2 -0
  15. package/dist/config-types.d.ts +27 -2
  16. package/dist/config.js +83 -2
  17. package/dist/degenerate-instrument.d.ts +0 -33
  18. package/dist/degenerate-instrument.js +2 -1
  19. package/dist/fleet/fleet-bus.d.ts +9 -4
  20. package/dist/fleet/fleet-bus.js +2 -1
  21. package/dist/host-decision.d.ts +39 -0
  22. package/dist/host-decision.js +6 -0
  23. package/dist/http/principal-gate.d.ts +9 -0
  24. package/dist/http/principal-gate.js +12 -0
  25. package/dist/http/route-ctx.d.ts +8 -5
  26. package/dist/http/routes/a2a-serve.js +4 -2
  27. package/dist/http/routes/approvals-assistant.d.ts +0 -13
  28. package/dist/http/routes/approvals-assistant.js +2 -1
  29. package/dist/http/routes/capabilities.js +2 -0
  30. package/dist/http/routes/diagnostics.js +7 -4
  31. package/dist/http/routes/runs.js +3 -2
  32. package/dist/http/routes/tasks.js +33 -29
  33. package/dist/http/routes/workflows.d.ts +0 -11
  34. package/dist/http/routes/workflows.js +18 -14
  35. package/dist/http/server.d.ts +7 -0
  36. package/dist/http/server.js +50 -46
  37. package/dist/leader/fanout.d.ts +6 -23
  38. package/dist/leader/fanout.js +8 -19
  39. package/dist/leader/leader.d.ts +13 -10
  40. package/dist/leader/leader.js +30 -22
  41. package/dist/leader/repair-wire.d.ts +4 -1
  42. package/dist/leader/repair-wire.js +7 -5
  43. package/dist/leader/wire.js +5 -4
  44. package/dist/main.js +7 -3
  45. package/dist/observability/fail-open.d.ts +8 -0
  46. package/dist/observability/fail-open.js +8 -0
  47. package/dist/observability/metrics.js +1 -1
  48. package/dist/observability/run-terminal-log.d.ts +25 -7
  49. package/dist/observability/run-terminal-log.js +15 -4
  50. package/dist/observability/tool-trace.d.ts +13 -12
  51. package/dist/observability/tool-trace.js +8 -13
  52. package/dist/parked-decide.d.ts +8 -4
  53. package/dist/parked-decide.js +1 -1
  54. package/dist/plugins/file-run-store.js +12 -21
  55. package/dist/plugins/memory-run-store.js +3 -2
  56. package/dist/plugins/run-store-sql.js +3 -2
  57. package/dist/run-local.js +6 -4
  58. package/dist/runs.d.ts +4 -19
  59. package/dist/runs.js +49 -47
  60. package/dist/terminal.d.ts +100 -0
  61. package/dist/terminal.js +55 -0
  62. package/dist/trace/core-keyset-guard.d.ts +20 -4
  63. package/dist/trace/ledger-sink.d.ts +2 -1
  64. package/dist/trace/ledger-sink.js +14 -7
  65. package/dist/trace/project.d.ts +9 -12
  66. package/dist/trace/project.js +54 -9
  67. package/dist/write-protection.d.ts +80 -0
  68. package/dist/write-protection.js +30 -0
  69. 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/README.md CHANGED
@@ -162,6 +162,8 @@ The server is configured entirely through environment variables. The most import
162
162
  | `DEFAULT_SCENARIO` | `code` | Default scenario when the request body names none |
163
163
  | `SANDBOX_PKG_SOURCE` | `global` | Package sources inside sandboxes: `global` (official upstreams) / `cn` (China mirrors) / `custom` / `none` |
164
164
  | `SENSITIVE_WRITE_PATTERNS` | core's recommended set | Sensitive-path write deny list; comma-separated value replaces the set, `off` **or an empty value** disables. Applied unconditionally at the governance layer (independent of client permission mode, lane or settings presence) — including on the `run-local` leg. A value that cannot compile into a guard set (e.g. `/`, a pattern with no path segment) refuses to start |
165
+ | `WRITE_PROTECTED_EXTRA` | unset (engine default table) | **Adds** rows to core's write-protection table (the literal-name table whose hit demotes a surviving `allow` to `ask` on Write/Edit/NotebookEdit). Comma-separated bare names (a bare name matches ANY path segment; a name containing `/` matches a consecutive segment run) or a JSON array of `"name"` strings / `{name, kind}` rows (`kind`: `basename` \| `segment` \| `segment-run`). The value is composed as `[...WRITE_PROTECTED_DEFAULT_TABLE, …]`, so **no default row can be lost**. Unset = no seat is wired = the engine's default table is in force (this server never copies that table). Form discrimination is by content, not by first character: a value containing any JSON structural character (`[ ] { } "`) is parsed as JSON and **must** be a top-level array (a missing pair of brackets refuses to start instead of being split into junk bare names). An empty value, a malformed entry (glob metacharacter, unknown kind, kind/name mismatch) or setting this together with `WRITE_PROTECTED_TABLE_REPLACE` **refuses to start**. Read faces: `GET /v1/capabilities` → `writeProtection.{armed,rows,replaced}`; `GET /v1/diagnostics/wiring` → `writeProtection.{rows,source,droppedDefaultRows}` (operator-only) |
166
+ | `WRITE_PROTECTED_TABLE_REPLACE` | unset (engine default table) | **Replaces the whole** write-protection table (core's seat is whole-table by contract). JSON array only — deliberately no comma shorthand, because a slipped bare string would swap 51 default rows for one. `[]` = the explicit "no write-protection table at all" posture. Replacing logs one **loud** boot line naming every default row you dropped (`write_protection_table_replaced`; the empty posture logs `write_protection_table_disabled`) — use `WRITE_PROTECTED_EXTRA` when you meant to ADD. Same refuse-to-start conditions as its sibling, plus: both knobs set = two writers on one surface = refuses to start |
165
167
  | `MANUAL_MODE_SHELL_GATE` | unset | `always`\|`classify` — tighten `Bash` into the approval chain, applied unconditionally at the governance layer (≥7.1.0: independent of client permission mode, lane, or settings presence). **Unset is not "off"**: since 7.12.0 the caller's explicit `permissionMode` supplies the baseline this knob tightens from (`bypassPermissions` → `off`, `auto`/`default`/`acceptEdits`/`plan` → `classify`; **no** mode stated → no gate, core's `off` default). This knob only ever raises that baseline — it has no relax half, so `off` is accepted as an explicit **no-op** (a boot line says so; not symmetric with `SENSITIVE_WRITE_PATTERNS=off`, which really does clear a set). **Any other value refuses to start** (7.12.0, BREAKING for a deployment that had a typo: it was previously treated as unset, i.e. silently no gate at all) |
166
168
  | `PERMISSIONS_DISABLE_AUTO_MODE` | `false` | Local mirror of CC `permissions.disableAutoMode` — the **org deny** bit for `permissionMode:"auto"` (paired with core's intent-arming rule). **Tighten-only**: `true` folds every principal's `runtimeCaps.autoMode` to `false` (a center grant cannot flip it back); unset leaves caps untouched, so on a center-less box a shell asking for `auto` **arms** the classifier once the paired core (intent-arming rule "requested ∧ classifier seat ∧ `autoMode !== false`" — absence is not a deny) is installed; on core 7.2.0 the engine still uses the old "org grant" rule (`permissionModeAuto.intentArming:false`), so the self-check answers `armed:true` only for a center-granted principal and `deployment_incapable` on a center-less box. Boolean word table; any other value refuses to start. Self-check: `GET /v1/capabilities?permissionMode=auto` → `permissionModeAuto.{armed, reason, model}` (USAGE §9.4) |
167
169
  | `SCRATCHPAD_SWEEP_TTL_MS` | 7 days | Idle-reap window for per-session scratchpad dirs (by dir mtime; `0` disables). The scratchpad is **ephemeral by contract**: replica-local disk, NOT part of the durable-suspend persistence set — a resume on a different replica, or after a sweep, starts with an empty dir (same two-track posture as the Agent SDK hosting doc: conversation persists, working-directory artifacts don't). Raise/disable only on single-replica deployments that park approvals for longer than the window |
package/README.zh-CN.md CHANGED
@@ -148,6 +148,8 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
148
148
  | `DEFAULT_SCENARIO` | `code` | 请求体未指定场景时的缺省场景 |
149
149
  | `SANDBOX_PKG_SOURCE` | `global` | 沙箱内装包源:`global`(官方源)/ `cn`(国内镜像)/ `custom` / `none` |
150
150
  | `SENSITIVE_WRITE_PATTERNS` | core 推荐集 | 敏感路径写拒集;逗号分隔值为整体替换,`off` **或留空**关闭。在治理层无条件施加(与客户端权限模式/lane/settings 在场性无关),`run-local` 腿同样生效;编译不出守卫集的值(如 `/`)启动即拒 |
151
+ | `WRITE_PROTECTED_EXTRA` | 未设(引擎缺省表) | 给 core 的**写保护名表**加行(字面名表:命中即把幸存的 `allow` 降级成 `ask`,作用于 Write/Edit/NotebookEdit)。逗号分隔裸名(裸名匹配**任意路径段**,含 `/` 的名匹配连续段)或 JSON 数组(`"name"` 串 / `{name, kind}` 行,`kind`:`basename` \| `segment` \| `segment-run`)。值按 `[...core 缺省表, …]` 组合,**丢不掉任何缺省行**。未设 = 不铸座 = 引擎缺省表在岗(本仓从不复制那张表)。两形按**内容**判而不是猜首字符:值里出现 JSON 结构字符(`[ ] { } "`)即按 JSON 解析,且顶层**必须是数组**(少写一对方括号 ⇒ 拒启,而不是被拆成垃圾裸名静默收下)。空值 / 坏值(通配符、未知 kind、kind 与名字段数矛盾)/ 与 `WRITE_PROTECTED_TABLE_REPLACE` 同时设置 ⇒ **启动即拒**。读面:`GET /v1/capabilities` 的 `writeProtection.{armed,rows,replaced}`;`GET /v1/diagnostics/wiring` 的 `writeProtection.{rows,source,droppedDefaultRows}`(operator-only) |
152
+ | `WRITE_PROTECTED_TABLE_REPLACE` | 未设(引擎缺省表) | **整表替换**写保护名表(core 的座按契约就是整表)。只收 JSON 数组 —— 刻意不给逗号简写:一个手滑的裸串会把 51 行换成 1 行。`[]` = 显式「完全不要这张表」。替换时 boot 期发一条**响亮**日志逐名列出被丢的缺省行(`write_protection_table_replaced`;空表走 `write_protection_table_disabled`)—— 想「加两行」请用 `WRITE_PROTECTED_EXTRA`。拒启条件同姊妹键,外加:两根同写 = 一条语义面两个写者 ⇒ 拒启 |
151
153
  | `MODEL_CONNECT_TIMEOUT_MS` | `30000` | 网关连接超时 |
152
154
  | `MODEL_FIRST_TOKEN_TIMEOUT_MS` | `120000` | 首 token 超时 |
153
155
  | `MODEL_IDLE_TIMEOUT_MS` | `300000` | 流中 idle 超时(`0` 关) |
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 可观测)**
@@ -563,6 +563,37 @@ UNATTENDED_APPROVAL_POLICY=park
563
563
  不是 server 的 ask 行,「有 durable 设施但没开协议」正是 R-13 要救的那类部署)。要回到旧的「窗满即拒」
564
564
  就配 `UNATTENDED_APPROVAL_POLICY=deny`。
565
565
 
566
+ **可选 — 写保护名表(S-138;缺席=引擎缺省表在岗)**
567
+
568
+ ```bash
569
+ WRITE_PROTECTED_EXTRA=.corp-deploy-key,team/secrets # 加法:[...引擎缺省表, 这两行]
570
+ WRITE_PROTECTED_TABLE_REPLACE='[{"name":".envrc","kind":"basename"}]' # 整表替换(与上一根互斥)
571
+ ```
572
+ - **这是什么**:core 的**字面名表**写保护 —— 一次落在表行上的可定路径写(Write/Edit/NotebookEdit)会把
573
+ 幸存的 `allow` 降级成 `ask`(拒/问的裁决不受影响)。表的缺省内容是 CC 的
574
+ `DANGEROUS_FILES`/`DANGEROUS_DIRECTORIES`/`DANGEROUS_DIRECTORY_PATHS` 三形 + 两行 sema 自有行。
575
+ 匹配是**字面**的(`kind`:`basename` = 末段同名 / `segment` = 任意路径段同名 / `segment-run` = 连续段序列),
576
+ 裸名简写 = `segment`(更宽的那一档),含 `/` 的裸名 = `segment-run`。
577
+ - **两根旋钮按意图分家**:core 的座是**整表替换**,所以「我想再保护两个文件」只能走 `WRITE_PROTECTED_EXTRA`
578
+ (它组合成 `[...缺省表, …]`,结构上丢不掉缺省行);真要换整张表才用 `WRITE_PROTECTED_TABLE_REPLACE`
579
+ (只收 JSON;`[]` = 显式「完全不要这张表」)。**两根同写 = 拒启**(一条语义面不许两个写者)。
580
+ - **两形怎么判**:值里出现 JSON 结构字符(`[` `]` `{` `}` `"`)⇒ 当 JSON 判 —— 解析失败或顶层不是**数组**
581
+ 一律拒启(`WRITE_PROTECTED_EXTRA='{"name":".x","kind":"basename"}'` 这种少写一对方括号的写法会被当场拒,
582
+ 而不是被拆成两个垃圾裸名静默收下);否则走逗号裸名表。真有名字带引号/花括号的文件 ⇒ 走 JSON 形。
583
+ - **响亮**:整表替换在 boot 期发一条日志**逐名**列出被丢的缺省行(`write_protection_table_replaced`),
584
+ `[]` 发 `write_protection_table_disabled`。空值 / 坏值(通配符、未知 kind、kind 与名字段数矛盾)一律
585
+ **启动期拒**,文案带引擎原话 + 出问题的 env 名。
586
+ - **缺席 ≠ 没装**:不设这两根 ⇒ 座不铸 ⇒ **引擎的缺省表在岗**(server 不复制那张表)。要确认这台机器上
587
+ 它到底在不在,读**读面**而不是猜:
588
+ - 租户面 `GET /v1/capabilities` → `writeProtection: {armed, rows, replaced}`(只给计数,不给行名);
589
+ - operator 面 `GET /v1/diagnostics/wiring` → `writeProtection: {rows[], source, droppedDefaultRows?}`
590
+ (`source`:`default` / `extra` / `replace` / `off`;逐行内容只在这一面)。
591
+ - **与 `SENSITIVE_WRITE_PATTERNS` 并列、不合流**:那一根是**模式**(正则/glob)型 DENY 集,这一根是
592
+ 字面名表的 `allow → ask` 降级。两套互不覆盖,读面也分开报 —— 一条路径被哪一套拦住,是两个不同的问题
593
+ (解法一个是改模式、一个是改名表)。
594
+ - **leader 车道同席**:与其余部署姿态席同一个取值点(`buildDeploymentPostureSeats`),主 runner / subRunner /
595
+ run-local / leader 五只 Runner 同表。
596
+
566
597
  **可选 — READ 面姿态(readFace,7.18.0 起;缺席=引擎当家)**
567
598
 
568
599
  ```bash
@@ -801,23 +832,25 @@ invalid_using_default` 警告 + 退回缺省值」这条臂随词表放宽一并
801
832
  | `repo` / `council` / `debate` | — | `repo` 为 `code-review`/`scan` 必填;`council`/`debate` 仅 `code-review`,见 §5 |
802
833
  | ~~model / tools / prompt~~ | 🚫 | **不接受**——服务端注入 |
803
834
 
804
- **`TaskResult`(同步 / `done` 事件里拿到的):**
835
+ **`TaskResult`(同步 / `done` 事件里拿到的;7.64.0 起终局是**一条** `terminal` 因由,不再有 `status`/`errorCode`/`errorMessage`/`blockedReason` 四个平面键):**
805
836
  ```json
806
- { "taskId":"…", "sessionId":"0190…", "status":"completed",
837
+ { "taskId":"…", "sessionId":"0190…",
838
+ "terminal": { "kind": "completed" },
807
839
  "result":"最终回答文本",
808
- "blockedReason": null, "errorMessage": null, "errorCode": null,
809
840
  "stats": { "turns": 1, "tokens": 123 } }
810
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`)。
811
844
  | 字段 | 用途 |
812
845
  |---|---|
813
846
  | `result` | 答案文本 |
814
847
  | `sessionId` | 续聊用——下次带回 |
815
- | `status` | `completed` / `blocked` / `failed`(终局闭集;`timeout` 已于 core 5.8.0/server 6.0.0 退役,不会再出现——见 §9) |
816
- | `errorCode` | 程序化分支:如 `"conflict"`(乐观锁丢失,可重试) |
817
- | `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` 臂:为什么做不了(缺信息/权限) |
818
851
 
819
- > ⚠️ **任务成败以 `status`/`errorCode` 为准,勿以 HTTP 状态码判**:同步腿 200=提交受理成功(任务可能
820
- > `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` 帧)。执行期
821
854
  > 错误结构上无法用 HTTP 状态码承载——流的响应头在 run 开跑前就发完了(详见
822
855
  > `docs/ASSISTANT-WIRE-CONTRACT.md` 附录 A)。
823
856
 
@@ -1174,17 +1207,17 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
1174
1207
  ## 9. 状态 / 错误码速查
1175
1208
  | 你看到 | 含义 | 怎么办 |
1176
1209
  |---|---|---|
1177
- | HTTP `200` + `status:"completed"` | 成功 | 取 `result` |
1178
- | `status:"blocked"` | agent 主动报卡住 | 看 `blockedReason`,补信息再发 |
1179
- | `status:"failed"` + `errorCode:"conflict"` | 跨实例乐观锁丢失 | 直接重试(幂等) |
1180
- | `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`(毫秒) |
1181
1214
  | HTTP `401` | 缺 `Authorization` / 缺 `x-agent-principal`(要求时) | 补头 |
1182
1215
  | HTTP `403` / `404`(session/run) | 不是该 principal 的资源 | 用正确身份 |
1183
1216
  | HTTP `409`(`/v1/runs`) | 同 session 已有活跃 run | 等它完成 / 用返回的 `activeTaskId` |
1184
1217
  | HTTP `429` | 限流 | 看 `Retry-After` 退避 |
1185
1218
  | HTTP `429` + `errorCode:"limit.cost_quota_exceeded"` | per-principal 累计成本配额越顶(`MAX_PRINCIPAL_COST_USD`;**进场门**,不打断在跑的 run) | 看 `Retry-After` / 体 `retryAfterSec` 退避;窗滚过或调高上限后放行 |
1186
1219
  | HTTP `429` + `errorCode:"usage.window_exhausted"` | 部署级治理窗耗尽(`USAGE_WINDOWS`,token 或 $ 天花板先满者) | 看响应体 `retryAfterSec` 退避;窗滑动/桶到期后放行 |
1187
- | `status:"failed"` + `errorCode:"usage.window_exhausted"` | 已受理的 run 在 **turn 边界**撞上治理窗且无法 durable 挂起 | 同上退避后重投;配好 checkpoint 基建则改为 `suspended` 等窗自动续跑 |
1220
+ | `terminal:{kind:"failed", code:"usage.window_exhausted"}` | 已受理的 run 在 **turn 边界**撞上治理窗且无法 durable 挂起 | 同上退避后重投;配好 checkpoint 基建则改为 `suspended` 等窗自动续跑 |
1188
1221
  | HTTP `501`(`/v1/runs`) | 内存模式不支持异步 | 配 MySQL 协议存储(`SESSION_BACKEND=mysql`) |
1189
1222
 
1190
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,
@@ -154,7 +154,7 @@ export interface RunnerDepsCtx {
154
154
  }
155
155
  /** [ref] A10 留档发现②:main runner `RunnerDeps` 与 main.ts subRunner 字面量之间此前手工重复
156
156
  * 的 ~15 个键,类型标注见 {@link createSharedRunnerDeps} 头注。 */
157
- export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryCapturePolicy" | "delegationEntryCaps" | "crossSessionInbound" | "crossSessionDialogExpiry" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "hands" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores" | "compliancePostureResolver" | "lockedConfig" | "retentionPolicy" | "onNotice" | "mcpRevocations">;
157
+ export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryCapturePolicy" | "delegationEntryCaps" | "crossSessionInbound" | "crossSessionDialogExpiry" | "writeProtectedPaths" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "hands" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores" | "compliancePostureResolver" | "lockedConfig" | "retentionPolicy" | "onNotice" | "mcpRevocations">;
158
158
  /**
159
159
  * [ref] A10 留档发现②(review 2026-07-29,[ref]§三族A 同源修补的延续):main runner 的
160
160
  * `RunnerDeps` 字面量(下方 `createRunnerDeps`)与 `main.ts` 里 subRunner 的 `new Runner({...})`
@@ -260,7 +260,7 @@ export declare function createEngineNoticeSeat(logger: {
260
260
  * 「键为 undefined」与「键缺席」对引擎逐字等价)。**不复制引擎缺省**(`roots`/`static-face`/`carry`/`open`/
261
261
  * CC parity 20/200 一个都不抄):上游改缺省之日起本仓不会成为第二份真源。
262
262
  */
263
- export type DeploymentPostureSeats = Pick<RunnerDeps, "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryCapturePolicy" | "delegationEntryCaps" | "crossSessionInbound" | "crossSessionDialogExpiry">;
263
+ export type DeploymentPostureSeats = Pick<RunnerDeps, "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryCapturePolicy" | "delegationEntryCaps" | "crossSessionInbound" | "crossSessionDialogExpiry" | "writeProtectedPaths">;
264
264
  export declare function buildDeploymentPostureSeats(config: Pick<ServiceConfig, keyof DeploymentPostureSeats>): DeploymentPostureSeats;
265
265
  export declare function createSharedRunnerDeps(ctx: SharedRunnerDepsCtx): SharedRunnerDeps;
266
266
  export declare function createRunnerDeps(ctx: RunnerDepsCtx): RunnerDeps;
@@ -46,6 +46,7 @@ export function buildDeploymentPostureSeats(config) {
46
46
  delegationEntryCaps: config.delegationEntryCaps ?? undefined,
47
47
  crossSessionInbound: Object.keys(crossSessionLayers).length > 0 ? crossSessionLayers : undefined,
48
48
  crossSessionDialogExpiry: crossSessionDialogExpiryOf(config),
49
+ writeProtectedPaths: config.writeProtectedPaths?.entries,
49
50
  };
50
51
  return seats;
51
52
  }
@@ -440,6 +440,8 @@ export const CONFIG_CATALOG = [
440
440
  r("WORKTREE_BASE_COMMIT", "orchestration", "string", "worktree 基准 ref(缺省 core HEAD)"),
441
441
  r("WORKTREE_ISOLATION_ENABLED", "orchestration", "boolean", "git-worktree 隔离(与 REPO_ROOT 同在才开,半配保持 OFF)", { staticDefault: "false", danger: SEC }),
442
442
  r("WORKTREE_REPO_ROOT", "orchestration", "path", "worktree 隔离的受信 base repo"),
443
+ r("WRITE_PROTECTED_EXTRA", "approval", "csv", "写保护名表的**加法**(逗号裸名或 JSON [{name,kind}];组合 [...core 缺省表, …];空值拒启)", { danger: SEC, derivedDefaultNote: "缺席 ⇒ 不铸座,core 缺省表(51 行)在岗" }),
444
+ r("WRITE_PROTECTED_TABLE_REPLACE", "approval", "json", "写保护名表的**整表替换**(JSON 数组;[]=显式无表;boot 期 loud 列出被丢的缺省行)", { danger: SEC, derivedDefaultNote: "缺席 ⇒ 不铸座,core 缺省表在岗;与 WRITE_PROTECTED_EXTRA 同写 ⇒ 拒启" }),
443
445
  ];
444
446
  function primitiveConfigValue(config, key) {
445
447
  const v = Reflect.get(config, key);