@sema-agent/server 7.11.0 → 7.13.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 (78) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +86 -2
  3. package/dist/adoption/plan.d.ts +38 -4
  4. package/dist/adoption/plan.js +72 -0
  5. package/dist/adoption/quiesce.d.ts +70 -0
  6. package/dist/adoption/quiesce.js +148 -0
  7. package/dist/adoption/runner.js +63 -5
  8. package/dist/adoption/sql.d.ts +15 -0
  9. package/dist/adoption/sql.js +18 -0
  10. package/dist/adoption/wire.d.ts +7 -1
  11. package/dist/adoption/wire.js +6 -0
  12. package/dist/approval-card.d.ts +5 -0
  13. package/dist/approval-card.js +22 -0
  14. package/dist/auth-keys.d.ts +28 -4
  15. package/dist/auth-keys.js +60 -15
  16. package/dist/boot/parked-revive-gate.d.ts +18 -2
  17. package/dist/boot/parked-revive-gate.js +136 -14
  18. package/dist/boot/permission-rules-audit.d.ts +49 -0
  19. package/dist/boot/permission-rules-audit.js +85 -0
  20. package/dist/boot/reapers.d.ts +15 -0
  21. package/dist/boot/reapers.js +101 -44
  22. package/dist/boot/resolve-spec.js +43 -12
  23. package/dist/boot/runner-deps.d.ts +16 -2
  24. package/dist/boot/runner-deps.js +5 -4
  25. package/dist/budget.js +22 -0
  26. package/dist/config-types.d.ts +31 -11
  27. package/dist/config.d.ts +28 -2
  28. package/dist/config.js +348 -79
  29. package/dist/governance-ask-marks.js +8 -2
  30. package/dist/http/active-run-conflict.d.ts +33 -8
  31. package/dist/http/active-run-conflict.js +37 -2
  32. package/dist/http/route-ctx.d.ts +6 -3
  33. package/dist/http/routes/adoption.js +25 -2
  34. package/dist/http/routes/approvals-assistant.js +35 -4
  35. package/dist/http/routes/capabilities.js +69 -10
  36. package/dist/http/routes/images.js +18 -0
  37. package/dist/http/routes/rules.d.ts +19 -7
  38. package/dist/http/routes/rules.js +180 -4
  39. package/dist/http/routes/runs.js +21 -5
  40. package/dist/http/routes/tasks.js +18 -6
  41. package/dist/http/server.d.ts +30 -10
  42. package/dist/http/server.js +183 -19
  43. package/dist/http/wire-types.d.ts +48 -0
  44. package/dist/main.js +65 -7
  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 +2 -1
  48. package/dist/observability/tool-trace.d.ts +5 -1
  49. package/dist/observability/tool-trace.js +33 -6
  50. package/dist/parked-decide.d.ts +13 -3
  51. package/dist/parked-decide.js +10 -1
  52. package/dist/plugins/adoption-log-sql.d.ts +40 -0
  53. package/dist/plugins/adoption-log-sql.js +69 -2
  54. package/dist/plugins/file-run-store.d.ts +85 -1
  55. package/dist/plugins/file-run-store.js +450 -17
  56. package/dist/plugins/permission-rule-store-file.d.ts +83 -0
  57. package/dist/plugins/permission-rule-store-file.js +371 -0
  58. package/dist/plugins/permission-rule-store-sql.d.ts +52 -0
  59. package/dist/plugins/permission-rule-store-sql.js +71 -2
  60. package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
  61. package/dist/plugins/shared-memory-store-sql.js +55 -18
  62. package/dist/plugins/sql-driver.d.ts +19 -0
  63. package/dist/plugins/sql-driver.js +12 -0
  64. package/dist/plugins/store-backend.d.ts +12 -6
  65. package/dist/plugins/store-backend.js +82 -10
  66. package/dist/rules-consent.d.ts +98 -1
  67. package/dist/rules-consent.js +84 -1
  68. package/dist/run-local.js +126 -15
  69. package/dist/runtime-governance.d.ts +33 -0
  70. package/dist/runtime-governance.js +41 -3
  71. package/dist/task-settings.d.ts +44 -0
  72. package/dist/task-settings.js +57 -1
  73. package/dist/tool-approval.d.ts +38 -1
  74. package/dist/tool-approval.js +125 -26
  75. package/dist/trace/core-keyset-guard.d.ts +14 -3
  76. package/dist/trace/project.d.ts +19 -2
  77. package/dist/trace/project.js +24 -4
  78. package/package.json +3 -3
@@ -26,7 +26,10 @@ import { redactSecrets } from "../trace/redact.js";
26
26
  import { IdempotencyCache, scopedIdempotencyKey } from "./idempotency.js";
27
27
  export { scopedIdempotencyKey };
28
28
  import { streamSseLog } from "./sse-log.js";
29
- import { clampVerifyRounds, verifyRoundsFromBody } from "./verify-rounds.js"; // design/97 R9: the shape's neutral leaf (route-ctx.ts type-imports VerifyRoundsSpec from here, not from this file)
29
+ // design/97 R9: the shape's neutral leaf (route-ctx.ts type-imports VerifyRoundsSpec from here, not from this file).
30
+ // `clampVerifyRounds` used to be re-exported from here for the tests; that re-export was deleted (8566348) and the
31
+ // import sat on as a dead name — tests import it from `verify-rounds.js` directly, which is the single owner.
32
+ import { verifyRoundsFromBody } from "./verify-rounds.js";
30
33
  import { handleCapabilities } from "./routes/capabilities.js";
31
34
  import { handleObservability } from "./routes/observability.js";
32
35
  import { handleDiagnostics } from "./routes/diagnostics.js";
