@sema-agent/server 7.124.1 → 7.125.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/MIGRATION.md +138 -2
  2. package/USAGE.md +46 -8
  3. package/dist/audit.d.ts +27 -5
  4. package/dist/audit.js +42 -8
  5. package/dist/boot/execution-env.d.ts +33 -1
  6. package/dist/boot/execution-env.js +31 -2
  7. package/dist/boot/resolve-spec.js +4 -3
  8. package/dist/boot/session-faces.d.ts +14 -1
  9. package/dist/boot/session-faces.js +6 -5
  10. package/dist/boot/stage-02-web-admin-pull.js +5 -1
  11. package/dist/boot/stage-05-execution-env.js +6 -2
  12. package/dist/boot/stage-09-leader.d.ts +1 -1
  13. package/dist/boot/stage-10-http-server.d.ts +1 -1
  14. package/dist/boot/web-admin.js +130 -140
  15. package/dist/capabilities/center-wiring.d.ts +2 -1
  16. package/dist/capabilities/composite-members.d.ts +1 -0
  17. package/dist/capabilities/composite-members.js +1 -0
  18. package/dist/capability-facts.d.ts +28 -3
  19. package/dist/capability-facts.js +20 -0
  20. package/dist/config-catalog.js +4 -1
  21. package/dist/config-d-migration.js +2 -1
  22. package/dist/config-lkg.d.ts +4 -1
  23. package/dist/config-lkg.js +16 -12
  24. package/dist/config-provider.d.ts +4 -1
  25. package/dist/config-provider.js +1 -0
  26. package/dist/config-types.d.ts +10 -3
  27. package/dist/config.js +44 -14
  28. package/dist/env-facts.d.ts +2 -2
  29. package/dist/env-facts.js +3 -2
  30. package/dist/fleet/fleet-bus.d.ts +24 -12
  31. package/dist/fleet/fleet-bus.js +4 -1
  32. package/dist/fleet/workspace-snapshot-residual.d.ts +10 -0
  33. package/dist/fleet/workspace-snapshot-residual.js +35 -0
  34. package/dist/hooks/hook-runner.d.ts +15 -27
  35. package/dist/hooks/hook-runner.js +19 -59
  36. package/dist/http/active-run-conflict.d.ts +3 -0
  37. package/dist/http/active-run-conflict.js +3 -0
  38. package/dist/http/admission.js +7 -7
  39. package/dist/http/approval-sweeps.js +2 -4
  40. package/dist/http/device-placement.js +1 -0
  41. package/dist/http/dispatch.js +1 -1
  42. package/dist/http/durable-run-leg.d.ts +20 -6
  43. package/dist/http/durable-run-leg.js +4 -4
  44. package/dist/http/idempotency.d.ts +83 -38
  45. package/dist/http/idempotency.js +38 -13
  46. package/dist/http/request-guards.d.ts +18 -0
  47. package/dist/http/request-guards.js +12 -3
  48. package/dist/http/resume-legs.js +7 -1
  49. package/dist/http/route-ctx.d.ts +3 -3
  50. package/dist/http/route-ctx.js +1 -1
  51. package/dist/http/routes/a2a-serve.js +27 -11
  52. package/dist/http/routes/capabilities.js +1 -0
  53. package/dist/http/routes/fleet.js +2 -0
  54. package/dist/http/routes/images.js +2 -2
  55. package/dist/http/routes/leader.d.ts +4 -0
  56. package/dist/http/routes/leader.js +13 -3
  57. package/dist/http/routes/observability.js +2 -0
  58. package/dist/http/routes/runs.d.ts +6 -2
  59. package/dist/http/routes/runs.js +71 -101
  60. package/dist/http/routes/session-sync.d.ts +3 -0
  61. package/dist/http/routes/session-sync.js +30 -1
  62. package/dist/http/routes/sessions-list.d.ts +11 -0
  63. package/dist/http/routes/sessions-list.js +6 -6
  64. package/dist/http/routes/sessions.d.ts +6 -1
  65. package/dist/http/routes/sessions.js +41 -10
  66. package/dist/http/routes/tasks.js +63 -21
  67. package/dist/http/run-read-face.d.ts +26 -0
  68. package/dist/http/run-read-face.js +67 -0
  69. package/dist/http/server.d.ts +4 -4
  70. package/dist/http/server.js +1 -2
  71. package/dist/http/submit-replay.d.ts +40 -0
  72. package/dist/http/submit-replay.js +22 -0
  73. package/dist/leader/endpoint.d.ts +10 -5
  74. package/dist/leader/endpoint.js +27 -5
  75. package/dist/observability/cost-quota.d.ts +51 -7
  76. package/dist/observability/cost-quota.js +35 -17
  77. package/dist/observability/fail-open.d.ts +1 -1
  78. package/dist/observability/fail-open.js +1 -1
  79. package/dist/observability/run-terminal.d.ts +5 -9
  80. package/dist/observability/run-terminal.js +2 -7
  81. package/dist/plugins/approval-ask-store-sql.js +1 -1
  82. package/dist/plugins/caching-session-store.d.ts +7 -2
  83. package/dist/plugins/caching-session-store.js +6 -0
  84. package/dist/plugins/device-store-sql.js +1 -1
  85. package/dist/plugins/ensure-index.js +22 -0
  86. package/dist/plugins/file-run-store.d.ts +24 -14
  87. package/dist/plugins/file-run-store.js +52 -12
  88. package/dist/plugins/leader-run-store-sql.d.ts +1 -11
  89. package/dist/plugins/leader-run-store-sql.js +1 -5
  90. package/dist/plugins/local-session-store.d.ts +35 -6
  91. package/dist/plugins/local-session-store.js +87 -18
  92. package/dist/plugins/memory-run-store.d.ts +13 -12
  93. package/dist/plugins/memory-run-store.js +25 -0
  94. package/dist/plugins/one-time-migrations.js +1 -1
  95. package/dist/plugins/pg-cost-quota.d.ts +1 -1
  96. package/dist/plugins/pg-cost-quota.js +2 -5
  97. package/dist/plugins/pg-pool.js +3 -0
  98. package/dist/plugins/pg-session-storage.d.ts +11 -1
  99. package/dist/plugins/pg-session-storage.js +44 -8
  100. package/dist/plugins/remote-env-k8s.d.ts +127 -12
  101. package/dist/plugins/remote-env-k8s.js +270 -27
  102. package/dist/plugins/run-store-sql.d.ts +26 -12
  103. package/dist/plugins/run-store-sql.js +50 -25
  104. package/dist/plugins/schema-consistency.d.ts +1 -13
  105. package/dist/plugins/schema-consistency.js +1 -41
  106. package/dist/plugins/schema-indexes.js +1 -0
  107. package/dist/plugins/schema-live-columns.d.ts +20 -0
  108. package/dist/plugins/schema-live-columns.js +42 -0
  109. package/dist/plugins/store-contracts.d.ts +40 -2
  110. package/dist/plugins/tidb-cost-quota.d.ts +1 -1
  111. package/dist/plugins/tidb-cost-quota.js +2 -5
  112. package/dist/plugins/tidb-pool.js +14 -3
  113. package/dist/plugins/tidb-session-store.d.ts +22 -2
  114. package/dist/plugins/tidb-session-store.js +33 -6
  115. package/dist/plugins/write-behind-counter.d.ts +12 -5
  116. package/dist/plugins/write-behind-counter.js +14 -7
  117. package/dist/prompts-domain-validate.js +3 -2
  118. package/dist/rules-consent.d.ts +2 -2
  119. package/dist/rules-consent.js +2 -2
  120. package/dist/run-local.js +31 -23
  121. package/dist/runs.d.ts +9 -0
  122. package/dist/runs.js +8 -0
  123. package/dist/runtime-governance.d.ts +3 -3
  124. package/dist/runtime-governance.js +2 -4
  125. package/dist/security.d.ts +19 -2
  126. package/dist/session-row.d.ts +53 -0
  127. package/dist/session-row.js +47 -0
  128. package/dist/session-sync.d.ts +6 -0
  129. package/dist/session-sync.js +5 -1
  130. package/dist/session-titler.d.ts +9 -5
  131. package/dist/session-titler.js +3 -1
  132. package/dist/spec-fields.js +2 -2
  133. package/dist/task-cwd.d.ts +0 -20
  134. package/dist/task-cwd.js +2 -10
  135. package/dist/tool-approval.d.ts +5 -7
  136. package/dist/trace/redact.d.ts +23 -50
  137. package/dist/trace/redact.js +42 -9
  138. package/dist/trace/self-minted-wire.d.ts +25 -0
  139. package/dist/trace/self-minted-wire.js +9 -0
  140. package/dist/web-admin-client/apply-effective.d.ts +10 -12
  141. package/dist/web-admin-client/apply-effective.js +2 -3
  142. package/dist/web-admin-client/candidate-gate.d.ts +69 -0
  143. package/dist/web-admin-client/candidate-gate.js +27 -0
  144. package/dist/web-admin-client/http-client.d.ts +2 -1
  145. package/dist/web-admin-client/http-client.js +2 -1
  146. package/dist/web-admin-client/pull-state.d.ts +6 -4
  147. package/dist/web-admin-client/read-warnings.d.ts +29 -5
  148. package/dist/web-admin-client/read-warnings.js +24 -4
  149. package/dist/web-admin-client/stage-limits.js +4 -1
  150. package/dist/workspace-space.d.ts +41 -0
  151. package/dist/workspace-space.js +5 -0
  152. package/package.json +3 -3
package/MIGRATION.md CHANGED
@@ -7,8 +7,144 @@
7
7
  > ⚠️ 完整清单在仓库根 `CHANGELOG.md`——它**不随 npm tarball 出包**(本文件随包)。看完整迁移窗的
8
8
  > 权威姿势是源码 tag diff:`git diff v<旧>..v<新>`(每版都推 `v<版本>` tag);npm 包页也镜像 CHANGELOG。
9
9
 
10
- ## Unreleased
11
-
10
+ ## 7.125.1 —— 受理门宿主绝对路径形判认三族(posix / Windows 盘符 / UNC;契约包 11.3.1)(行为修复,升级零动作)
11
+
12
+ 0. **行为修复,无升级动作**:Windows 宿主送 `C:\…` / UNC 形的 `cwd` / `additionalDirectories` / `additionalReadDirectories` / `skill.baseDir` 不再被受理门 `400 request.field_invalid`;posix 路径逐字同 7.125.0;拒句与错误码不变。零 DDL、零旋钮、零 wire 形变;细节见 CHANGELOG 7.125.1。
13
+
14
+ ## 7.125.0 —— 提交口幂等 `task_run.idem_key` 新列 + `session_meta.pinned` 新列(存量库各加一列,先升库再升副本)· 会话口 id 形门放宽(拒句变)· 配置候选整份不收 · hooks matcher 不再锚定(选中只增)· 脱敏产物回原文 · 在飞合流跟随方 `done.result` 换形 · `?session=` 过滤键 = 发起会话 · k8s `/workspace` 跨轮保留 opt-in(缺省不变)· 本地 `config.d` 起服迁移覆盖 `remote-exec.json`
15
+
16
+ 1. **订正 7.124.0 节第 14 条的值域句**(文字订正,行为不变):`410 gone.ask` / `404 tool_approval.not_pending` 的 `cause` 值域里新增的 `run_exited` 与 `batch_sibling_withdrawn` 两词是闭集成员、字面为真,但在 7.124.0 / 7.124.1 的决断口上**没有部署可达的构造**:`batch_sibling_withdrawn` 只对同一批里其他待决行撤卡,而引擎一次只呈一张卡,同批只登一行;`run_exited` 是承载腿退场时的兜底清扫臂,断连先落 `stream_disconnected`、取消与同步腿断连先落 `run_cancelled`。消费端按闭集穷举处理两词即可,不要把它们当作可复现的场景。
17
+
18
+ 2. **归位说明**:下面 7.124.1 节的三条文字订正随 7.124.1 包发出时误留在本「Unreleased」段下;内容未变,现归入 7.124.1 节。
19
+
20
+ 3. **起服不再容忍坏域:配置候选只有一只判官(起服 = 刷新 = 迟到起服 = dry-run = `run-local`)**。一份候选(中心有效视图,或本地 `config.d` 读出的那份)任一域判形失败
21
+ (读不懂 / 拼错键 / 退役形 / 坏文件)或 prompts 面坏形 ⇒ **整份不收,一个域都不应用**。`config.d` 里**读腿根本不读**的文件(`workers.json` / `hosts.json` /
22
+ 任何不是域的 `*.json`)不是域错:一份一行 `config_unread_file{dir, file}` 点名、不判拒、其余照用(此前读腿把它折成域错,刷新拍因此整拒)。此前只有刷新整拒;起服逐域容忍
23
+ (坏域落 schema 缺省、其余域照用、`configAppliedVersion` = 这一代 —— `engineSignal` 说已应用,坏域的事实只在一行 `config_domain_invalid` 里)。
24
+ - **起服遇拒与「拉取失败」同一条回落链(候选 → 盘上 LKG → env)**:有 LKG ⇒ 按 LKG 起服(目录、`@` 规则、限额都是 LKG 的),`configAppliedVersion` = LKG 那一代;
25
+ 没有 ⇒ env 起服,`configAppliedVersion` 不动。被拒候选只推进 `configTargetVersion` ⇒ 起服第一拍 `configTargetVersion ≠ configAppliedVersion`
26
+ (例外:LKG 与候选同一版本 —— 中心自上一进程落盘之后没发布过 —— 两值相等,LKG 就是那一代迁过形的那一份,引擎在服务它)。
27
+ 中心发布修好的一版 ⇒ 刷新拍下一拍落地。被拒候选不进 LKG、不推进校验子。
28
+ - **日志**:`config_candidate_rejected` 起服 / 刷新 / 迟到起服同一事件名、同一体,多一键 `leg`(`boot` / `refresh` / `late-boot` / `run-local`;本地腿另带本地根 `dir`);
29
+ 同一份候选、同一组坏域只响一次(起服响过,刷新首拍不再响)。迟到起服腿的 `config_boot_deferred_candidate_rejected` 退役,改发 `config_candidate_rejected`(`leg:"late-boot"`)——
30
+ 按旧事件名告警的规则改名。`config_domain_invalid` 逐域坐标行照发(句子改说「整份被拒」)。dry-run 起服:被拒候选只出拒行,不出 `web_admin_dry_run`、不推进校验子。
31
+ - **谁受伤**:① 本地腿(`CONFIG_PROVIDER=local`):`config.d` 里有一份判形失败的域文件 ⇒ 此前其余域照用,现在整份本地配置不用、引擎按 env 起(本地腿没有 LKG;
32
+ `run-local` 同判);
33
+ 拒行点名坏域与本地根,`config_local_no_models` 点真因。出路 = 改好或把那份文件移出 `config.d`(本地腿刷新拍读到修好的树:热面下一拍落地;起服时没收任何本地配置 ⇒ 那一拍同时签 restart-to-apply 各面(skills / mcp / scenarios)的重启信号,重启一次即收敛)。
34
+ ② 中心腿第一次起服就撞坏候选、盘上又没有 LKG:此前带坏域起、`configAppliedVersion` = 候选,现在 env 起、`configAppliedVersion` 不动。没有旋钮(不给「容忍」开关)。
35
+ - **订正 7.124.0 节「升级前必读」与第 1 条的两种部署句**(已发节正文只读,订正写在这里):本版起「视图里有退役形」的部署**也可以引擎先** ——
36
+ 新引擎起服撞到旧形候选 ⇒ 整份拒 ⇒ 读盘上 LKG(7.123.0 写的 formatVersion 3 读回时迁成新形,`atModelAllowlist` → `atMention` 不丢)⇒ 目录与 `@` 规则照旧;
37
+ 没有 LKG(或 LKG 迁不动,`config_lkg_unusable` 点名迁移 id)的副本 env 起服(响亮)。两种部署收成一句:**先滚谁都行**;窗内 `configTargetVersion ≠ configAppliedVersion` = 这台副本没在服务中心此刻那一代
38
+ (在更早一代的 LKG 或 env 上),起服 / 刷新的 `config_candidate_rejected` 点名 `SS-M6` / `SS-M9` = 中心还在发旧形;web-admin 升到 11.x、数据迁成新形之后下一拍自然落地。
39
+ 带限额的 `runtime` 域照旧是读腿整包拒(拉取失败,走同一条回落链,版本读不到)。窗内仍不要重启 7.123.0 引擎(它起服时对 11.x 视图只丢 `atMention` ⇒ `@` 限制静默放开 ——
40
+ 那是 7.123.0 的代码,本版改不了)。由编排器渲染 env 的车队仍是停机换代三步(第 7 条:那是 env 改名问题,与本条无关)。
41
+
42
+ 4. **会话行可写位(wire additive,零动作)**:新动词 `PATCH /v1/sessions/:id`(体 `{ title?: string | null, pinned?: boolean }`;`title: null` 清回自动标题)、`GET /v1/sessions` 行恒带 `pinned`(缺省 `false`)、`GET /v1/sessions/:id` 的用户消息行可带回退手柄 `taskId`(契约 §20)。会话标题从此是「用户写的,否则自动标题器的」—— 把 `title` 当成「恒为自动标题」来用的读者改读法;端侧自持的改名 / 置顶本地层可以退役(写进服务即一处真源)。浏览器前端:CORS 预检的 `allow-methods` 多了 `PATCH`。嵌入方:包根导出 `buildSessionAudit` 多一个可选参数,手铸会话店若要支持 `PATCH` 须实现会话面 `sessionFaceOf`(属主 + 化身一次读)与写口 `patchSessionMeta(sessionId, patch, face)`(只写 `face` 那一位住客、按同一条件读回并交回那一行,对不上 ⇒ `undefined`、不写);2c 会话同步另要 `sessionSummaryOf`(缺任一口 ⇒ `PATCH` 501 / 2c 会话同步整族 501)。
43
+
44
+ 5. **SQL 后端:`session_meta` 新列 `pinned`(存储面)**:存量 MySQL 协议 / PostgreSQL 库升级后**拒启**(`config.schema_mismatch`,拒因逐字给出 `ALTER TABLE session_meta ADD COLUMN pinned BOOLEAN NOT NULL DEFAULT FALSE;`)。两条出路,代价不同,如实:
45
+ - **加列**(零数据丢失,与 7.101.0 `instruction_source` 那一列同一形):照拒因那一句加列后起服。滚动序恒为「**先升库、再升副本**」(加列之后,旧副本重启会把它看成多出的列而拒启)。
46
+ - **删表重建**(本仓 schema 口径的另一条路,四步):① 升级前,用旧版服务的 2c 会话同步逐会话导出(`GET /v1/sessions/:id/sync/manifest` + `…/sync/entries`)② `DROP TABLE session_meta;` ③ 起本版服务(建表)④ 用 2c 逐会话推回(`POST /v1/sessions/:id/sync/import` 两相)。这条路**会丢**:旧版的 manifest 不带 `title`(会话标题丢失,下一次提交时由自动标题器重填);导入把活动时刻(`lastActivityAt` / `firstActivityAt`)记成导入时刻;属主记成推回它的那个已验身份 —— 🔴 **service token(fleet-wide)代推会把会话写成无属主**(属主事实在删掉的那张表里,manifest 不带属主),而无属主的会话对任何调用方可读可写,多租部署因此**只能由各属主以自己的已验身份逐会话推回**(锚的属主随之重键),没有「集中代推、属主沿用源端」这条路;会话化身在导入时重铸(按化身绑定的会话级记忆采集 opt-out 不再绑它,需重新设置);子代转录(placed)会话不收 2c 导入;删表到推回之间,所有会话在列表里不可见(`session_event` 行还在,推回时按会话整段覆盖)。
47
+ - `DB_BACKEND=local` 与内存形零动作(置顶存在新的边档 `session-pinned.json` 里,文件不在 = 都没置顶)。
48
+
49
+ 6. **单副本(进程内)成本配额的窗改成时钟对齐桶**(行为变更,零配置动作):没配 SQL 后端的部署,`MAX_PRINCIPAL_COST_USD` 的窗此前从该 principal 的首笔花费起算、满 `COST_QUOTA_WINDOW_SEC` 清零;现在与 SQL 部署同形 —— 桶号 = `floor(now / 窗长)`,日窗在 UTC 零点滚。⇒ 紧贴桶界前后的两只桶合计可在很短时间内花掉近两倍额度(SQL 部署既有形);越顶 429 的 `retryAfterSec` 是到当代桶末的秒数。`COST_QUOTA_WINDOW_SEC` 的含义与缺省不变。要压边界突发:窗与额度按目标速率同比调小,或改用 `USAGE_WINDOWS` 的 `rolling` anchor。恢复时刻读 `GET /v1/usage` 体新增的 `windowResetsAt`(越顶 429 体同值)。
50
+
51
+ 7. **`COST_QUOTA_WINDOW_SEC` 不在 `[1, 1e10]` 秒即拒启**(env 腿与 web-admin 发布同一只判官;web-admin 发布域外值整批拒):此前 env 腿照收 0 / 负数 / 任意大值,0 时进程内配额静默不生效。升级前核一眼 env:把它当「关掉」写成 0 的部署删掉这一行(缺省 86400)或写域内的值;关配额用 `MAX_PRINCIPAL_COST_USD=0`。
52
+
53
+ 8. **提交口幂等改成「资源行上的唯一键」(行为变更 + 存量表要加一列;契约 §0.7)**。`Idempotency-Key` 从此由资源行上的唯一键执法:
54
+ run 的 `task_run.idem_key`(新列)、leader run 与镜像烘焙沿用各自表上的 `idem_key`;键 = `sha256(身份位 ␟ 资源族 ␟ 原始键)`,
55
+ 资源族 `run` / `leader` / `bake`(不是端点)。同键再投读到那一行作答 —— 跨副本、跨重启成立;进程内只合流并发的在飞请求。
56
+ - **身份位 = `principal ?? 凭据族`**:请求带验过的 principal ⇒ 按 principal 分桶;不带 principal ⇒ 按凭据族(出示的是哪套服务凭据)分桶;
57
+ 两者皆无 ⇒ 一个桶。没有 principal 的多系统部署(几套服务凭据共用一台引擎)与 7.124.0 及以前一样按系统互不命中,无需改键。
58
+ **变化只在带 principal 的请求上**:同一个 principal 换一套服务凭据、用同一个键重投,7.124.0 及以前是两条,现在命中同一行(同一个人的同一次提交)。
59
+ - **A2A `message/send` 的派生 taskId 随之换算一次**:它与 `idem_key` 吃同一个身份位(此前派生 taskId 的作用域是「凭据族 × principal」)。
60
+ 升级窗里对一条升级前已发出的消息重发,会建一条新 task;窗外零影响。不带 principal 的第二套服务凭据发同一条消息得到它自己的 task(与 7.124.x 同)。
61
+ - **A2A 的非 202 收据按码分句**(JSON-RPC 码仍是 `-32602`):7.124.x 把任何非 202 收据都说成「会话正在跑」;现在 `409 conflict.session_active_run`
62
+ 与 `409 conflict.run_exists` 各一句、句中点名状态与码,其余原样点名状态与码。按 `error.message` 文本匹配的对端改按码读。
63
+ - **存量 `task_run` 表(MySQL 协议 / PostgreSQL)要加一列,两条路任选其一**。本仓不发改列语句:没加列就起服 ⇒ **拒启**,
64
+ 与第 5 条 `session_meta.pinned` 同一拒句形 —— `config.schema_mismatch`,拒句点名 `task_run` 缺 `idem_key` 并给出那一句
65
+ `ALTER TABLE task_run ADD COLUMN …;`(起服的唯一索引补建遇到「列不在」不建、交给这道列册对账,不等开库等待窗)。
66
+ ① **原地加列(保留 run 历史,推荐)**:先停写或在维护窗内,对库执行
67
+ `ALTER TABLE task_run ADD COLUMN idem_key VARCHAR(64) NULL;`(PostgreSQL:`ALTER TABLE task_run ADD COLUMN idem_key VARCHAR(64) COLLATE "C";`),
68
+ 再起本版 —— 唯一索引 `uq_task_run_idem` 由起服的增量索引补建(存量行全是 NULL,建索引不会撞键)。**先升库、再升副本**:
69
+ 加了列的库上还在跑的旧副本会被对账判成「库比代码新」而拒启,所以滚动窗里新旧副本不要连同一个库。
70
+ ② **重建**:升级前导出要保留的 run 历史 → 删 `task_run`(`DROP TABLE task_run;`)→ 起本版(按新形重建)→ 导回;或接受丢掉 run 历史只做中间三步。
71
+ `DB_BACKEND=local` 零动作:键随 `run.json` 落盘,旧文件读作「没有键」。
72
+ - **旧键失效一次**:leader run 与镜像烘焙的键值形变了(作用域多了资源族一位、身份位带种类字;烘焙的键从明文改成定宽 hash),升级前写下的行**按新键不再命中** ——
73
+ 升级窗里(含新旧副本同时受理的滚动窗)对一条升级前已提交的 leader run / 烘焙用同一个键重投,会建一条新的。run 的 `Idempotency-Key` 在 7.124.x 只活在进程内存里
74
+ (升级重启即丢),没有可失效的持久键。窗外零影响;要零重复,升级前停写或等在飞提交落定、回执到手。
75
+ - **同步 / 流式腿跨进程重放的新形**:`POST /v1/tasks` 同键命中一条已有的 run(换了进程 / 副本 / 重启,或原请求已落定)⇒
76
+ `200 { …GET /v1/runs/:id 的读面, replay: true }`,不再「当新提交再跑一遍」;`POST /v1/tasks/stream` 同形(`x-task-id` 头 + `meta` 帧,
77
+ 终帧 `done { result: 同一份体, replay: true }`)。重放体不是首答的形(首答是引擎结果):按 `replay === true` 分流、结果读 `result`;
78
+ 重放体上没有 `wiringManifest` / `notice`。7.124.0 及以前,同一进程 15 分钟内的同键重试回缓存的首答体。
79
+ - **同一个键先走 `/v1/tasks/stream` 再走 `/v1/runs`** 回的是那条 run 的 202 收据(修前是前者缓存的引擎结果);同一个原始键在 `run` 与
80
+ `leader` / `bake` 之间互不命中。
81
+ - **失败也占键**:一条做过工作之后失败(或被取消、停在审批上)的 run,同键再投回的就是它 —— 7.124.0 及以前流式腿只缓存成功的那一次,失败后同键重试会重跑。
82
+ 要重跑就换一把新键。例外:服务端在做任何工作之前就拒掉的提交不占键,同键重发按新请求受理 —— 零 turn、零 token 的 prepare 期拒
83
+ (三条提交腿同一只判据:同步腿答 4xx / 带 `Retry-After` 的 503,流式 / durable 腿答 2xx、终局带同一个码)与 device 放置拒。
84
+ - **没有行的提交只剩在飞合流**:部署没有 run 店(内存形)—— 落定之后同键重试**会再跑一遍**(7.124.0 及以前同一进程 15 分钟内回缓存体)。
85
+ 要持久幂等:配 run 店(`DB_BACKEND=mysql|pg|local`)。`/v1/tasks` · `/v1/tasks/stream` 不带 `sessionId` 也建行(授权口为这一轮现铸一只会话 id),同键重放照常成立。
86
+ - **新读口**:`GET /v1/runs?idempotencyKey=<原始键>`(本人 200 = 读面;没有 / 别人的 ⇒ 同一个 `404 not_found.run`)、
87
+ `GET /v1/leader?idempotencyKey=<原始键>`(同形,`404 not_found.leader_run`)—— 崩溃重启后先查这把键建过什么,再决定要不要重投。
88
+ - **`409 conflict.run_exists` 的句子变了**(码不变):`a run already exists for this submission's taskId or Idempotency-Key`。按码分流的消费方零影响。
89
+ - `POST /v1/runs/:id/steer` / `/interrupt` 的 `Idempotency-Key` 收成引擎 `inputId` 的派生值变了(同族同一只铸法);升级窗里对同一条转向用同一个键重发,
90
+ 会被当成一条新输入。
91
+
92
+ 9. **k8s 车道:`/workspace` 跨轮保留(opt-in;缺省不变 = 每轮为空)**。升级零动作:`REMOTE_EXEC=k8s` 的部署行为不变(`WORKSPACE_PERSIST` 缺省 `off`)。
93
+ 要开:设 `WORKSPACE_PERSIST=space`,且 `MINIO_ENDPOINT` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` 三键齐、会话店读得出会话住客(local / SQL 会话店)—— 每条 run 结束把 `/workspace` 快照进对象存储
94
+ (按会话住客分空间:同一会话共用,会话删掉后同一 id 重新登记 = 新空间、从空目录起;每空间留最近 3 份),同一会话的下一条 run 开始时恢复最近一份。
95
+ - **开着时的残窗**:提交标记本身的写入也可能晚于引擎 15 秒的拆环境上界落地 ⇒ 紧接着的下一条 run 恢复的是上上份,随后上一条 run 对 `/workspace` 的改动被下一条的保存覆盖、不报错。
96
+ 提交围栏落到 server 侧持久态之后这一窗才关(届时缺省改回开,另有 MIGRATION 条目);在那之前开它 = 知情承担这一窗(USAGE §9.3b)。
97
+ - **开之前核一件事**:这组 MinIO 凭据能读写 `WORKSPACE_SNAPSHOT_BUCKET`(缺省 `workspaces`)桶,且 **server 进程**够得着 `MINIO_ENDPOINT`。开着时读 `latest` 失败,run 连沙箱失败
98
+ (响亮,不给空目录)。
99
+ - **新旋钮拒启**:`WORKSPACE_PERSIST` 只收 `space` / `off`(缺省 `off`);`WORKSPACE_SNAPSHOT_KEEP` 只收 `[1, 50]` 的整数;设 `space`(且配了存储)而会话店是内存店(没有会话住客读口)⇒ 拒启,拒句点名出路(换 local / SQL 会话店,或去掉 `space`)。
100
+ - **新日志**(都只在开了 `space` 时):`workspace_persist_unavailable`(起服,没配存储)· `workspace_space_unavailable`(本副本读不到会话住客 ⇒ 这条 run 既不恢复也不保存)·
101
+ `workspace_snapshot_failed`(run 末保存失败)· `workspace_snapshot_prune_failed`(删旧失败)· `workspace_commit_abandoned` / `workspace_commit_marker_stuck`(提交标记)。
102
+ 日志里点名空间的字段是 `space` = 会话化身的前 8 位。
103
+ - **新 wire(additive)**:能力位 `workspaceSync: { persist, keep }`(k8s 车道 ∧ 存储在场才有;缺省 `persist: "off"`,只在 `"space"` 时渲「文件跨轮保留」);舰队流终态 task 帧残局键 `workspaceSnapshot: "failed"`(只承载本 run 在终态帧之前落定的保存结局)。
104
+ - **存储**:会话删除不连带删快照对象(删后前一位住客的空间不再可达)—— 给桶配生命周期规则兜底,规则对**整个 `<前缀>/`** 按龄过期(跨轮保留在 `<前缀>/<化身>/space/`、停驻快照在 `<前缀>/<会话 id>/`,`latest` 指针与快照同一条);只清 `.tar.gz` 会留下指向空处的 `latest`,该空间的 run 连沙箱失败(响亮)。
105
+
106
+ 10. **`settings.hooks` 的 matcher:含正则字符的 matcher 不再锚定(行为变更;读法与壳同形,选中集合只增不减)**。此前服务端把含正则字符的 matcher
107
+ 整体包成「整名全匹配」的正则;壳(本地执行同一份 hooks 的一侧)按契约包的读法、**不锚定**。hook 条目 `if` 条件里的名字部分走同一只读法 —— 那里的正则同样不再锚定。同一只正则形 matcher 在两端选中的工具不同:
108
+ `Edi.` 在服务端只选中 `Edit`,在壳还选中 `MultiEdit` / `NotebookEdit`。从本版起服务端与壳**问同一只函数**(`@sema-agent/settings-schema` 的 `hookMatcherSelects`),
109
+ 本仓不留副本:只含名字字符(`[A-Za-z0-9_-]`)、`|`、`,`、空格的 matcher 仍按名字表成员判(读法一字未动);空 / `*` / 缺省仍选中一切;**其余整体当 JS 正则、不锚定**。
110
+ - *谁受影响*:matcher 含 `.` `*` `(` `[` `^` `$` 等正则字符、且依赖「整名全匹配」的部署。*怎么改*:要钉一个名字写 `^Edit$`;只要几个名字写名字表 `Edit|Write`
111
+ (成员判,不受影响);**排除式要带 `^`**:`^(?!Bash).*` 照旧只排除 Bash,而不带 `^` 的 `(?!Bash).*` 现在选中一切(包括 Bash)。*不改会怎样*:升级后这些 matcher 会**多**选中工具 ——
112
+ PreToolUse 的 deny 钩子拦到更多调用、PostToolUse 钩子对更多工具触发;已选中的工具一个不会少。
113
+ - **受理面同一只读法**:fresh 提交的「matcher 选中工具别名、却选不中它的正名 ⇒ 400」判据与运行期问同一只函数,所以「死 matcher」的集合随之变化 ——
114
+ 只有不锚定才选中别名的 matcher(如 `ListPee.`)现在在 fresh 提交上 400(点名别名与正名,改成正名即可);反过来,只有不锚定才选中正名的写法现在不再被拒。耐久 resume 重放仍不判。
115
+ - **编不过的正则**:以前包一层锚定后恰好能编过的少数串(如 `a)(b`)现在与壳一样编不过 ⇒ 该组整组不跑 + 一行 `hook_matcher_invalid`(此前这类串能跑)。编不过本来就是整组不跑,形不变。
116
+ - 受理面对正则形 matcher 的 100 ms 求值预算、超长不读(8 192 字符)、「未判」登记(`server.hooks.matcher-unjudged`)均不变。
117
+
118
+ 11. **本地 `config.d` 的起服一次性迁移也改写 `remote-exec.json`**(行为变更,升级零动作)。本地腿(`CONFIG_PROVIDER=local`)起服前的一次性迁移覆盖面加上车道文件
119
+ `config.d/remote-exec.json`:k8s `apiUrl` / `s3Snapshot.endpoint` 带 URL fragment(`#…`)的旧形起服即剥掉 fragment 写回(与命令行
120
+ `npx @sema-agent/settings-schema migrate <config.d directory>` 同一只迁移与写器,字节相同),日志 `config_dir_migrated` 的收据点名文档 `remote-exec`。此前这一形起服拒启、只能手跑命令行。
121
+ - *谁受影响*:`remote-exec.json` 端点带 `#` 的本地腿部署(升级后起得来了);`remote-exec.json` 是空 / 坏 JSON 的部署 —— 先多一行 `config_dir_migration_failed`
122
+ (一字不写),随后照旧在车道装配处拒启(与此前同一处)。*怎么做*:升级前备份 `config.d`(写回是原子的,写过的文件留在新形上);坏文件修好或删掉。
123
+ - *回滚*:迁过的 `remote-exec.json` 只是少了 fragment,旧版引擎照读。
124
+
125
+ 12. **会话口的 id 形门放宽成一只(行为变更,拒句变)**:`POST /v1/sessions/:id/fork`、`DELETE /v1/sessions/:id`、`/v1/sessions/:id/policy` 对调用方给的 `:id`
126
+ 与 2c 会话同步同一只形判 —— 任意版本的 canonical uuid 过门(此前只收 uuidv7;壳预设的 v4 会话 id 建得出、同步得了,却 fork / 抹除 / 设策略不了)。
127
+ - *谁受影响*:按「v4 id ⇒ 400」写了分支的客户端(现在 200);按 400 句子文本匹配的客户端 —— 句子改为 `invalid session id (must be a canonical uuid)`(码仍是 `request.id_invalid`)。
128
+ 非 uuid 的任意串(`myproj` 之类)仍 400。`GET /v1/sessions?before=` 游标与 `body.taskId` 仍要求 uuidv7(同码,句子各自点名)。*怎么改*:按 `errorCode` 分支,句子只作展示。
129
+
130
+ 13. **客户端可见自由文本的脱敏产物回到原文(行为变更)**:凭据跨度外的格式字符(零宽字符、ZWJ 表情、双向标记、ZWNJ)逐字保留,只有认出的凭据被换成记号。
131
+ 此前脱敏吐出的是剥掉全部格式字符的文本 —— 方向控制符与零宽字符**重新出现**在这些面上:审批帧 / 卡的 `args` 与预览、终局 `terminal.message` 与 blocked 臂的
132
+ `detail.hookReason`、`engine_notice` 的 `message` / `detail` 规则文本、`riskDescriptor.probeReason` 与 live 审批帧的 `probeReason`、live `tool_start` / `tool_end` 叶、trace 自由文本。
133
+ - *谁受影响*:把这些串原样渲进终端 / 卡面的消费端(壳的审批卡、web 卡面),或按「这些面上恒无格式字符」写了规则的消费端。
134
+ - *怎么改*:显示前中和不可见字符与方向控制符(中和归渲染端;不可信文本过自己的转义)。审批的方向符在场位(`inputHasBidi` / `hasBidiControls`)照旧披露,可据此标出风险。
135
+
136
+ 14. **流式腿同进程在飞合流的跟随方:首发被拒时终帧 `done.result` 换形(行为变更)**:同一进程里同键并发的流式提交,首发被拒(会话占用 `409 conflict.session_active_run`、
137
+ `409 conflict.run_exists`、device 放置拒、同步腿零工作拒)时,跟随方的终帧 `done.result` 此前是首发 JSON 拒绝体原样(`error` / `errorCode`,无 `status` / `errorMessage`),
138
+ 现在与首发同一只投影 —— 会话占用 ⇒ 冲突臂,其余 ⇒ 拒绝三键 `{ status: "failed", errorCode, errorMessage }`。
139
+ - *谁受影响*:读合流拒绝帧里 `error` 键的消费方(现在是 `errorMessage`)。*怎么改*:按 `errorCode` 判、`errorMessage` 读句子;回落读 `error` 的分支可以删。
140
+
141
+ 15. **`GET /v1/fleet/stream?session=<会话 id>` 的过滤键改成发起会话 id(行为变更)**:task 行的会话过滤键 = 发起这条 run 的会话 id(此前是 run 的 `taskId`,
142
+ 于是带会话 id 订阅时主 run 行与前台子代行全被滤掉、整轮只有 `meta` 与空 `snapshot`)。没有会话的 run 行不打键(同 principal 的会话订阅方仍看得见,内容降权)。
143
+ - *谁受影响*:把 run 的 `taskId` 当 `?session=` 值订阅的消费方 —— 现在只认会话 id(契约一直写的是会话 id)。*怎么改*:订阅改用会话 id;`POST /v1/runs` 没带会话 id 时用 202 体里回的 `sessionId`。
144
+
145
+ ## 7.124.1 —— 脱敏全臂 `sk-` 值下限 16 → 20 + 规范 UUID 负谓词(行为修复,升级零动作)· 随包文档三处文字订正(行为不变)
146
+
147
+ 0. **脱敏行为修复,无升级动作**:后台 bash 的 `output_file` 路径不再被脱成假路径;成文的已知局限 —— 部署方自定义的 13–19 位或恰为 UUID 的 `sk-` 钥紧贴字母数字写时不再被脱(词头位置照脱);细节见 CHANGELOG 7.124.1。
12
148
  1. **订正 7.124.0 节「升级前必读」一句**(文字订正,行为不变):那句写决断口 409「新增一码」—— 7.124.0 **没有**新增任何决断口 409 码(复审中整退);决断口的变化只有:`field:"answer"` 的 409 由 `approval_binding_mismatch` 挪到 `resume_outcome_invalid`,以及 `409 checkpoint.unsupported_version` 体多出 additive 的 `writerVersion` / `readerVersion` 两键。第 14 条正文是对的,以第 14 条为准。
13
149
  2. **订正契约 §0.4 一句**(文字订正,行为不变):扁平布尔对 `draining` / `drainingSince` 在 7.124.0 **仍照发**,删发随提货 settings-schema 12.0.0 的那一版(届时发车帖点名);消费方现在就可改读 `engineSignal.draining`。
14
150
  3. **订正 7.124.0 节第 14 条的 `field:"answer"` 一句**(文字订正,行为不变):「`field:"answer"` 改发 `resume_outcome_invalid`」描述的是本服务对 core 闭集词 `answer` 的**映射表项**,不是 7.124.0 决断口上可观察到的行为变化 —— 在 core 7.48.0 下,引擎铸 `field:"answer"` 的三种形(答案不是规范数组 / 答案配在 deny 或非问题卡上 / 批准问题卡却无答案)在 `POST /v1/approvals/:sessionId/decide` 上都先被本服务的请求/状态守卫以 `400 request.field_conflict` 或 `400 request.body_shape` 拒掉,普通停驻卡上**没有**一种请求能拿到那条 409;唯一能让引擎抛出它的是 parked 子代行上「deny 带 answer / 非问题卡带 answer」,而那条腿把它作 `422 parked_resume.startup_failed` 透传(引擎原句,无 `field`)。消费端不需要为这一句改任何码;两腿投影不一致本身已登记为待收口项。
package/USAGE.md CHANGED
@@ -203,7 +203,7 @@ WEB_SEARCH_PROBE_ON_BOOT=true # boot 期一次性真出网探活,
203
203
  # 每任务成本/token 上限(运维 CEILING)。调用方可在 body 传更小的 maxCostUsd/maxTokens,但会被夹到这个上限以下。
204
204
  MAX_TASK_COST_USD=0.50 MAX_TASK_TOKENS=200000 # 0/不设 = 不限。超限 → 任务 failed + errorCode limits.max_{tokens,cost,turns,walltime}_exceeded
205
205
  # 每 principal 跨任务累计成本配额(固定桶窗,非滑动窗)。某人桶内累计花费超顶 → 下一个任务被拒(429 + retry-after)。
206
- MAX_PRINCIPAL_COST_USD=5.00 COST_QUOTA_WINDOW_SEC=86400 # 0/不设 = 不限;默认窗口 1 天
206
+ MAX_PRINCIPAL_COST_USD=5.00 COST_QUOTA_WINDOW_SEC=86400 # 额度 0/不设 = 不限;窗长 1 秒 ~ 1e10 秒(域外拒启),默认 1 天
207
207
  # ⚠️ 上面两组都是 **per-SLICE / per-任务** 的窗:一次 park→resume(审批、plan_review、抢占、预算切片)
208
208
  # 会给引擎**重开一个满窗**。要给「整条 park/resume 链」封顶,用下面这组**跨片总额**(需 RESOURCE_SUSPEND=true)。
209
209
  RESOURCE_SUSPEND_TOTAL_TOKENS=750000 # 整条链累计 token 总额;0/不设 = 不限
@@ -256,7 +256,7 @@ MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;词
256
256
  | 旋钮 | 谁触发、什么时刻判 | 拒/停的形 | 能打断在跑的 run 吗 |
257
257
  |------|------------------|-----------|--------------------|
258
258
  | `MAX_TASK_COST_USD` | 引擎(core)按**这一片任务**的累计花费判:首个模型调用前的 pre-call 估算 + turn 结束回查;**未开** `RESOURCE_SUSPEND` 时另有流中途投影取消(开了 resource-suspend 这一臂自动关,只在边界判) | 未开/不合格 ⇒ 终态 `failed` + `errorCode: limits.max_cost_exceeded`(HTTP 仍 200 / SSE `done` 帧带码);**开了 resource-suspend 且合格 ⇒ `suspended` + `resource_limit` 卡**(可续跑,不是失败)——消费方两种终态都要认 | ✅ 能,但只封**单片**:一次 park→resume 会重开满窗(见下条「per-slice 窗 vs 跨片总额」) |
259
- | `MAX_PRINCIPAL_COST_USD`(+ `COST_QUOTA_WINDOW_SEC`) | **HTTP 提交面**在受理请求**之前**读该 principal 桶内累计。挂门的是**任务/run 族**:`/v1/tasks`、`POST /v1/runs`、steer/resume/wake、workflows、leader、a2a,以及 park 后赎回时开的续跑腿(⚠️ 例外见下:operator-only 的记忆整合口不在此列) | `429` + `errorCode: limit.cost_quota_exceeded` + `Retry-After` 头 + 体 `{usedMicroUsd, limitMicroUsd, retryAfterSec}` | ❌ **不能**。跑起来之后不再回查:越顶那张单会**跑完**,天花板从**下一次提交**起生效 |
259
+ | `MAX_PRINCIPAL_COST_USD`(+ `COST_QUOTA_WINDOW_SEC`) | **HTTP 提交面**在受理请求**之前**读该 principal 桶内累计。挂门的是**任务/run 族**:`/v1/tasks`、`POST /v1/runs`、steer/resume/wake、workflows、leader、a2a,以及 park 后赎回时开的续跑腿(⚠️ 例外见下:operator-only 的记忆整合口不在此列) | `429` + `errorCode: limit.cost_quota_exceeded` + `Retry-After` 头 + 体 `{usedMicroUsd, limitMicroUsd, retryAfterSec, windowResetsAt}` | ❌ **不能**。跑起来之后不再回查:越顶那张单会**跑完**,天花板从**下一次提交**起生效 |
260
260
  | `USAGE_WINDOWS[].maxCostUsd`(§9.2) | 引擎在**每个 turn 边界**把累计花费记进跨任务账本再回查窗;提交面另有同店同键的快速门 | 基建合格 ⇒ `suspended` + `checkpointGate.reason=usage_window` + `resumeAfterMs`(等窗放行原样续跑);不合格 ⇒ `failed` + `errorCode: usage.window_exhausted` + `retryAfterMs`;提交面 ⇒ `429 usage.window_exhausted` | ✅ 能,且是唯一按**跨任务累计**花费中途停的一根(第一根按的是这一片自己的额度,管不到租户总账) |
261
261
 
262
262
  - **怎么选**:想给**单张任务**封顶 → `MAX_TASK_COST_USD`;想给**某租户按天/周限额、超了就不许再提新单** →
@@ -271,13 +271,14 @@ MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;词
271
271
  (前置:模型**可计价**,否则任务门口就拒,见 §9.2)。
272
272
  - 记账口径:`MAX_PRINCIPAL_COST_USD` 的累计来自权威的整数 `costMicroUsd`(覆盖主任务 + 异步/council
273
273
  子任务,按 principal 归因);单副本部署是进程内计数,配了 SQL 后端则跨副本共享。
274
- - ⏱️ **窗形是「固定桶」不是滑动窗**(别按滑动窗估保护强度):内存腿从该 principal 的**首次记账**开锚,
275
- 满 `COST_QUOTA_WINDOW_SEC` 后整桶清零重开;SQL 腿(跨副本)用**对齐桶**(桶号 = `floor(now / 窗长)`),
276
- 桶界随时钟对齐。两种形都在**桶边界**清零 ⇒ 紧贴边界前后的两个桶可以在很短时间内合计花掉近两倍额度。
274
+ - ⏱️ **窗形是「时钟对齐的固定桶」不是滑动窗**(别按滑动窗估保护强度):桶号 = `floor(now / 窗长)`,
275
+ 桶界按 Unix 纪元对齐(日窗 `86400` 在 UTC 零点滚),与该 principal 何时第一次花钱无关 —— 单副本内存计数与跨副本 SQL 共享计数是同一个窗形。
276
+ 当代桶的窗末在 `GET /v1/usage` 体的 `windowResetsAt`(ISO 8601)里,越顶 429 的体里也带同一个值。
277
+ 窗在**桶边界**清零 ⇒ 紧贴边界前后的两个桶可以在很短时间内合计花掉近两倍额度。
277
278
  ⚠️ **别用「把窗调短」当护栏**:额度不变而缩窗,边界突发仍是近两倍额度,长期允许速率反而抬高
278
279
  (每天 $5 改成每小时 $5,一天就成了约 $120)。要压边界突发,窗与额度**按目标速率同比调小**,
279
280
  或改用治理窗——`USAGE_WINDOWS` 的 `rolling` anchor 才是真滑动窗。
280
- ⚠️ 换窗长 = 换记账周期,而且**改后重启**才生效:内存腿换窗长即清当代累计;SQL 腿的桶号由
281
+ ⚠️ 换窗长 = 换记账周期,而且**改后重启**才生效:内存腿的计数随重启清零,新窗长从零记起;SQL 腿的桶号由
281
282
  `floor(now / 窗长)` 算出,新窗长**通常**落到不同桶号(= 从零记起),但两个相近的窗长在当下时刻
282
283
  可能算出**同一个**桶号,那一次换窗就会继续沿用旧桶的累计。要确保从零记起,换窗长时顺手清一次
283
284
  `cost_quota` 表里该 principal 的旧桶行。
@@ -316,7 +317,7 @@ MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;词
316
317
  best-effort,与 `msSinceLastActivity` 同族)/ 账本读不可用,四种都是缺席;后者计数在
317
318
  `fail_open_total{tag="server.runs.cross-slice-usage-unavailable"}`;③ 它是**给人看的预警面**,真正执法的
318
319
  是引擎账本 —— 别拿它做自动重试/取消的机器判据。
319
- - **per-principal 累计配额**:用 `AsyncLocalStorage` 把 principal 透传到 cost tracer,所以**council/team 子任务的花费也算到发起人头上**。**配了 `SESSION_BACKEND=mysql` 时自动跨副本共享**(`cost_quota` 表,写后聚合的**原子自增** `micro=micro+delta`,对齐固定窗,最终一致——多副本花费 SUM 到一起、不丢增量);否则 in-memory per-replica 固定桶窗(首次记账开锚,单副本兜底)。启动日志 `costQuota` 字段回显 `shared(mysql)`/`in-process`/`off`。跨副本是最终一致(flush 间隔内峰值可能略超,由**硬 per-task `maxCostUsd` 兜底**)。
320
+ - **per-principal 累计配额**:用 `AsyncLocalStorage` 把 principal 透传到 cost tracer,所以**council/team 子任务的花费也算到发起人头上**。**配了 `SESSION_BACKEND=mysql` 时自动跨副本共享**(`cost_quota` 表,写后聚合的**原子自增** `micro=micro+delta`,对齐固定窗,最终一致——多副本花费 SUM 到一起、不丢增量);否则 in-memory per-replica 计数(单副本兜底;窗形与 SQL 腿同一个时钟对齐桶)。启动日志 `costQuota` 字段回显 `shared(mysql)`/`in-process`/`off`。跨副本是最终一致(flush 间隔内峰值可能略超,由**硬 per-task `maxCostUsd` 兜底**)。
320
321
  - **`RATE_LIMIT_RPM` 请求限流同样自动跨副本**(`SESSION_BACKEND=mysql` 时,`rate_limit` 表,与配额共用 `WriteBehindCounter`;启动日志 `rateLimit` 回显)。注意:写后聚合 = **软限流**(边界上短暂略超 OK,适合公平/热调用方防护);要**硬合规上限**得另走 CAS/原子计数,不靠写后聚合——和断路器跨副本同款权衡。
321
322
 
322
323
  **可选 — 生成调参透传 + 退化打捞(core 1.59/1.60)**
@@ -1926,7 +1927,8 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
1926
1927
  | HTTP `403` / `404`(session/run) | 不是该 principal 的资源 | 用正确身份 |
1927
1928
  | HTTP `409`(`/v1/runs`) | 同 session 已有活跃 run | 等它完成 / 用返回的 `activeTaskId` |
1928
1929
  | HTTP `429` | 限流 | 看 `Retry-After` 退避 |
1929
- | HTTP `429` + `errorCode:"limit.cost_quota_exceeded"` | per-principal 累计成本配额越顶(`MAX_PRINCIPAL_COST_USD`;**进场门**,不打断在跑的 run) | 看 `Retry-After` / 体 `retryAfterSec` 退避;窗滚过或调高上限后放行 |
1930
+ | HTTP `429` + `errorCode:"limit.cost_quota_exceeded"` | per-principal 累计成本配额越顶(`MAX_PRINCIPAL_COST_USD`;**进场门**,不打断在跑的 run) | 看 `Retry-After` / 体 `retryAfterSec` 退避(体 `windowResetsAt` = 当代窗滚过的时刻);窗滚过或调高上限后放行 |
1931
+ | HTTP `400` + `errorCode:"request.id_invalid"` | 调用方给的 id 形不对。会话口 `:id`(fork / 抹除 / 会话策略 / 2c 同步)的形门只有一只 = 任意版本 canonical uuid(壳预设的 v4 也过门);`GET /v1/sendfile-links?before=` 游标与 `body.taskId` 幂等键要求 uuidv7(时间序 / 幂等键契约) | 按 `errorCode` 分支;句子各自点名要求的形(`must be a canonical uuid` / `must be a uuidv7 …`),只作展示、别锚文案 |
1930
1932
  | HTTP `429` + `errorCode:"usage.window_exhausted"` | 部署级治理窗耗尽(`USAGE_WINDOWS`,token 或 $ 天花板先满者) | 看响应体 `retryAfterSec` 退避;窗滑动/桶到期后放行 |
1931
1933
  | `terminal:{kind:"failed", code:"usage.window_exhausted"}` | 已受理的 run 在 **turn 边界**撞上治理窗且无法 durable 挂起 | 同上退避后重投;配好 checkpoint 基建则改为 `suspended` 等窗自动续跑 |
1932
1934
  | HTTP `501`(`/v1/runs`) | 内存模式不支持异步 | 配持久存储(`DB_BACKEND=mysql` / `pg` + 坐标;7.106.0 起给了坐标必须点名 `DB_BACKEND`,S-866 ⑤b) |
@@ -2038,6 +2040,42 @@ k8s 沙箱(pod `activeDeadlineSeconds` 真硬死线)向引擎声明 `lifetimeMs`
2038
2040
  边界尝试 suspend(reason `env_lifetime`,基建合格时)而不是被平台杀在半途;单个 >60s 的工具调用仍可能
2039
2041
  冲过该窗口,声明是尽力座不是保证。e2b 沙箱有 keepalive 滚动续期、无固定死线,不声明(行为不变)。
2040
2042
 
2043
+ ### 9.3b k8s 车道:工作区跨轮保留(opt-in,按会话住客的空间快照,7.125.0 起)
2044
+
2045
+ k8s 车道每条 run 一只新 pod(pod 寿命 = 泄漏护栏,不跨 run 复用)。**缺省不保留**(`WORKSPACE_PERSIST` 缺省 `off`:每轮空目录,与之前同形)。设 `WORKSPACE_PERSIST=space` 且配了快照存储后,`/workspace` 不再每轮为空 —— 开之前先读下面的「开着时的残窗」:
2046
+
2047
+ - **空间 = 会话的这一位住客**(会话化身:会话建行时铸的不透明随机串,删会话随行消失)。同一会话的各条 run 共享一个空间;不同会话互不可见;会话删掉后同一个 id 被重新登记(不论谁)= 新住客,从空目录开始,前一位住客的对象不再被读。前缀 `WORKSPACE_SNAPSHOT_PREFIX` 缺省 `kata`,桶 `WORKSPACE_SNAPSHOT_BUCKET` 缺省 `workspaces`。
2048
+ - **两条车道,两种分区**:
2049
+ - **跨轮保留**(本节):归档 `<前缀>/<化身>/space/<snapshotId>.tar.gz`,指针 `<前缀>/<化身>/space/latest`,提交标记 `<前缀>/<化身>/space/committing`;只有这条车道被列举、被删旧,且只在 `space/` 里。按住客分区的理由:读它的是「这只会话的下一条 run」,分区就决定了谁读得到 —— 按会话 id 分,删后重登记的另一位就会恢复前一位的文件。
2050
+ - **停驻挂起 / 续跑**(引擎驱动的 durable suspend):归档 `<前缀>/<会话 id>/<snapshotId>.tar.gz`(与 7.125.0 之前同形,存量停驻快照照常可读,零迁移);从不被列举、从不被删旧 —— 一条停驻中的 run 的快照不会被同会话后来的 run 挤掉。按会话 id 分区的理由:读它的只有那条停驻 run 自己的检查点(snapshotId 记在检查点里,删会话连检查点一起删),分区只做命名空间、不决定谁读。
2051
+ - **住客读不到** ⇒ 这条 run 的工作区既不恢复也不保存,一行 `workspace_space_unavailable`(`reason: incarnation_unknown` / `sessionId`),**不会**改按会话 id 找空间。住客由本副本受理提交时从会话店读出;内存会话店没有这只读口,不能开 `space`:起服拒(见下方失败形)。日志里点名空间只打化身的前 8 位(字段 `space`),完整化身不进日志。
2052
+ - **指针**:`space/latest`,体 = 最近一份 snapshotId。run 结束时先把 `/workspace` 打包上传(与挂起同一条车道:沙箱只拿到限时预签 URL,凭据留在 server),成功后**整体覆写** `latest`,再删 pod。下一条 run 第一次用到沙箱时读 `latest`:在 ⇒ 新 pod 上恢复那一份;不在(空间第一条 run)⇒ 空目录。
2053
+ - **保留**:每个空间只留最近 `WORKSPACE_SNAPSHOT_KEEP` 份(缺省 3),写新快照后删最老的;`latest` 指向的那份恒留。没有另外的索引表 —— 桶里列出来的就是全部。
2054
+ - **只盖 `/workspace`**:进程、内存态、`/tmp` 不跨轮(与挂起 / 续跑同一口径)。大小上界 = 快照车道的数据传输时限(适配器缺省 5 分钟);超时 = 快照失败,不截断。
2055
+ - **提交标记(下一条 run 等上一条存完)**:run 末保存的第一步是写 `space/committing`(体 = 开始提交的毫秒时间戳),存完(或失败)后删掉。引擎对「拆执行环境」这一步有 15 秒上界,更慢的上传在后台续完、run 先结束 —— 这时紧接着开始的下一条 run 连沙箱时看见标记,就**等**:每 1 秒重读一次,直到标记消失(再按新的 `latest` 恢复),最多等到标记年龄达到上传自己的时限(适配器缺省 5 分钟,同一个数)。标记年龄已过时限 = 上一条的 worker 死在提交里 ⇒ 一行 `workspace_commit_abandoned`(`space` / `ageMs`),照常恢复 `latest`(它仍指上一份**完整**快照)。提交自己也守同一个时限:从写标记算起超过时限才走到写 `latest` 的那次提交**不再推进** `latest`(按 `workspace_snapshot_failed`、`stage: pointer` 报),所以一条已按「放弃」继续的下一条 run 不会被它事后改回去。终态帧上的 `workspaceSnapshot` 只承载本 run 在终态帧之前落定的结局:保存比终态帧晚落定时这条 run 的帧不带,那次结局只留运维行,也不会挂到同一会话下一条 run 的帧上。
2056
+ - **开着时的残窗(上一条 run 的改动可能被覆盖)**:提交标记本身也是一次对象存储写,它也可能晚于引擎 15 秒的拆环境上界才落地(存储慢 / 卡顿)。这时紧接着的下一条 run 连沙箱时还看不见标记,就按当时的 `latest` —— 也就是**上上份** —— 恢复;上一条迟到的提交随后存完并推进 `latest`,而下一条 run 结束时又以它恢复的上上份为底存回去、再推进 `latest` ⇒ **上一条 run 对 `/workspace` 的改动被覆盖**,不报错。这一窗要等「提交围栏落在 server 侧持久态(释放会话占用之前由 server 写、下一条 run 连沙箱之前读)」才关;在那之前持久化保持 opt-in,开它的部署知情承担这一窗(工作区小、存储快时窗口很难踩到,但没有上界保证)。
2057
+ - **指针 / 列举 / 删除由 server 直接请求 `MINIO_ENDPOINT`**(快照字节仍是 pod ↔ MinIO 直传)。server 进程必须够得着这个端点(会话快照 blob 卸载本来就从 server 进程请求同一个端点)。
2058
+
2059
+ | 旋钮 | 缺省 | 说明 |
2060
+ |---|---|---|
2061
+ | `WORKSPACE_PERSIST` | `off` | `space` = 按会话住客的空间跨轮保留(opt-in;有上面写明的残窗;前提:会话店读得出会话住客 —— local / SQL 会话店,内存会话店设 `space` ⇒ 拒启);`off` = 每轮新建(缺省,与 7.125.0 之前同形)。词表外 ⇒ 拒启。只在 `REMOTE_EXEC=k8s` 时读 |
2062
+ | `WORKSPACE_SNAPSHOT_KEEP` | `3` | 每个空间保留的快照份数,整数 `[1, 50]`;域外 / 小数 ⇒ 拒启 |
2063
+
2064
+ **失败形(都是响亮的)**:
2065
+
2066
+ - **没配快照存储**(`MINIO_ENDPOINT` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` 缺任一)⇒ 行为与之前逐字相同,`GET /v1/capabilities` 不带 `workspaceSync`;设了 `WORKSPACE_PERSIST=space` 时另有起服一行 `workspace_persist_unavailable`(`missing` 点名缺的键),缺省 `off` 不出这一行。
2067
+ - **会话店读不出会话住客**(内存会话店:没有会话化身读口)而设了 `space`(且配了快照存储)⇒ **拒启**,拒句一行点名 `WORKSPACE_PERSIST=space`、会话店种类(与 `/health.sessionStoreLabel` 同一读法)、所需读口与可用的店。出路:换 local 会话店(`DB_BACKEND=local`)或 SQL 会话店(`DB_BACKEND=mysql|pg`,`SESSION_BACKEND` 不设 `memory`),或去掉 `WORKSPACE_PERSIST=space`(缺省 `off`)。拒启而不是降级:否则能力位报 `space`、每条 run 却一份都不存。
2068
+ - **读 `latest` 失败**(非 404 的 HTTP 错、网络错)⇒ 这条 run 连沙箱失败(`connect_failed`,工具调用报沙箱不可用),**不会**悄悄给一个空目录 —— 「以为恢复了其实没有」比失败更坏。修好存储或设 `WORKSPACE_PERSIST=off` 即恢复。
2069
+ - **`latest` 指向的快照对象不在了**(被删 / 被只盖 `.tar.gz` 的生命周期规则清掉)⇒ 同样连沙箱失败,句子点名那个 snapshotId;恢复中途失败(下载 / 解压)⇒ 同样失败,新起的 pod 当场删掉 —— 残缺目录既不给工具用,也不会在 run 末被快照回去覆盖 `latest`。出路:删掉该空间的 `latest`(下一条 run 从空目录起)或设 `WORKSPACE_PERSIST=off`。
2070
+ - **run 末提交标记写失败、上传失败或 `latest` 写失败** ⇒ 一行 `workspace_snapshot_failed`(`space` / `stage` = `marker` \| `upload` \| `pointer` / `code` / `error`,预签 URL 已脱敏),这条 run 在 `GET /v1/fleet/stream` 上的终态 task 帧带 `workspaceSnapshot: "failed"`;pod 照删;`latest` 不动 ⇒ 下一条 run 恢复的是**上一份**。
2071
+ - **删最老的失败** ⇒ 一行 `workspace_snapshot_prune_failed`,不影响本轮(空间里只是多留几份)。
2072
+ - **提交标记删不掉** ⇒ 一行 `workspace_commit_marker_stuck`;下一条 run 会等到标记过时限再按 `workspace_commit_abandoned` 继续。
2073
+ - **读提交标记失败**(非 404)或标记体不是毫秒时间戳 ⇒ 与读 `latest` 失败同形:这条 run 连沙箱失败(`connect_failed`)。
2074
+
2075
+ **能力位**:`GET /v1/capabilities.workspaceSync = { persist, keep }`,只在 k8s 车道 ∧ 配了快照存储时在场;`persist` 如实报生效值(缺省 `"off"`);报 `"space"` 的部署,会话店一定读得出住客(读不出的组合起服即拒)。壳 / 中心只在 `persist:"space"` 时渲「工作文件跨轮保留」。
2076
+
2077
+ **须知**:开了 `space` 之后,把 `/workspace` 当一次性临时盘用的调用方,下一条 run 会看到上一条留下的文件;回到每轮为空 = 去掉 `WORKSPACE_PERSIST=space`(或设 `off`)。存储增长 = 每个空间 `KEEP` 份 tar;会话删除不会同步删这些对象 —— 删后前一位住客的空间不再可达(新住客是新的化身),对象留到桶的生命周期规则清掉:规则要对**整个 `<前缀>/`** 按龄过期(跨轮保留在 `<前缀>/<化身>/space/`、停驻快照在 `<前缀>/<会话 id>/`,都在它下面;指针与快照同一条规则);只对 `.tar.gz` 配会留下指向空处的 `latest`,那个空间的 run 会连沙箱失败。
2078
+
2041
2079
  ### 9.4 auto 模式(`permissionMode:"auto"`):武装三态、本地 org deny、自查(与 core 的意图武装式合修)
2042
2080
 
2043
2081
  **背景**:此前引擎只在「组织授予」(web-admin 解出的 per-principal `runtimeCaps.autoMode === true`)时武装
package/dist/audit.d.ts CHANGED
@@ -18,7 +18,8 @@ export interface SessionAudit {
18
18
  } | null;
19
19
  /** Current model context: prior messages with older history collapsed into a summary message.
20
20
  * 🔴 **存档原形**(store 里怎么写就怎么回)—— 不是 wire 形。HTTP 读面一律过 {@link redactSessionViewForRead}(次序 = 选窗 → 脱敏 → 截断)
21
- * 再回给调用方;直接消费本视图的宿主(`buildSessionAudit` 是包根导出)自己负责同一道脱敏。 */
21
+ * 再回给调用方;直接消费本视图的宿主(`buildSessionAudit` 是包根导出)自己负责同一道脱敏。
22
+ * 唯一一处服务端添键:用户消息行的回退手柄 `taskId`(给了 {@link RewindHandles} 才投,见 {@link buildRewindHandles})。 */
22
23
  messages: unknown[];
23
24
  /** [ref]① web 观测切片 B: the session's prompt-epoch pin, core contract verbatim (core 1.304
24
25
  * `session.getPromptEpoch()` — first-class `prompt_epoch` entry, compaction restatement, [ref] anchor
@@ -31,19 +32,40 @@ export interface SessionAudit {
31
32
  activatedBy: "session_start" | "compaction" | "legacy_migration";
32
33
  };
33
34
  }
34
- /** Build the audit view from the durable log. Returns undefined if the session does not exist. */
35
- export declare function buildSessionAudit(pool: Pool, sessionId: string): Promise<SessionAudit | undefined>;
35
+ /**
36
+ * 用户消息行的**回退手柄**表:用户条目 id → 回退时交给 `resumeAt` 的那个 `event_id`(R8 用户消息锚按 run 的 taskId 键入)。
37
+ * 真源 = `resume_anchor`(锚店 `listBySession` 的读数),本表只是它对**一位读者**的一次投影。
38
+ */
39
+ export type RewindHandles = ReadonlyMap<string, string>;
40
+ /** 会话视图读口(`ServiceDeps.sessionAudit`)的读者参数:`reader` = 读这张视图的**已验身份**(无身份 = `null`)。给了 ⇒ 用户消息行投回退手柄。 */
41
+ export interface SessionAuditReadOpts {
42
+ readonly reader: string | null;
43
+ }
44
+ /**
45
+ * {@link RewindHandles} 的唯一铸点。一条规则:**一只用户条目带手柄,当且仅当锚店里恰好一行 `entry_id` = 它、`owner` = 读者** ——
46
+ * 这正是 `resumeAt` 解析的谓词(`resolve(sessionId, eventId, owner)`:同会话、同 event、属主 null-safe 相等),所以行上出现的手柄
47
+ * 读者拿去回退一定解得出;别人的锚(混属主会话)不投,同一条目被两行锚点名不挑(两只都缺席,不猜)。老条目没有锚 ⇒ 缺席。
48
+ * E18 助手文本锚(按 live eventId 键入,指向助手条目)也在锚店里 —— 它们只会点名助手条目,投影只看用户行,所以不上 wire。
49
+ */
50
+ export declare function buildRewindHandles(anchors: ReadonlyArray<{
51
+ readonly eventId: string;
52
+ readonly entryId: string;
53
+ readonly owner: string | null;
54
+ }>, reader: string | null): RewindHandles;
55
+ /** Build the audit view from the durable log. Returns undefined if the session does not exist. `handles` = the reader's
56
+ * rewind-handle projection ({@link buildRewindHandles}); absent ⇒ no `taskId` keys. */
57
+ export declare function buildSessionAudit(pool: Pool, sessionId: string, handles?: RewindHandles): Promise<SessionAudit | undefined>;
36
58
  /** The PG twin — same view over PgSessionStorage.wake. Until this, the
37
59
  * audit face was TiDB-only and a PG/local deployment 404'd the whole web session area. local = follow-on
38
60
  * (its storage seam has no wake-equivalent yet — may turn out to be a core gap). */
39
- export declare function buildSessionAuditPg(pool: PgPool, sessionId: string): Promise<SessionAudit | undefined>;
61
+ export declare function buildSessionAuditPg(pool: PgPool, sessionId: string, handles?: RewindHandles): Promise<SessionAudit | undefined>;
40
62
  /** The LOCAL (File) leg — web session area on DB_BACKEND=local.
41
63
  * repo 取自 `createLocalSessionRepo`(与 `LocalSessionStore` 同一只工厂):坏记录的披露座(F 类 `server.session-repo.corrupt-record-skipped`)
42
64
  * 因此在视图口同样在场 —— 视图照常返回、跳过的记录有计数 + 探针行(修前自建一只不带座的 repo,零披露)。
43
65
  * 实现姿势:`FileSessionRepo.exportEntries`(纯读、torn-tail 恢复、missing→not_found)→
44
66
  * `boundedTail`(纯函数切 floor)→ InMemorySessionStorage → 同一 auditFromStorage 内核。零写面、
45
67
  * 零新 seam;createdAt 从 repo.list() 的 metadata 取(File 形态 meta 与 entries 分储)。 */
46
- export declare function buildSessionAuditLocal(root: string, sessionId: string): Promise<SessionAudit | undefined>;
68
+ export declare function buildSessionAuditLocal(root: string, sessionId: string, handles?: RewindHandles): Promise<SessionAudit | undefined>;
47
69
  /**
48
70
  * 🔴 **会话视图的读面口 —— `GET /v1/sessions/:id` 的全部取法(整读 / `?tail` / `?before` / `?blobCap` / `?message=N`)共用这一只**。
49
71
  * 次序 = **选 → 脱 → 截**(路由 `http/routes/sessions.ts`):整条消息的选窗不切字符串,先选窗不改任何一条的脱敏读数、只把脱敏的量降到选中的那几条;
package/dist/audit.js CHANGED
@@ -5,13 +5,26 @@ import { createLocalSessionRepo } from "./plugins/local-session-store.js";
5
5
  import { redactModelText } from "./observability/run-terminal.js";
6
6
  import { redactDeepWith } from "./trace/redact.js";
7
7
  import { INJECTED_BLOCK_RE } from "./user-authored-text.js";
8
- export async function buildSessionAudit(pool, sessionId) {
9
- return auditFromStorage(await TiDBSessionStorage.wake(pool, sessionId), sessionId);
8
+ export function buildRewindHandles(anchors, reader) {
9
+ const byEntry = new Map();
10
+ for (const a of anchors) {
11
+ if ((a.owner ?? null) !== reader)
12
+ continue;
13
+ byEntry.set(a.entryId, byEntry.has(a.entryId) && byEntry.get(a.entryId) !== a.eventId ? null : a.eventId);
14
+ }
15
+ const out = new Map();
16
+ for (const [entryId, eventId] of byEntry)
17
+ if (eventId !== null)
18
+ out.set(entryId, eventId);
19
+ return out;
20
+ }
21
+ export async function buildSessionAudit(pool, sessionId, handles) {
22
+ return auditFromStorage(await TiDBSessionStorage.wake(pool, sessionId), sessionId, handles);
10
23
  }
11
- export async function buildSessionAuditPg(pool, sessionId) {
12
- return auditFromStorage(await PgSessionStorage.wake(pool, sessionId), sessionId);
24
+ export async function buildSessionAuditPg(pool, sessionId, handles) {
25
+ return auditFromStorage(await PgSessionStorage.wake(pool, sessionId), sessionId, handles);
13
26
  }
14
- export async function buildSessionAuditLocal(root, sessionId) {
27
+ export async function buildSessionAuditLocal(root, sessionId, handles) {
15
28
  const repo = createLocalSessionRepo(root);
16
29
  let entries;
17
30
  try {
@@ -25,14 +38,15 @@ export async function buildSessionAuditLocal(root, sessionId) {
25
38
  const meta = (await repo.list()).find((m) => m.id === sessionId);
26
39
  const { tail, floorEntryId } = boundedTail(entries);
27
40
  const storage = new InMemorySessionStorage({ entries: tail, floorEntryId, ...(meta ? { metadata: meta } : {}) });
28
- return auditFromStorage(storage, sessionId);
41
+ return auditFromStorage(storage, sessionId, handles);
29
42
  }
30
- async function auditFromStorage(storage, sessionId) {
43
+ async function auditFromStorage(storage, sessionId, handles) {
31
44
  if (!storage)
32
45
  return undefined;
33
46
  const session = new StoredSession(storage);
34
47
  const meta = await session.getMetadata();
35
48
  const ctx = await session.buildContext();
49
+ const messages = withRewindHandles(ctx.messages, handles !== undefined && handles.size > 0 ? await session.getEntries() : [], handles ?? NO_HANDLES);
36
50
  const promptEpoch = await session.getPromptEpoch();
37
51
  return {
38
52
  sessionId,
@@ -40,10 +54,29 @@ async function auditFromStorage(storage, sessionId) {
40
54
  floorEntryId: storage.getFloorId(),
41
55
  thinkingLevel: ctx.thinkingLevel,
42
56
  model: ctx.model,
43
- messages: ctx.messages,
57
+ messages,
44
58
  ...(promptEpoch ? { promptEpoch } : {}),
45
59
  };
46
60
  }
61
+ const NO_HANDLES = new Map();
62
+ function withRewindHandles(messages, entries, handles) {
63
+ const entryIdOf = new Map();
64
+ for (const e of entries)
65
+ if (e.type === "message" && e.message !== undefined)
66
+ entryIdOf.set(e.message, e.id);
67
+ const out = messages.map((m) => {
68
+ if (m.role !== "user")
69
+ return m;
70
+ const entryId = entryIdOf.get(m);
71
+ const taskId = entryId === undefined ? undefined : handles.get(entryId);
72
+ const archived = Object.hasOwn(m, "taskId");
73
+ if (!archived && taskId === undefined)
74
+ return m;
75
+ const { taskId: _archived, ...rest } = m;
76
+ return (taskId === undefined ? rest : { ...rest, taskId });
77
+ });
78
+ return out.every((x, i) => x === messages[i]) ? messages : out;
79
+ }
47
80
  const deepScrub = (v) => redactDeepWith(v, redactModelText);
48
81
  const textSlot = (v) => (typeof v === "string" ? redactModelText(v) : deepScrub(v));
49
82
  function readLeaf(treatment, v) {
@@ -94,6 +127,7 @@ const MESSAGE_READ_TREATMENT = {
94
127
  user: {
95
128
  role: "verbatim", content: "verbatim", timestamp: "verbatim", enginePrefixChars: "verbatim", engineSegments: "verbatim",
96
129
  engineMinted: "verbatim", engineNoteKey: "verbatim", provenance: "verbatim", actor: "verbatim",
130
+ taskId: "verbatim",
97
131
  },
98
132
  custom: { role: "verbatim", customType: "verbatim", content: "verbatim", display: "verbatim", details: "verbatim", provenance: "verbatim", timestamp: "verbatim" },
99
133
  assistant: {
@@ -7,8 +7,11 @@ import { PerTaskImageRegistry } from "../per-task-image.js";
7
7
  import type { DeviceEnrollment } from "../device-enrollment.js";
8
8
  import type { DeviceStore } from "../device-store.js";
9
9
  import type { DeviceWsHub } from "../device-ws-hub.js";
10
+ import { k8sExecutionEnvFactory, type K8sEnvConfig, type K8sEnvDeps } from "../plugins/remote-env-k8s.js";
11
+ import * as facts from "../capability-facts.js";
10
12
  import { type TaskAttachmentStore } from "../plugins/task-attachment-store.js";
11
13
  import type { TaskInheritEnv } from "../request-value-closure.js";
14
+ import type { SessionFaceReader } from "../session-incarnation.js";
12
15
  /**
13
16
  * per-session 的 **cwd / shellEnv 登记簿**(TOC cwd seam + R-survey shellEnv seam)+ 它们的**租户围栏**。
14
17
  *
@@ -56,10 +59,39 @@ export interface SessionScopedRegistries {
56
59
  * 与两张内容表同一只 LRU、同一条围栏(换主即清)、同一条删除(`dropSession`)。令牌只在进程内存里,不进任何日志。
57
60
  */
58
61
  noteSessionIncarnation(sid: string, incarnation: string | undefined): void;
59
- /** 同步读装配期记下的化身(记忆座的唯一读者)。 */
62
+ /** 同步读装配期记下的化身。两位读者读同一格:记忆座(`boot/session-incarnation-seat.ts`)与 k8s 工作区空间的分区
63
+ * ({@link createK8sRunEnvFactory} —— 读不到 ⇒ 空间车道不开,**没有**按会话 id 兜底的臂)。 */
60
64
  sessionIncarnationOf(sid: string): string | undefined;
61
65
  }
62
66
  export declare function createSessionScopedRegistries(maxEntries?: number): SessionScopedRegistries;
67
+ /**
68
+ * k8s 车道的**每 run 绑定**:部署级适配器配置 × 三只 run 作用域的座(都按本 run 的 `ctx.sessionId` 取)——
69
+ * · 镜像:§7 P0.5 per-task 镜像(resolveSpec 按调用方 principal 解出、按会话登记;缺席 ⇒ 部署缺省镜像);
70
+ * · **工作区空间的分区 = 会话化身**(`spaceIncarnation`):读 {@link SessionScopedRegistries.sessionIncarnationOf} ——
71
+ * resolve-spec 每次提交经 `sessionFaceOf` 记下的**同一格**(与记忆座同一只读口,不另起第二读法)。读不到 ⇒ 不传,
72
+ * 适配器的空间车道不开(connect 不恢复、destroy 不存)并响亮一行 `workspace_space_unavailable`;**绝不**以会话 id 代替;
73
+ * · run 末保存结局的座:为这条 run 建 env 时(它 connect 之前)开一只 {@link createRunWorkspaceSnapshotSeat} —— 开座即关掉本会话
74
+ * 更早的座(上一条 run 晚于拆环境上界的结局只留运维行,不上任何帧,也不吞本 run 自己的结局);终态帧在 settle 时取一次;
75
+ * · 运维行座(`logger`)随部署级配置走。
76
+ * `deps` = 适配器自己的测试缝(假集群 / 假对象存储),生产装配不传。返回的是工厂闭包 ⇒ `create*`。
77
+ */
78
+ export declare function createK8sRunEnvFactory(k8sCfg: K8sEnvConfig, seats: {
79
+ imageOf(sessionId: string): string | undefined;
80
+ incarnationOf(sessionId: string): string | undefined;
81
+ }, deps?: K8sEnvDeps): ReturnType<typeof k8sExecutionEnvFactory>;
82
+ /**
83
+ * k8s 工作区空间的**起服前提**(装配后的真门;同段先例 = `assertDeviceLaneWired`)。法只有一条:
84
+ * **生效策略 `space`(事实 `workspaceSpace`)∧ 会话店没有化身读口 ⇒ 拒启。**
85
+ *
86
+ * 空间按会话住客分区,住客只有一只读口 —— resolve-spec 经会话店的 `sessionFaceOf`(`sessionFaceReaderOf`,与记忆座同一只;调用方传它的读数)
87
+ * 记进 {@link SessionScopedRegistries.sessionIncarnationOf},{@link createK8sRunEnvFactory} 从那一格取。会话店没有这只读口(内存会话店)
88
+ * ⇒ 每条 run 都读不到住客、一份都不存,而能力位 `workspaceSync.persist` 只读配置、照报 `"space"`。事实**保持只读配置**;让它恒真的办法
89
+ * 是拒掉这一组合,不是给事实加运行期依赖。生效策略缺席(非 k8s 车道 / 没配快照存储:能力位本就不在场,后者有起服一行
90
+ * `workspace_persist_unavailable`)或 `off` ⇒ 不判。读得到读口之后,适配器的 `unavailable` 臂只剩「这条 run 读不到住客」一形。
91
+ *
92
+ * 拒句一行,点名三件:旋钮词、会话店种类(`sessionStoreLabelOf` —— `/health.sessionStoreLabel` 同一读法)、所需读口与可用的店。
93
+ */
94
+ export declare function assertWorkspaceSpaceReadable(space: ReturnType<typeof facts.workspaceSpace>, sessionFaceRead: SessionFaceReader | undefined, sessionStoreLabel: string): void;
63
95
  /** device lane 的三件活对象(店 / 汇聚端 / 准入链)。缺席 = 本部署没有 device 车道。 */
64
96
  export interface DeviceLaneWiring {
65
97
  store: DeviceStore;
@@ -11,6 +11,7 @@ import { deviceExecutionEnvFactory } from "../plugins/remote-env-device.js";
11
11
  import { e2bExecutionEnvFactory } from "../plugins/remote-env-e2b.js";
12
12
  import { hostExecutionEnvFactory, RemoteHostExecutionEnv } from "../plugins/remote-env-host.js";
13
13
  import { k8sExecutionEnvFactory } from "../plugins/remote-env-k8s.js";
14
+ import { createRunWorkspaceSnapshotSeat } from "../fleet/workspace-snapshot-residual.js";
14
15
  import { localDockerExecutionEnvFactory } from "../plugins/remote-env-local-docker.js";
15
16
  import { sshExecutionEnvFactory } from "../plugins/remote-env-ssh.js";
16
17
  import { isRemoteScratchpadLane, withRemoteScratchpad } from "../plugins/remote-scratchpad.js";
@@ -111,6 +112,33 @@ export function createSessionScopedRegistries(maxEntries = MAX_CWD_SESSIONS) {
111
112
  sessionIncarnationOf: (sid) => perSessionIncarnation.get(sid),
112
113
  };
113
114
  }
115
+ export function createK8sRunEnvFactory(k8sCfg, seats, deps) {
116
+ return (ctx) => {
117
+ const incarnation = seats.incarnationOf(ctx.sessionId);
118
+ return k8sExecutionEnvFactory({
119
+ ...k8sCfg,
120
+ image: seats.imageOf(ctx.sessionId) ?? k8sCfg.image,
121
+ ...(incarnation !== undefined ? { spaceIncarnation: incarnation } : {}),
122
+ onWorkspaceSnapshot: createRunWorkspaceSnapshotSeat(ctx.sessionId),
123
+ }, deps)(ctx);
124
+ };
125
+ }
126
+ export function assertWorkspaceSpaceReadable(space, sessionFaceRead, sessionStoreLabel) {
127
+ if (!space.available || !persistNeedsOccupant(space.policy.persist) || sessionFaceRead !== undefined)
128
+ return;
129
+ throw new Error(`WORKSPACE_PERSIST=space but the session store is ${sessionStoreLabel}, which has no session occupant read (sessionFaceOf) [workspace-space-unreadable]: ` +
130
+ "the k8s workspace space is partitioned by the session occupant, so on this store no run would restore or save /workspace while " +
131
+ 'GET /v1/capabilities.workspaceSync reports persist "space". Use a session store with that read (DB_BACKEND=local, or DB_BACKEND=mysql|pg ' +
132
+ "with SESSION_BACKEND not memory), or drop WORKSPACE_PERSIST=space (the default is off), and restart");
133
+ }
134
+ function persistNeedsOccupant(persist) {
135
+ switch (persist) {
136
+ case "space":
137
+ return true;
138
+ case "off":
139
+ return false;
140
+ }
141
+ }
114
142
  function buildRealSystemNote(remoteExec) {
115
143
  if (!isSingleMachineDefaultLane(remoteExec))
116
144
  return "non-isolated target — actions are permanent; rely on the policy gate + HITL (design/61 §5)";
@@ -162,13 +190,14 @@ export function createExecutionEnv(ctx) {
162
190
  ...(config.remoteExec.timeoutMs != null ? { timeoutMs: config.remoteExec.timeoutMs } : {}),
163
191
  ...(config.remoteExec.memory ? { memory: config.remoteExec.memory } : {}),
164
192
  ...(config.remoteExec.cpu ? { cpu: config.remoteExec.cpu } : {}),
165
- ...(config.remoteExec.s3Snapshot ? { s3Snapshot: config.remoteExec.s3Snapshot } : {}),
193
+ ...(config.remoteExec.s3Snapshot ? { s3Snapshot: { ...config.remoteExec.s3Snapshot, ...((f) => (f.available ? { space: f.policy } : {}))(facts.workspaceSpace(config)) } } : {}),
194
+ logger,
166
195
  ...(() => {
167
196
  const derived = derivePkgSourceEnv(config.sandboxPkgSource, customPkgSourceFromEnv());
168
197
  return Object.keys(derived).length > 0 ? { podEnv: derived } : {};
169
198
  })(),
170
199
  };
171
- executionEnvFactory = (ctx) => k8sExecutionEnvFactory({ ...k8sCfg, image: perTaskImage.get(ctx.sessionId) ?? k8sCfg.image })(ctx);
200
+ executionEnvFactory = createK8sRunEnvFactory(k8sCfg, { imageOf: (sid) => perTaskImage.get(sid), incarnationOf: sessionScoped.sessionIncarnationOf });
172
201
  }
173
202
  else if (config.remoteExec?.provider === "ssh") {
174
203
  executionEnvFactory = sshExecutionEnvFactory({
@@ -32,7 +32,8 @@ import { memoryProjectKeyFor, memorySpecForRequest } from "../memory-scope.js";
32
32
  import { inheritEnvFieldIssue, singleMemberOffFieldIssue } from "../request-value-closure.js";
33
33
  import { a2aForScenario, mcpForScenario } from "../web-admin-client/facade.js";
34
34
  import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, normalizeSuggestNextPrompts, promptProfileFromBody, resolveTaskLimits, retainBackgroundProcessesFromBody, taskAgentsSpecFragment, toolMaterializeStrategyFromBody, toolNameListFromBody } from "../spec-fields.js";
35
- import { cwdHonored, deviceCwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
35
+ import { isAbsoluteHostPath } from "@sema-agent/settings-schema";
36
+ import { cwdHonored, deviceCwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
36
37
  import { assertRequestA2aUnlocked, resolveRequestA2a } from "../task-a2a.js";
37
38
  import { assertRequestMcpContentOrigin, assertRequestMcpUnlocked, resolveRequestMcp } from "../task-mcp.js";
38
39
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, taskHooksHonored, providerDropsAppend, settingsPermissionBucketIssue, withPermissionMode } from "../task-settings.js";
@@ -197,9 +198,9 @@ export function createResolveSpec(ctx) {
197
198
  if (auth?.sessionId)
198
199
  fenceSessionOwner(auth.sessionId, auth.principal ?? null);
199
200
  if (typeof body.cwd === "string" && body.cwd.length > 0 && auth?.sessionId) {
200
- if (cwdWriteGate && isValidCwd(body.cwd))
201
+ if (cwdWriteGate && isAbsoluteHostPath(body.cwd))
201
202
  setSessionCwd(auth.sessionId, body.cwd);
202
- else if (inProcessSingleUserLane(config) && isValidCwd(body.cwd) && satisfiedByProcessCwd(body.cwd, realpathSync))
203
+ else if (inProcessSingleUserLane(config) && isAbsoluteHostPath(body.cwd) && satisfiedByProcessCwd(body.cwd, realpathSync))
203
204
  logger.debug("task_cwd_inherited", { lane: execLane, sessionId: auth.sessionId });
204
205
  else
205
206
  logger.warn("task_cwd_ignored", { honored: cwdWriteGate, lane: execLane, sessionId: auth.sessionId });
@@ -1,3 +1,16 @@
1
+ /**
2
+ * [ref] A10:composition root 分段 —— session 只读/删除面(审计、watch 注册表、E21 purge 协调器、
3
+ * degenerate 仪表、plan-cache 探针)。
4
+ *
5
+ * 纯搬运:函数体逐字来自 `main.ts`(原 2967-3092 行),缩进不变;新增的只有 import 与包壳。
6
+ *
7
+ * ⚠️ **位置即契约**:
8
+ * 1. `sessionWatchRegistry` 必须在 `purgeSession` **之前**构造 —— purge 尾部的 `dropSession` 是
9
+ * 同副本租户轮转围栏(delete 即终结旧 owner 的 SSE 订阅),两者是一对。
10
+ * 2. `setLeafAdvanceListener(...)` 是**进程级单点**装载(leaf-bus 只有一个监听位),必须与
11
+ * registry 的构造同段发生,晚于它、早于任何会 append 的路由。
12
+ */
13
+ import { type SessionAuditReadOpts } from "../audit.js";
1
14
  import type { ServiceConfig } from "../config.js";
2
15
  import type { Logger } from "../observability/logger.js";
3
16
  import type { Metrics } from "../observability/metrics.js";
@@ -50,7 +63,7 @@ export interface SessionFacesCtx {
50
63
  }
51
64
  export declare function createSessionFaces(ctx: SessionFacesCtx): {
52
65
  ownerAware: OwnerAwareSessionStore;
53
- sessionAudit: ((sessionId: string) => Promise<{
66
+ sessionAudit: ((sessionId: string, opts?: SessionAuditReadOpts) => Promise<{
54
67
  owner: string | null;
55
68
  sessionId: string;
56
69
  createdAt: string;