@@ -135,7 +138,14 @@ function isCheckpointReopenedFailure(r) {
135
138
  // named alias so the existing call sites read clearly; the JWT logic now lives in one place that createAuthorizer
136
139
  // shares, so spec.principal (governance + cost) and these observability/owner sites can never drift apart again.
137
140
  /** design/158 A9 装配表(顺序 = 拆分前 `handle()` 里各域出现的先后,逐行同序;真正的 `return` 在 handle 内,
138
- * 这里只做**签名一致性**的编译期钉:任何域入口漂了形,是编译红,不是运行时静默 404)。 */
141
+ * 这里只做**签名一致性**的编译期钉:任何域入口漂了形,是编译红,不是运行时静默 404)。
142
+ *
143
+ * 🔴 A-010.24(验真后修):上面那句「逐行同序」此前是**一句自述,没有任何东西执法**,而它已经不成立 ——
144
+ * `handleDiagnostics` 从来没进过这张表,车二着陆后 `handleRules` / `handleSharedMemory` 的先后也反了。
145
+ * 类型钉不住这两类漂:少一个成员、调换两个成员,`RouteHandler[]` 全都收下。而这张表是评审时唯一会被
146
+ * 当成「域全集 + 域次序」来读的清单(新域该插哪儿、某域在谁之后才可达),它给错答案是要害。
147
+ * 执法面现在真的有了:`test/route-shape-roster.test.ts` 的**层D**从 `handle()` 的真源码抽出域调用序,
148
+ * 与本表**逐项**对拍 —— 漏员或乱序都指名道姓地红。改 handle() 的次序时,这张表要跟着改。 */
139
149
  const ROUTE_DOMAINS = [
140
150
  handleTraceUsage,
141
151
  handleWorkflows,
@@ -150,10 +160,11 @@ const ROUTE_DOMAINS = [
150
160
  handleImages,
151
161
  handleApprovalsAssistant,
152
162
  handleObservability,
163
+ handleDiagnostics,
153
164
  handleAdoption,
154
165
  handleMemoryPolicy,
155
- handleSharedMemory,
156
166
  handleRules,
167
+ handleSharedMemory,
157
168
  handleSessionsList,
158
169
  handleSessions,
159
170
  handleSessionSync,
@@ -528,10 +539,16 @@ export function createHttpServer(rawDeps) {
528
539
  // The bake door (POST /v1/images/bakes*) is build-host-RCE-capable and authed by the Bearer-token-no-cookie
529
540
  // model (§P2.4b) — it MUST also refuse when no service token is configured (else a forged principal header
530
541
  // alone could reach an operator-gated RCE door).
531
- if (req.method === "POST" &&
532
- !anyServiceAuth &&
542
+ // A-010.23 (#209 件5) adds the same sibling arm for the REWRITE doors (`isCredentialGatedRewrite`:
543
+ // adoption + rules/cc-import + rules revoke) — same sentence, same threat, different verbs: one rekeys every
544
+ // row a principal owns, one mints that principal's DURABLE allow rules, one deletes them. They stay OUT of
545
+ // `isBillableSubmitPath` on purpose (billable=false; see that predicate's twin comment), and that family is
546
+ // METHOD-AWARE (its revoke verb is DELETE) so it is evaluated OUTSIDE the POST conjunct — same shape as
547
+ // `isDestructiveSessionWrite` below.
548
+ if (!anyServiceAuth &&
533
549
  !deps.config.allowUnauthedWrites &&
534
- (isBillableSubmitPath(url) || url.startsWith("/v1/images/bakes"))) {
550
+ ((req.method === "POST" && (isBillableSubmitPath(url) || url.startsWith("/v1/images/bakes"))) ||
551
+ isCredentialGatedRewrite(req.method ?? "", url))) {
535
552
  sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions");
536
553
  return;
537
554
  }
@@ -1082,6 +1099,13 @@ export function createHttpServer(rawDeps) {
1082
1099
  sendError(res, 400, "request.field_invalid", "interactiveTools must be a boolean");
1083
1100
  return null;
1084
1101
  }
1102
+ // [1909]⑧ oneShot(core 5.23.0):boolean fail-loud,与它的姊妹旋钮 interactiveTools 逐字同姿势 ——
1103
+ // 两者说的是同一件事的两半(这次提交是不是 `-p` 一次性形)。非 boolean 静默丢会让调用方以为已经声明
1104
+ // 了一次性、模型却照旧被告知「结束回合等通知」= 后台结果丢失,正是本键存在的那个失败形。
1105
+ if (body.oneShot !== undefined && typeof body.oneShot !== "boolean") {
1106
+ sendError(res, 400, "request.field_invalid", "oneShot must be a boolean");
1107
+ return null;
1108
+ }
1085
1109
  // [854]② retainBackgroundProcesses:boolean fail-loud(邻居姿势 —— 非 boolean 静默变「不驻留」正是
1086
1110
  // silent-drop 病灶);单用户闸/多租忽略在 resolveSpec(retainBackgroundProcessesFromBody)。
1087
1111
  if (body.retainBackgroundProcesses !== undefined && typeof body.retainBackgroundProcesses !== "boolean") {
@@ -1271,7 +1295,15 @@ export function createHttpServer(rawDeps) {
1271
1295
  * rebuild the taskConfig via the same `resolveSpec` path (from the sessionId-keyed checkpoint_ctx), then
1272
1296
  * `runner.resume`. The capability token never leaves the service. Updates the parked run row to its new state.
1273
1297
  */
1274
- async function resumeCheckpoint(sessionId, decision, reason, req, // absent on the internal D-D SLA deny-sweep (resolveSpec rebuilds auth from the checkpoint, not req)
1298
+ async function resumeCheckpoint(sessionId, decision, reason,
1299
+ // core `ApprovalSettledBy`(5.23.0):**这次结算的出处**,由调用方命名。REQUIRED —— core 的字段
1300
+ // 文档把义务钉在结算点上(「Fill it at every settlement site」),并说明集中推导正是「窗到期被报成
1301
+ // 另一个人拒绝」的成因。本函数有两个调用方且函数内**无从分辨**(sweep 与人为 decide 在这里唯一
1302
+ // 的差别是 `req` 缺席,那是「有没有 HTTP 请求」不是「谁结算的」),所以只能穿参。
1303
+ // ⚠️ core 的相容规则:`decision:"approve"` 只接受 `"human"` 或缺席 —— 「没人答所以它跑了」不是
1304
+ // 任何事实的记录。今天唯一传非 `"human"` 的调用方(SLA sweep)恒 deny,故不冲突;将来若有一个
1305
+ // 会 approve 的非人调用方,它该传的是**缺席**而不是一个自造的词。
1306
+ settledBy, req, // absent on the internal D-D SLA deny-sweep (resolveSpec rebuilds auth from the checkpoint, not req)
1275
1307
  answer, binding, onResumeCommitted) {
1276
1308
  const cs = deps.checkpointStore;
1277
1309
  // No scope filter here BY DESIGN (council): an operator may view/decide ANY tenant's pending approval.
@@ -1388,7 +1420,9 @@ export function createHttpServer(rawDeps) {
1388
1420
  ...(deps.logger ? { warn: (event, fields) => deps.logger?.warn?.(event, fields) } : {}),
1389
1421
  }, {
1390
1422
  token, scope: cp.scope, sessionId: cp.sessionId, pendingAction: cp.pendingAction,
1391
- decision, ...(reason !== undefined ? { reason } : {}),
1423
+ // #204 件6③:赎回腿是**另一个**结算点,出处同样穿参(它自己不猜)。今天走到这里的恒是人为
1424
+ // /decide —— 上面那道门写着 `req !== undefined`,而 sweep 恰是 req 缺席的那条腿。
1425
+ decision, settledBy, ...(reason !== undefined ? { reason } : {}),
1392
1426
  ...(binding !== undefined ? { binding: { ...(binding.boundCallId !== undefined ? { boundCallId: binding.boundCallId } : {}), ...(binding.boundInputHash !== undefined ? { boundInputHash: binding.boundInputHash } : {}), ...(binding.updatedInput !== undefined ? { updatedInput: binding.updatedInput } : {}) } } : {}),
1393
1427
  // remember 的 grant 闭包(onResumeCommitted 的唯一现役来源)在 parked 腿以行的 root/host
1394
1428
  // 会话为锚透传([1591] 候裁② server 修——落子代会话则探针键永不相交,见 grantOnCommit 注);
@@ -1526,6 +1560,8 @@ export function createHttpServer(rawDeps) {
1526
1560
  ...(decision === "approve" && binding?.updatedInput !== undefined ? { updatedInput: binding.updatedInput } : {}),
1527
1561
  ...(reason ? { reason } : {}),
1528
1562
  ...(answer !== undefined ? { answer } : {}),
1563
+ // #204 件6:结算出处逐字上 outcome(见参数顶注)。core 只接受它自己那三个词,server 不派生、不缺省。
1564
+ settledBy,
1529
1565
  };
1530
1566
  // Developer-mode verify (1.44) survives suspend/resume (core `resumeWithVerification`, design/51 P1-b): a
1531
1567
  // task submitted with verify:true that hit an F4 gate must STILL be gated by the adversarial verifier on
@@ -2113,10 +2149,68 @@ export function createHttpServer(rawDeps) {
2113
2149
  }
2114
2150
  catch (e) {
2115
2151
  if (e instanceof CheckpointError) {
2152
+ // ── A-010.2:`checkpoint.resume_aborted` = 取消终局(既非 terminal-failed,也**不是** retriable)──
2153
+ // core 只在 `taskConfig.signal.aborted === true` 时铸这个码(四个铸造点:pre-CAS 入口 / 编辑复裁
2154
+ // 竞速两处 / consumed 后 / reopen 成功后),而本腿递进去的 `signal` 只有 `cancelCtrl.signal` ——
2155
+ // 其唯一 abort 源是取消(inflightRuns 句柄 + 心跳轮询的持久取消旗)。所以收到该码 ⟺ 这条 run
2156
+ // 被取消了,四形的正确落点因此**同一个**:按取消结算(下面那道 pending/consumed 的判别式,在
2157
+ // 「该做什么」这个问题上是无差别的,故不做)。
2158
+ // · 归下面的 terminal 支(修前现状)= 200 的取消出路(本 catch 更下面的 `cancelCtrl.signal.aborted`
2159
+ // 分支)被本 CheckpointError 分支**遮蔽**:运维拿到 409 + core 裸报文,行虽落 failed 但
2160
+ // `errorCode` 不是 `cancelled` 且 `setTerminal` 连 TaskResult 都不写(null),pre-CAS 三形的
2161
+ // 卡还留 pending = 行终局 + 卡 pending 的孤儿对。
2162
+ // · 归 retriable(把该码加进下面的闭集)= 把一条**用户已取消**的 run 重新 park 成 `suspended`;
2163
+ // 下一次 /decide 的 markResuming 会重置取消旗并把活真跑起来 —— 取消被静默打败,执行车道回归。
2164
+ // ⛔ 修法刻意是「只给该码开一条臂」而不是把 `cancelCtrl.signal.aborted` 分支上提到本分支之前:
2165
+ // 同在 abort 期铸的 `checkpoint.reopen_failed` 两形(store 拒 reopen = 审批已终局消耗;reopen
2166
+ // 在飞失败 = 卡的状态从这里无法证明)带的信息比 "cancelled" 多,上提会把它们折没。
2167
+ if (e.code === "checkpoint.resume_aborted") {
2168
+ // 序与 /v1/runs/:id/cancel 的 cancelSuspended 逐字同向:**先**按 cancel 语义结算卡,**再**驱行
2169
+ // 终局(行终局会释放 task_active;反序会开一个「卡还 pending 而行已死」的窗)。cancel 的卡结算
2170
+ // 语义是 expire(≈ deny 的 reaper 终局),不是 resolve —— 取消意味着 run 死掉,deny-RESUME 会
2171
+ // 把拒绝喂回模型烧 token。consumed 那一形(core 3894)在这里天然是 no-op:CAS 只翻 pending。
2172
+ // ⚠️ 与 `/v1/runs/:id/cancel` 的 `cancelSuspended` **刻意分道**的一点(codex 对抗复审 R2-高2
2173
+ // 的处置):那只在 CAS 输掉时会改答 409,因为它站在腿**外面**——输掉意味着「可能有一条 decide
2174
+ // 腿正在跑」,它没资格驱行终局。本臂站在腿**里面**:markResuming 已经赢过、这条 run 归我们,
2175
+ // 而本码的语义是「它被取消了」。此刻 CAS 输掉只可能是卡已离开 pending(自己那次 resume 消费掉
2176
+ // 的 consumed 形,或 reaper 先收了)——两种都意味着**没有活审批留在外面**,行照驱终局才是对的:
2177
+ // 不驱才会把会话锁一直攥着(这正是 [868] 那条事故)。故不改结果面,但**必须留痕**。
2178
+ const cs2 = deps.checkpointStore;
2179
+ if (cs2) {
2180
+ try {
2181
+ const live = await cs2.get(token);
2182
+ if (live?.status === "pending" && !(await cs2.expire(token, live.scope))) {
2183
+ // CAS 输了:卡在我们读它与写它之间被别人(自己的 resume / reaper)settle 了。行为面无变化,
2184
+ // 但「取消时这张卡到底是谁收的」不留痕就永远查不出来。
2185
+ deps.logger?.warn?.("resume_cancel_checkpoint_settle_lost", { taskId, sessionId });
2186
+ }
2187
+ }
2188
+ catch (cancelSettleErr) {
2189
+ // 卡结算是尽力而为:行终局才是释放锁的权威写,残留的 pending 卡另有 reaper 车道兜底。
2190
+ // 但**必须留痕** —— 静默吞掉的话「取消之后卡为什么还在」在遥测里没有任何线索。
2191
+ deps.logger?.warn?.("resume_cancel_checkpoint_settle_failed", {
2192
+ taskId, sessionId, err: cancelSettleErr instanceof Error ? cancelSettleErr.message : String(cancelSettleErr),
2193
+ });
2194
+ }
2195
+ }
2196
+ // `claimedRow &&` 与下面那道分支**同一个**守卫,不是多余的:驱行终局是有资格才做的写(只有
2197
+ // 抢到这条 run 的腿才有资格),而「本码只可能在 markResuming 之后铸」这件事是当前控制流的
2198
+ // 巧合、不是结构保证。守卫写出来,这条臂就不依赖那个巧合。
2199
+ if (claimedRow && taskId && deps.runStore) {
2200
+ const c = { taskId, sessionId, status: "failed", errorCode: "cancelled", errorMessage: e.message, stats: { turns: 0, tokens: 0 } };
2201
+ await deps.runStore.setTerminal(taskId, "failed", c, c.errorMessage ?? null).catch(() => undefined);
2202
+ }
2203
+ settleFleet("failed"); // MF-Fleet (#7): 取消结算终局 → 行从 fleet 摘除
2204
+ return { status: 200, body: { taskId, sessionId, status: "failed", errorCode: "cancelled" } };
2205
+ }
2116
2206
  // We flipped the row to `running` (claimedRow). Two CheckpointError classes diverge here (D-1, core 1.101):
2117
- // • TERMINAL (already_resolved / not_found / gate_mismatch / unsupported_version): the checkpoint is
2207
+ // • TERMINAL (already_resolved / not_found / gate_mismatch): the checkpoint is
2118
2208
  // consumed or gone — drive the row terminal now (releasing task_active) instead of leaving a zombie
2119
2209
  // `running` row for reapStale to mislabel and hold the session lock for ~runStaleSec. (Original case.)
2210
+ // ⚠️ `unsupported_version` USED to sit in this bullet unconditionally (its checkpoint does NOT go
2211
+ // away — that was the registered orphan debt). As of #209 / core 5.25.0 it SPLITS on
2212
+ // `detail.reason`: a recognised worker-swap word joins the retriable class below; an absent /
2213
+ // unrecognised word stays TERMINAL. Read the block under the closed set before touching it.
2120
2214
  // • RETRIABLE PRE-CAS (invalid_outcome = binding mismatch / reopen_revote = env_failed must replay the
2121
2215
  // persisted winner / reopened_concurrently = a concurrent resolve-reopen advanced the rev): the
2122
2216
  // checkpoint STAYS pending and the operator re-fetches + re-/decides. Driving the row terminal would
@@ -2134,7 +2228,34 @@ export function createHttpServer(rawDeps) {
2134
2228
  // 会释放 task_active 并孤儿化那条仍 pending 的卡 —— 一张还能点的卡当场变死件。
2135
2229
  e.code === "resume.constraint_rejected" ||
2136
2230
  e.code === "resume.constraint_unprojectable" ||
2137
- e.code.startsWith("wake."); // design/144:wake 拒绝(gate_pending/nothing_to_deliver)全是 PRE-CAS,checkpoint 保持 pending——驱 terminal 会孤儿化仍在等的 park
2231
+ e.code.startsWith("wake.") || // design/144:wake 拒绝(gate_pending/nothing_to_deliver)全是 PRE-CAS,checkpoint 保持 pending——驱 terminal 会孤儿化仍在等的 park
2232
+ // 🟢 **`checkpoint.unsupported_version` 的一臂精确 retriable**(#209 件2,core 5.25.0 提货批
2233
+ // [3443];取代 #205 件4② 那条「维持 TERMINAL + 孤儿代价在案」的登记账)。
2234
+ //
2235
+ // 旧账的形状(留着,因为它解释了为什么这条臂长这样):该码在 core 上有**三个** PRE-CAS 铸点
2236
+ // (版本超上限 / org 治理行落到无 `permissionRuleOrg` 的 worker / 带 remote `workspaceHandle` 的行
2237
+ // 落到无 `executionEnvFactory` 的 Runner),三形卡一律留 pending 但**可恢复性不同**,而 core 当时
2238
+ // 同码同 `detail`(都不带)⇒ 下游只剩「按报文文案猜」或「按 checkpoint 形状反推」两条路,两版修法
2239
+ // 各被一轮 codex 对抗复审验伪(blanket-retriable 让无手臂永挂 task_active;按 `gate.realApproval`
2240
+ // 分流不健全 —— core 把该位放 gate、`workspaceHandle` 放 state,同一条行可以两样都带)。按宪法
2241
+ // 「源头修复,禁下游旁路」全回退,并把判别式列成上游请托([3434] 请托 1)。
2242
+ //
2243
+ // 请托已兑现:core 5.25.0 起三个铸点各带 `detail.reason`(`runtask.js:3706/3721/3733` 亲读),
2244
+ // d.ts 逐字说三者都是「retryable on a newer / factory-wired / org-wired worker」。⇒ 本臂**读词表**
2245
+ // (`WORKER_SWAP_REDEEMABLE`,闭集、新词 tsc 红),不做任何形状推断:
2246
+ // · 认得的三个词 ⇒ retriable:行重新 park suspended,那条仍 pending 的卡不再被孤儿化
2247
+ // (旧账里「行 failed 而卡活着、下一次 /decide → 404、只能等 `terminal_at_ms`」的代价至此销掉)。
2248
+ // · 词缺席 / 认不得 ⇒ **维持 TERMINAL**(旧行为逐字保留)。这不是保守的装饰:旧引擎的裸抛与
2249
+ // 将来某个我们还没读过的新臂都落在这一支,而把它们默认成 retriable 会把一条**真的没救**的行
2250
+ // 永久 park 在 suspended 上攥着 task_active —— 那正是 codex R1-[high] 验伪掉的那一版。
2251
+ // 🔴 形③(`env_factory_missing`)按 core 的词判 retriable,而不是按 #205 那条「选 Runner 由
2252
+ // `handsLanes.laneOf(spec)` 按场景判,重投永远同样失败」的推断:那条推断说的是**同一份部署配置下**
2253
+ // 重投无用,而 core 的词说的是「接上 `executionEnvFactory` 的 worker 兑得掉」—— 那是一次运维动作,
2254
+ // 与 `governed_unwired` 要求接 `permissionRuleOrg` 完全同族。两者都不是「换个副本再点一次」。
2255
+ // 期间行停在 suspended 攥着会话锁,与它 park 时的状态**逐字相同**(这条腿只是没能把它推进),
2256
+ // 而 TERMINAL 那一支要用「释放锁」换「卡变死件」,方向更坏。
2257
+ // 行为面钉:`test/durable-resume-http.test.ts` 的「#209 件2」三正格 + 三反极格。
2258
+ (e.code === "checkpoint.unsupported_version" && checkpointRowRedeemableElsewhere(e.detail?.reason));
2138
2259
  if (claimedRow && taskId && deps.runStore) {
2139
2260
  // Re-park on the SAME gate family the outcome targeted: a plan_review retry must re-park `needs_review`
2140
2261
  // (keep the row's review status + the lock), not `suspended` — else GET /v1/assistant/tasks mislabels it.
@@ -2713,7 +2834,9 @@ export function createHttpServer(rawDeps) {
2713
2834
  parkedSkipped += 1;
2714
2835
  continue;
2715
2836
  }
2716
- await resumeCheckpoint(sessionId, "deny", "approval SLA expired — auto-denied").then((r) => { if (r.status >= 400)
2837
+ // #204 件6②:出处 = `"timeout"`(窗到期,**没有人**答)。此前这里与人为 /decide 共用一个不带
2838
+ // 出处的构造,于是下游把一次自动拒渲染成「有人拒绝了你」。
2839
+ await resumeCheckpoint(sessionId, "deny", "approval SLA expired — auto-denied", "timeout").then((r) => { if (r.status >= 400)
2717
2840
  deps.logger?.warn?.("deny_sweep_resume_blocked", { sessionId, status: r.status, body: r.body }); }, (e) => deps.logger?.warn?.("deny_sweep_resume_failed", { sessionId, err: e instanceof Error ? e.message : String(e) }));
2718
2841
  }
2719
2842
  if (parkedSkipped > 0)
@@ -2723,14 +2846,19 @@ export function createHttpServer(rawDeps) {
2723
2846
  // value AS an http.Server — listen/close/etc. — while main.ts's reaper reads server.denyExpiredApprovals).
2724
2847
  return Object.assign(server, { denyExpiredApprovals });
2725
2848
  }
2726
- /** SSE: replay durable events after Last-Event-ID, then tail until the run is terminal (or stale). */
2727
- /**
2728
- * The differences a concrete log (task_run | image_bake) feeds the ONE resumable SSE reader (P2.8). Everything
2729
- * the reader does — Last-Event-ID/`?from=` resume, the 416 retention boundary, the per-poll concurrent
2730
- * status+events read, the terminal re-fetch (the terminal event lands in the gap before the status flips), the
2731
- * stale fallback, the 15-min cap, the 15s idle heartbeat — is provider-agnostic and lives in `streamSseLog`.
2732
- * 🔴 The task_run provider MUST keep the existing wire bytes EXACTLY (center's relay + 730+ tests depend on it).
2733
- */
2849
+ const WORKER_SWAP_REDEEMABLE = {
2850
+ version_newer: true,
2851
+ env_factory_missing: true,
2852
+ governed_unwired: true,
2853
+ real_approval_damaged: false,
2854
+ real_approval_forged: false,
2855
+ constraint_chain_missing: false,
2856
+ };
2857
+ /** 判别符缺席(旧引擎裸抛)或词表不认得 ⇒ **false**,即维持修前的 TERMINAL 归属。fail-closed 的方向在
2858
+ * 这里是「不宽容默认」:一个我们还没读过的新臂不该因为一个 `?? true` 被悄悄改判成「重试吧」。 */
2859
+ function checkpointRowRedeemableElsewhere(reason) {
2860
+ return reason !== undefined && WORKER_SWAP_REDEEMABLE[reason] === true;
2861
+ }
2734
2862
  /** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
2735
2863
  * guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
2736
2864
  * via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
@@ -2752,6 +2880,39 @@ export function isBillableSubmitPath(url) {
2752
2880
  SESSION_WAKE_RE.test(url) ||
2753
2881
  url.startsWith("/v1/approvals"));
2754
2882
  }
2883
+ /**
2884
+ * A-010.23(#209 件5)—— **改写门**:不烧模型、但在**没有任何 service credential** 的部署形下必须与
2885
+ * billable / bake 两族一样 fail-closed 的写门。谓词与 `isBillableSubmitPath` **刻意分开**:这几扇门
2886
+ * `billable=false`(零模型工作),塞进那张名单会让它们连带吃 drain 与 model-roster-pending 两道 503 ——
2887
+ * 而收编与规则读写在排空期/roster 未落时做完全无害。
2888
+ *
2889
+ * 判据(两个合取项,缺一不进本表)= 「授权的唯一输入是 principal 头」∧「动作是**持久改写**」:
2890
+ * · `POST /v1/adoption` —— operator-only 的一次性部署级动作,把一个 principal 名下**每一行**重写到另一个
2891
+ * 身份;授权判据 `explicitOperatorOk(gatedPrincipal(...))`。
2892
+ * · `POST /v1/rules/cc-import/*` —— principal lane 的**持久 allow 规则**铸造口;一条持久 allow 规则对后续
2893
+ * 同命令的 classify 档 ask 常驻消音(core #144 之后仍然如此,只是不再消 mandated 的那些)。
2894
+ * · `DELETE /v1/rules` —— 同一个规则店的**撤销**口。方向相反(收紧)但同样是持久改写,而且它多一条
2895
+ * operator 越权域(`?principal=` / body 的 `principal` 让 operator 收回**任一**租户的规则)⇒ 伪造一个
2896
+ * 列在 `OPERATOR_PRINCIPALS` 里的头,就能把每一位租户的规则一次清空。
2897
+ * 无 service token 的部署上 `verifiedPrincipal` 走的是「BFF/gated:头已由上游验过」那一支,而这一形恰恰
2898
+ * 是**没有**那个上游 —— 于是伪造一个头就能收编别人、替任意租户种下常驻放行、或替他们全删。bake 门当年
2899
+ * 补的就是同一句话,`isDestructiveSessionWrite` 从逐条路由白名单改成族判定也是同一句话(那次的措辞逐字是
2900
+ * 「a forged principal header alone could **tighten/DoS** a session's tools」——撤销口落的正是 DoS 那一半)。
2901
+ *
2902
+ * 🔴 **方法感知**(codex 对抗复审 R2-[high],验真后修):撤销走的是 `DELETE`,而本门最初只挂在 `POST` 的
2903
+ * 合取里 —— 于是一个自称「持久改写」的谓词把爆炸半径最大的那条动词漏在门外,正是 `isDestructiveSessionWrite`
2904
+ * 当年被抓到的同一形。签名因此收方法,与那只谓词逐字同形。
2905
+ * 🔴 读面不进本表:`GET /v1/adoption/:id` 与 `GET /v1/rules` 各有自己的属主/operator 门,本门只拦写。
2906
+ * 行为面钉:`test/rewrite-door-service-token-gate.test.ts`(四扇门各一正一反 + 逃生口格 + 读面负控)。
2907
+ */
2908
+ export function isCredentialGatedRewrite(method, url) {
2909
+ const m = method.toUpperCase();
2910
+ if (m === "POST")
2911
+ return url === "/v1/adoption" || url.startsWith("/v1/rules/cc-import/");
2912
+ if (m === "DELETE")
2913
+ return url === "/v1/rules";
2914
+ return false;
2915
+ }
2755
2916
  // ⚠️ SSE 帧纪律(2026-07-12):center BFF 中继凭「data 无 type 字段」识别心跳帧并吞掉
2756
2917
  // (AgentEvent 是 closed oneof 全带 type,故可控)。**未来新增任何 SSE data 帧必须带 type 字段**,否则会被
2757
2918
  // 中继当心跳吞掉、永远到不了前端。heartbeat 帧(`event: heartbeat` + `data: {}`)是唯一豁免。
@@ -2833,6 +2994,9 @@ const ROUTE_LABEL_LITERALS = new Set([
2833
2994
  // ⚠️ 本段注释里也不许出现方括号后紧跟右圆括号的字符对:billable 门的表捕获正则是**非贪婪**的,
2834
2995
  // 那两个字符会让它在这里提前收尾,后面的字面量整段落在门的视野外(写这条注时真的踩到过)。
2835
2996
  "/v1/rules/cc-import/prepare", "/v1/rules/cc-import/redeem",
2997
+ // #203 §2:撤销面两口(GET 列举 / DELETE 撤销)共用一个字面路径。同样写字面量而不是引 `RULES_PATH`
2998
+ // 常量,理由与上一条逐字相同(名册门与 billable 申明门都扫源码文本)。
2999
+ "/v1/rules",
2836
3000
  ]);
2837
3001
  /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
2838
3002
  * [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
@@ -10,6 +10,14 @@ import type { AgentDefinition, McpServerSpec } from "@sema-agent/core";
10
10
  export interface TaskRequestBody {
11
11
  objective: string;
12
12
  sessionId?: string;
13
+ /** Which deployment SCENARIO assembles this task's tools/prompt/skills (the keys of the worker's scenario
14
+ * table — e.g. "default"/"code-review"/"scan"/"team"). An INTENT bounded by the deployment's own table AND,
15
+ * on a governed worker, by the principal's center-resolved allowlist: an unknown name is a typed 400
16
+ * `scenario_unknown`, a known-but-not-allowed one a typed 400 `scenario_not_allowed`; absent ⇒ the ruling's
17
+ * assigned scenario, else `DEFAULT_SCENARIO` (consumed at `boot/resolve-spec.ts` via `gateScenarioRequest`).
18
+ * ⚠️ scenario-SPECIFIC keys (`repo`/`lenses`/`rounds`, read through `ScenarioRequest`'s index signature) are
19
+ * deliberately NOT declared here — they belong to one scenario each, not to the shared submit surface. */
20
+ scenario?: string;
13
21
  /** dispatch-gateway failover 幂等第二级:caller-minted 任务 id(uuidv7)。同 owner 重放同 id ⇒ 幂等重放;
14
22
  * 异 owner ⇒ 409 `conflict.run_exists`;非 uuidv7 ⇒ 400 `request.id_invalid`(routes/runs.ts:192-208 真验收)。
15
23
  * [2400] TR-16 连带:此键此前只活在 runs.ts 读点,导出类型漏declared——CAPS-OPS-13 同族第 9 键。 */
@@ -76,6 +84,14 @@ export interface TaskRequestBody {
76
84
  * a rung fails the gate (default = it didn't complete). Default off; mutually exclusive with `verify`;
77
85
  * not on /v1/tasks/stream. ⚠️ each rung is a cold re-run — use for read-only / idempotent tasks. */
78
86
  cascade?: boolean;
87
+ /** Multi-lens review council (the `code-review` scenario's expensive tier) — `council:true` fans the review out
88
+ * to N parallel lenses + an arbiter instead of the lead reviewing in-line; `debate:true` additionally runs the
89
+ * L2 peer-debate rounds. Read on TWO seams and both matter: the scenario builder mounts `run_council`
90
+ * (`capabilities/scenarios.ts` `codeReview`), and `boot/resolve-spec.ts` treats either flag as an EXPLICIT team
91
+ * declaration — it widens the task's wall-clock tenancy budget (`resolveTaskLimits`) and suppresses the value
92
+ * router's auto-escalation (`explicitTeam`). Only literal `true` counts on both seams. */
93
+ council?: boolean;
94
+ debate?: boolean;
79
95
  /** Work-view correlation id (optional): groups the runs of one logical task across both client doors
80
96
  * (MCP façade / portal) so a fragmented set of runs reads as one task. Opaque to the engine — only
81
97
  * persisted on the run row + filterable via `GET /v1/tasks?jobId=`. ≤64 chars (matches the column). */
@@ -102,6 +118,18 @@ export interface TaskRequestBody {
102
118
  * surfaces the validated object as `TaskResult.structuredOutput` (an invalid submit retries, then fails with
103
119
  * `output.invalid`). A plain JSON-schema OBJECT (shape + size validated here; deep validity is core's). */
104
120
  outputSchema?: Record<string, unknown>;
121
+ /** Retry budget for an INVALID `submit_output` against `outputSchema` (core `TaskSpec.outputRetries`) — the
122
+ * caller-facing other half of the structured-output pair. Narrow acceptance in `boot/resolve-spec.ts`:
123
+ * finite, ≥1, floored, capped at 10; anything else ⇒ key omitted (core's own default). Meaningless without
124
+ * `outputSchema` (core ignores it there). */
125
+ outputRetries?: number;
126
+ /** Within-task compaction tuning (core design/145 `TaskSpec.compaction`). ⚠️ ONLY `clampTolerance` is a caller
127
+ * knob — the rest of core's compaction object is an OPERATOR axis and is deliberately not on the wire, so this
128
+ * type is narrower than core's field on purpose. `boot/resolve-spec.ts` accepts a finite number in [0,1] and
129
+ * omits the whole `compaction` key otherwise. Pairs with `compactionModel` (which gear compacts). */
130
+ compaction?: {
131
+ clampTolerance?: number;
132
+ };
105
133
  /** Reasoning-effort selection (CC `/effort` picker, shell-host contract E7): a neutral effort tier mapped to
106
134
  * core's `TaskSpec.thinking` (`ThinkingLevel`). Accepted set = core's tiers (off/minimal/low/medium/high/xhigh/max);
107
135
  * the picker's advertised default set is `/v1/models` `supportedEffortLevels`. A provided-but-unknown value is a
@@ -275,6 +303,26 @@ export interface TaskRequestBody {
275
303
  * (a caller can't point a shared/cloud worker at an arbitrary host path). Validated absolute (prepareSpec 400s a
276
304
  * relative cwd — it would silently resolve against the SERVICE process cwd). */
277
305
  cwd?: string;
306
+ /** design/119 (CC `--add-dir`, core `TaskSpec.additionalDirectories`): extra host dirs the FILE tools may reach
307
+ * beyond the containment root. 🔒 Gated exactly like `cwd`/`shellEnv` — honored ONLY on the single-user host
308
+ * lane (`task-cwd.ts` `cwdHonored`); off that lane `boot/resolve-spec.ts` drops them with a loud
309
+ * `task_additional_directories_ignored` (never silently). SHAPE is fail-loud at submit (absolute host paths,
310
+ * no `..` segments, ≤ MAX_ADDITIONAL_DIRS entries) — separate from whether the lane honors them.
311
+ * `additionalReadDirectories` (core 5.11.0) is the same door with a READ-only semantic: it widens the read
312
+ * containment (classify auto-allow + read_file/grep) and never the write face. Rides the persisted body onto
313
+ * resume legs. */
314
+ additionalDirectories?: string[];
315
+ additionalReadDirectories?: string[];
316
+ /** [1909]⑧/[1910]/[1911] (core 5.23.0 `TaskSpec.oneShot`): this SUBMISSION is one-shot — no later turn exists
317
+ * in which an async background notification could land (the archetypal case is a headless `sema -p` whose
318
+ * process exits when the turn ends). PER-REQUEST on purpose: "does this submission expect to be continued" is
319
+ * a property of the submission, not of the connection it arrived on. core consumes it as GUIDANCE ONLY (the
320
+ * `RunWorkflow` / delegation receipts tell the model to block-wait via `TaskOutput({block:true})` instead of
321
+ * "end your turn, you will be notified" — the latter is actively wrong here and loses background results); it
322
+ * grants nothing, so there is no tenancy gate. Sibling of `interactiveTools` (the same `-p` posture) and passed
323
+ * through the same way: a boolean rides, absent/garbage ⇒ key omitted (core's default = interactive). Rides the
324
+ * persisted body onto resume legs. */
325
+ oneShot?: boolean;
278
326
  /** [R3] Caller-supplied per-request MCP servers (the TOC client's local `.mcp.json`), aligned to core
279
327
  * `McpServerSpec`. 🔒 honored on any SINGLE-USER deployment (task-mcp.ts `mcpInjectionHonored` = `requirePrincipal!==true`)
280
328
  * — the requester is the super-admin of their OWN worker (CC-parity), on ANY execution lane (the stdio MCP runs on the
package/dist/main.js CHANGED
@@ -44,6 +44,7 @@ import { createParkedReviveInheritedGate } from "./boot/parked-revive-gate.js";
44
44
  import { startReapers } from "./boot/reapers.js";
45
45
  import { openStores } from "./boot/stores.js";
46
46
  import { runAdoptionBootScan } from "./boot/adoption.js";
47
+ import { auditDormantPermissionRules } from "./boot/permission-rules-audit.js";
47
48
  import { createBudgetAndTracing } from "./boot/budget-tracing.js";
48
49
  import { createRuleConsentLane } from "./rules-consent.js";
49
50
  import { createExecutionEnv } from "./boot/execution-env.js";
@@ -188,6 +189,27 @@ async function main() {
188
189
  // 🔴 总开关在最前(codex round7 [high] 一):关 ⇒ 整条车道根本不装配(店/车道/帧键/两口一起消失)。
189
190
  const permissionRuleStores = config.permissionRulesEnabled && backend ? backend.permissionRule() : undefined;
190
191
  const ruleConsent = permissionRuleStores ? createRuleConsentLane(permissionRuleStores) : undefined;
192
+ // 🔴 A-010.10(验真后修):**旋钮开着却上不了场 ⇒ 打一行**,与同族旋钮 `STREAM_APPROVAL_ENABLED` 的
193
+ // `stream_approval_disabled` 一行(`boot/coordinators.ts`)对称。
194
+ // 此前这一格全程静默:`PERMISSION_RULES_ENABLED=true` 的部署若没有 backend、或 backend 没实装规则店,
195
+ // 车道就悄悄整条不装配 —— 运维看到的是「设了旋钮,可 `/v1/rules` 恒 501、卡上恒无候选」,而启动日志
196
+ // 里一个字都没有。判据逐字照那条先例:①**info 不是 warn**(这是合法部署形,不是配置错误);
197
+ // ②旋钮本来就没开的部署**不打**(显式关掉的人自己知道为什么,一行噪音零信息);
198
+ // ③原因写成机读位(哪一项不成立),不是一句人话。
199
+ // 与紧随其后的 `auditDormantPermissionRules` 是两件事、互不遮蔽:那条说的是「车道上了场,并且库里有
200
+ // 既有桶被默认 ON 唤醒」,这条说的是「车道压根没上场」——两者的合取恒假,永远只会打其中一行。
201
+ if (config.permissionRulesEnabled && permissionRuleStores === undefined) {
202
+ logger.info("permission_rules_lane_unavailable", {
203
+ knob: "PERMISSION_RULES_ENABLED",
204
+ reason: backend === undefined ? "no_backend" : "backend_has_no_rule_store",
205
+ backendKind: backend?.kind ?? null,
206
+ note: "PERMISSION_RULES_ENABLED is on but no permission-rule store is wired — /v1/rules/* answer 501, approval cards carry no rule suggestions, and respond's persistRule is refused with rule_lane_unavailable",
207
+ });
208
+ }
209
+ // #203 §3(设计稿 v2 F4 残余):默认 ON 会把**既有**规则桶一并唤醒 —— 在启动日志里把它说出来。
210
+ // 三条判据(店缺席 / 零桶 / 运维显式表过态 ⇒ 都不打行)与失败方向(数不出来只 warn,绝不拒启、
211
+ // 也绝不编一个 0)逐字见 `boot/permission-rules-audit.ts`。
212
+ await auditDormantPermissionRules({ stores: permissionRuleStores, logger, explicit: config.permissionRulesEnabledExplicit });
191
213
  // design/158 A10:执行环境装配搬到 src/boot/execution-env.ts(逐字)。
192
214
  // ⚠️ 工厂装饰顺序=行为(scratchpad → worktree → SendUserFile 登记 → 附件物化最外层),见该文件头注。
193
215
  const { perTaskImage, sessionEnvSelection, perSessionCwd, setSessionCwd, setSessionShellEnv, executionEnvFactory, worktreeReap, sendUserFileTaskEnvs, lspManager, } = createExecutionEnv({ config, logger, metrics, taskAttachmentStore });
@@ -235,8 +257,15 @@ async function main() {
235
257
  // ⚠️ 刻意**不动** `toolResultStore` 本身:那个键的「present ⇔ durable 后端在场」语义还管着 E21 purge、
236
258
  // reaper 清扫、leader 装配与 /health 的接线自述(改它会让无 backend 的部署自述成 "shared(sql)" = 谎)。
237
259
  const runnerOffloadStore = toolResultStore ?? new InMemoryToolResultStore({ maxTotalChars: 64_000_000 });
260
+ // 交接件⑤:commit 尾注署名座**算一次**,主/sub 两只 Runner 经共享基座同源。
261
+ // [931]① clay 拍(core 1.300 BREAKING:缺省不署 Co-Authored-By,署名=产品身份资产归部署):
262
+ // branded 形态(local provider = Sema 产品线,scenarios brandIdentity 同判据)commit 尾注接 Sema 署名;
263
+ // 非 brand 部署维持 core 新缺省(不署)。判据逐字不变,变的只是它现在**也**喂给 subRunner ——
264
+ // 一个被委派的子代提交进的是同一个仓、代表同一个部署,它的 commit 少一行 trailer 没有理由。
265
+ const commitHands = config.configProvider === "local" ? { commitCoAuthor: "Sema <noreply@vivi-ai.com>" } : undefined;
238
266
  // design/158 A10:RunnerDeps 装配段搬到 src/boot/runner-deps.ts(逐字;runStore 晚绑改取值,见该文件头注)。
239
267
  const runnerDeps = createRunnerDeps({
268
+ hands: commitHands,
240
269
  config, logger, metrics, localRoot, promptSource: configCenter.promptSource, rosterStore, backgroundAgentStore, mailboxStore, usageWindowStore, brain,
241
270
  pricing, tracer, outcomeSink, elicitation, question, toolApproval, sessionStore, memoryEngine,
242
271
  memorySyncRunner, toolResultStore: runnerOffloadStore, sessionPolicyStore, runtimeCapsResolver, fileSnapshotStore,
@@ -269,11 +298,26 @@ async function main() {
269
298
  // 与 resource suspend 都会写),续跑时被路由到这只无手 Runner,core 在 **CAS 之前**抛
270
299
  // `CheckpointError("checkpoint.unsupported_version", "checkpoint has a remote workspaceHandle but no
271
300
  // RunnerDeps.executionEnvFactory is wired to rebuild the env")`(runtask.js:3704)。
272
- // 本仓的分类表把该码归 **TERMINAL**(http/server.ts CheckpointError 分支):run 行被 `setTerminal`
273
- // failed 并**释放** task_active,响应 409 携带该 errorCode —— 会话**不会**被占住到 TTL,运维重提该
274
- // 任务即可。故这是**响亮的、有界的**升级窗代价,不是静默损坏;但它是行为面的升级注记,发车说明必须写。
275
- // ⚠️ 未覆盖登记:本形需要「真 durable + 带 workspaceHandle 的旧 checkpoint」才能驱动,本批未建格
276
- // (属真双库/迁移测试面)—— 移交主会话裁定是否要做 checkpoint 感知的续跑选路或启动期预检。
301
+ // 🟢 **裁定已翻新(#209 件2,core 5.25.0 提货批 [3443]):本形现在归 RETRIABLE。**
302
+ // 沿革留档(免下次重开同一个问题):该码在 core 上共三个铸点 —— 本形 / 版本超上限 / org 治理行
303
+ // 落到无 `permissionRuleOrg` 接线的 worker —— 三形都是 PRE-CAS、卡一律留 pending,但**可恢复性
304
+ // 不同**;#205 当时 core **不给判别式**(同码、`detail` 同样缺席),两版下游修法各被一轮 codex
305
+ // 对抗复审验伪(①三形一律 retriable ②按 `gate.realApproval` 分流),按「源头修复禁下游旁路」全
306
+ // 回退、维持 TERMINAL,并把判别式列成上游请托([3434] 请托 1)。
307
+ // 请托已兑现:core 5.25.0 给三个铸点各带 `CheckpointError.detail.reason`,本形 = `env_factory_missing`,
308
+ // d.ts 逐字「retryable on a factory-wired worker」。⇒ `http/server.ts` 的分类表现在**读词表**判
309
+ // (`WORKER_SWAP_REDEEMABLE`,闭集、core 加词 tsc 红),不做形状推断;本形的行**重新 park
310
+ // suspended**、那条仍 pending 的卡不再被孤儿化(#205 登记的孤儿代价至此销账)。
311
+ // ⚠️ 与旧裁定的差别在**代价的方向**,如实说清:修前拿「释放 task_active」换「卡变死件(下一次
312
+ // /decide → 404,只能等 `terminal_at_ms`)」;修后行停在 suspended 攥着会话锁,与它 park 时逐字
313
+ // 相同,直到运维给这条车道接上 `executionEnvFactory`(或把该场景路由回有手 Runner)。#205 当年
314
+ // 驳掉 blanket-retriable 的那条理由(`handsLanes.laneOf(spec)` 按场景判 ⇒ 换副本重投必同样失败)
315
+ // 仍然成立,但它说的是「换个副本再点一次没用」,而 core 的词说的是「接上 factory 的 worker 兑得掉」
316
+ // —— 那是一次**运维动作**,与 `governed_unwired` 要求接 `permissionRuleOrg` 完全同族。
317
+ // 升级窗仍是**响亮的**(409 + 该 errorCode + `retriable: true`),不是静默损坏;发车说明照写。
318
+ // 论证与钉:`http/server.ts` 分类表下方那段 + `test/durable-resume-http.test.ts` 的「#209 件2」六格。
319
+ // ⚠️ 未覆盖登记(原样保留):本形需要「真 durable 店 + 带 workspaceHandle 的旧 checkpoint」才能
320
+ // 驱动,真店端到端仍属真双库/迁移测试面,未建。
277
321
  const handslessRunner = new Runner(withoutExecutionEnv(runnerDeps));
278
322
  // codex R10: TRUE ⇒ the Runner just froze a PRIVATE tier-expanded catalog copy (core runtask.js constructor,
279
323
  // dist-read) — in-place model-plane mutation no longer reaches it, so refresh-time plane changes must be
@@ -399,6 +443,7 @@ async function main() {
399
443
  // 基座只有一份)。差异键在展开后显式列出,每个都有为何不同的理由(见 boot/runner-deps.ts 头注)。
400
444
  ...createSharedRunnerDeps({
401
445
  config,
446
+ hands: commitHands, // 交接件⑤:与主 runner 同一个值(基座保证同源)
402
447
  brain,
403
448
  pricing,
404
449
  tracer,
@@ -719,8 +764,21 @@ async function main() {
719
764
  // `mandatePostureOf`)。此处新铸的转发闭包与 runnerDeps 那只行为逐字相同(都只转 `toolApproval.ask`),
720
765
  // 摘要只看在场性;仅 core 的 hook 席位去重按函数身份判,那一侧的身份未命中是**多筛一次**(core 自述
721
766
  // 的保守方向),不是漏筛。
767
+ // A-010.1 同族的另外两位(`resolveRuntimeCaps` / `autoModeSeatMounted`):5.22.0 的摘要卷进的是**三**位
768
+ // 决议链元数据,其中 `autoModeArmed` 与 `durableMandate` 的 `forceDurableGate` 项都由 per-principal 的
769
+ // `RuntimeCaps` 决定。两处必须与 core 看到的**同一只**事实源接线,否则同样是「park 记 true 而赎回腿供
770
+ // false ⇒ 摘要恒不匹配 ⇒ 这批 principal 的卡永不可赎」:
771
+ // · `resolveRuntimeCaps` = 递给 `RunnerDeps.runtimeCapsResolver` 的同一只(单一属主 boot/runtime-caps.ts);
772
+ // · `autoModeSeatMounted` = **读** runnerDeps 上那一位,不写字面 `true` —— core 的武装式是
773
+ // `runtimeCaps.autoMode === true ∧ deps.autoMode !== undefined`,第二个半场的真值只有 runnerDeps 知道,
774
+ // 在此处手抄 `true` 会在 runner-deps 那边改成有条件挂载的那一天静默错供一位。
722
775
  const parkedReviveInheritedGate = parkedReviveTool && config.durableApproval
723
- ? createParkedReviveInheritedGate({ config, question, approvalExemptionStore, logger, localRoot, approverSeat: createRunnerDepsOnAsk(toolApproval) })
776
+ ? createParkedReviveInheritedGate({
777
+ config, question, approvalExemptionStore, logger, localRoot,
778
+ approverSeat: createRunnerDepsOnAsk(toolApproval),
779
+ ...(runnerDeps.runtimeCapsResolver ? { resolveRuntimeCaps: runnerDeps.runtimeCapsResolver } : {}),
780
+ autoModeSeatMounted: runnerDeps.autoMode !== undefined,
781
+ })
724
782
  : undefined;
725
783
  // 场景详情:内建 details 必须在 overlay 合并【前】构建(探针要打纯内建工厂,不是被 center 顶掉的);
726
784
  // center details 随 overlay 同判定源盖同名——source 语义与 selectScenario 实际取用永一致(约定①)。
@@ -740,7 +798,7 @@ async function main() {
740
798
  config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore,
741
799
  rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap,
742
800
  workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore,
743
- backgroundAgentStore, mailboxStore, toolApproval,
801
+ backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores,
744
802
  getRunDenySweep: () => runDenySweep,
745
803
  });
746
804
  // Optional OTLP/HTTP metrics export (1.37). Periodically pushes the registry to an OTel collector;
@@ -49,10 +49,18 @@ export declare const FAIL_OPEN_TAGS: {
49
49
  readonly cls: "F";
50
50
  readonly note: "CC 规则导入票的**认领回滚**失败(认领之后的某一步没成 ⇒ 本该把认领放回去,而这次放回本身也抛了)。放行的最坏后果 = 这张票留在已认领态、属主这一轮不能重试 —— 恰好等于加认领回滚**之前**的行为,不是新损失;票随 TTL 自然消失,属主重走一次 prepare 即可拿新票(导入按 core 的设计幂等:同一条规则再兑付只是同一个 dot 的重放)。方向上没有任何权限被放宽(规则**没有**落地才走到这条臂),故 F 类;必须留痕,否则「票为什么突然不能用了」在遥测里没有任何痕迹。";
51
51
  };
52
+ readonly "server.parked-revive.ancestor-classifier-unreachable": {
53
+ readonly cls: "P-DEBT";
54
+ readonly note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。";
55
+ };
52
56
  readonly "server.fleet.subscriber-callback-threw": {
53
57
  readonly cls: "F";
54
58
  readonly note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。";
55
59
  };
60
+ readonly "server.run-store.stale-claim-quarantine-unremoved": {
61
+ readonly cls: "F";
62
+ readonly note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。";
63
+ };
56
64
  };
57
65
  /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
58
66
  export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
@@ -72,10 +72,18 @@ export const FAIL_OPEN_TAGS = {
72
72
  cls: "F",
73
73
  note: "CC 规则导入票的**认领回滚**失败(认领之后的某一步没成 ⇒ 本该把认领放回去,而这次放回本身也抛了)。放行的最坏后果 = 这张票留在已认领态、属主这一轮不能重试 —— 恰好等于加认领回滚**之前**的行为,不是新损失;票随 TTL 自然消失,属主重走一次 prepare 即可拿新票(导入按 core 的设计幂等:同一条规则再兑付只是同一个 dot 的重放)。方向上没有任何权限被放宽(规则**没有**落地才走到这条臂),故 F 类;必须留痕,否则「票为什么突然不能用了」在遥测里没有任何痕迹。",
74
74
  },
75
+ "server.parked-revive.ancestor-classifier-unreachable": {
76
+ cls: "P-DEBT",
77
+ note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。",
78
+ },
75
79
  "server.fleet.subscriber-callback-threw": {
76
80
  cls: "F",
77
81
  note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。",
78
82
  },
83
+ "server.run-store.stale-claim-quarantine-unremoved": {
84
+ cls: "F",
85
+ note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。",
86
+ },
79
87
  };
80
88
  /**
81
89
  * 断流丢帧该记哪个 tag —— **按帧型分类**,纯函数(与写流的那条闭包解耦,才单测得动)。
@@ -336,7 +336,8 @@ export function createMetrics() {
336
336
  m.counter("web_search_bad_payload_total", "Web-search provider responses whose results field was not an array (LOW), by provider");
337
337
  // 收账批(2026-07-08):这 15 个计数器一直只有 inc 没注册 — 在 auto-register 落地前
338
338
  // 整批从未上过 /metrics(inc 曾对未注册名静默 no-op)。补显式注册拿正经 HELP 文案。
339
- m.counter("permission_denied_total", "Tool-gate denials by source (policy/hook/plan-mode; goal B4 always-on deny meter)");
339
+ m.counter("permission_denied_total", "Tool-gate denials by source (core's PermissionDeniedSource, incl. org governance; 'other' = a word this build does not know; goal B4 always-on deny meter)");
340
+ m.counter("permission_rule_events_total", "Persisted allow-rule seam events by event (persisted_rule_allowed = a rule authorized a call with nobody asked; rule_store_unreadable = the store failed to read so the call was adjudicated with ZERO rules)");
340
341
  m.counter("compaction_events_total", "Compaction lifecycle events by outcome (started/completed/failed/skipped) and trigger");
341
342
  m.counter("hook_llm_calls_total", "prompt/agent hook entries that completed a model call (hooks 3b), by type");
342
343
  m.counter("auth_bridge_verify_total", "Registry SSO JWT verifications (auth-bridge), by outcome");
@@ -1,6 +1,10 @@
1
- import type { Hooks } from "@sema-agent/core";
1
+ import type { Hooks, PermissionDeniedSource } from "@sema-agent/core";
2
2
  import type { Logger } from "./logger.js";
3
3
  import type { Metrics } from "./metrics.js";
4
+ /** 表内 ⇒ 收窄成 `PermissionDeniedSource`(类型守卫)。计量点用它判「这个词是不是本 build 认得的真词」。
5
+ * 导出是为了让运行期锚(`test/tool-trace.test.ts`,从装树 core 的 `.d.ts` 解析真词表)**复用同一个谓词**
6
+ * —— 测试自己另写一份判据 = 又一张手抄表,正是本文件刚消灭的那个病。 */
7
+ export declare function isKnownDenySource(v: unknown): v is PermissionDeniedSource;
4
8
  /**
5
9
  * ALWAYS-ON gate-deny meter (goal B4, 2026-07-08): `permission_denied_total{source}` — how often the
6
10
  * adjudicate chain DENIES a tool call, by gate source. Deliberately SEPARATE from {@link createToolTracer}: