@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
@@ -15,6 +15,7 @@
15
15
  import { Runner, defaultTaskRegistry } from "@sema-agent/core";
16
16
  import { sweepStaleScratchpads } from "../env-facts.js";
17
17
  import { CostQuota } from "../observability/cost-quota.js";
18
+ import { isAdoptionQuiescing, trackMaintenanceLeg } from "../adoption/quiesce.js";
18
19
  import { createApprovalReconciler } from "../approval-reconciler.js";
19
20
  /** #157 / [2762]§五(sema-comms `audits/failopen-governance-176.md` §7 "Z 类 1 条"):default consecutive-failure
20
21
  * count before a reaper leg's swallowed error gets a `warn` line. The ~15 `.catch(() => undefined)` sites
@@ -74,10 +75,22 @@ export function createThrottledReaperCatch(name, logger, threshold = REAPER_FAIL
74
75
  }
75
76
  /** 起后台维护 tick,返回定时器 handle(收尾段 clearInterval 用)。 */
76
77
  export function startReapers(ctx) {
77
- const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, getRunDenySweep, } = ctx;
78
+ const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores, getRunDenySweep, } = ctx;
78
79
  // S7 (SILENT-FALLBACK P0-d): the sweeps' return counts were discarded — an instance death that batch-fails
79
80
  // N orphans was indistinguishable from organic failures. Count + log ONLY when a sweep flipped rows (>0),
80
81
  // so healthy ticks stay silent. Tolerant of void-returning stores (typeof guard).
82
+ /**
83
+ * 一条维护腿的**起飞登记**(codex 对抗复审 R1 [high] 的修复面)。
84
+ *
85
+ * 代替裸 `void <promise>`:语义与 `void` 完全一样(不 await、错误由各腿自己的 guard 接住),
86
+ * 只多一件事 —— 把这一份 promise 登记进收编闸的在飞集,好让一条正要动数据的收编弧**等它落地**。
87
+ * 少一处没换,那条腿就是闸的漏网者(而漏网是静默的),所以本文件里 tick 内**不该再出现裸 `void`**。
88
+ * 入参允许 `undefined`(可选链的腿:`void store?.reap()` 在店缺席时求值成 undefined)。
89
+ */
90
+ const leg = (p) => {
91
+ if (p !== undefined)
92
+ trackMaintenanceLeg(p);
93
+ };
81
94
  const reapCount = (metric, labels) => (n) => {
82
95
  if (typeof n === "number" && n > 0) {
83
96
  metrics.inc(metric, labels, n);
@@ -111,6 +124,8 @@ export function startReapers(ctx) {
111
124
  const rosterReapOlderThanGuard = createThrottledReaperCatch("roster_reap_older_than", logger);
112
125
  const scratchpadSweepStaleGuard = createThrottledReaperCatch("scratchpad_sweep_stale", logger);
113
126
  const approvalReconcileGuard = createThrottledReaperCatch("approval_reconcile", logger);
127
+ const permissionRuleReapGuard = createThrottledReaperCatch("permission_rule_reap_expired", logger);
128
+ let permissionRuleReapInFlight = false; // A-010.17 / codex R2:重入守卫(邻居四腿同形,理由见调用点)
114
129
  let approvalReconcileInFlight = false; // 车5 §8 C-8:重入守卫(邻居四先例同形——本腿时长随 PARKING 行数增长)
115
130
  // #151 车5(design/172 §3.0 对账收敛器 + 崩溃恢复扫描):建腿条件 = 开关开 ∧ **有 `StoreBackend`**
116
131
  // (见下方判据行 —— 是**任意** backend,不限 SQL twins:`local` 车道也算,它的 `approvalAsk()` 是进程内
@@ -237,12 +252,32 @@ export function startReapers(ctx) {
237
252
  metrics.setGauge("fleet_clock_probe_ok", 0);
238
253
  }
239
254
  };
255
+ let quiesceLogged = false;
240
256
  const reaper = setInterval(() => {
241
- void probeClockSkew().then(() => probeClockSkewGuard.onSuccess(), probeClockSkewGuard.onError); // S10
242
- void runStore
257
+ // 🔴 A-010.18(验真后修):**收编弧跑动期间整轮让路**。design/183 I1「引擎先停」此前在进程内
258
+ // 零执法 —— 弧在逐表 UPDATE 的同时,本 tick 的好几条腿写的正是被迁的那些表。最锋利的是审批收敛器:
259
+ // 它按 `owner` 读出一批行再 CAS 结算,而收编正在把 `approval_ask.owner` 从 A 改成 B ⇒ 一只本该
260
+ // PARKED 的 ask 可能被写成 **DENIED**,而 DENIED 是终局(收编回滚不了它,重跑也追认不回来)。
261
+ // 为什么让开**整轮**而不是挑腿避让、以及「只挡本副本」这条射程边界,逐字见 `adoption/quiesce.ts`。
262
+ // 让路是零代价的:每条腿都幂等、都能等下一轮,而弧是毫秒级的几条 UPDATE。
263
+ if (isAdoptionQuiescing()) {
264
+ if (!quiesceLogged) {
265
+ quiesceLogged = true; // 一次性 —— 弧毫秒级,逐 tick 刷屏没有信息量
266
+ logger.info("reaper_tick_yielded_to_adoption", {
267
+ // 文案只说**兑得出来**的话(codex R1 [high] 的教训):本行保证的是「不再**起飞**新腿」。
268
+ // 已经在飞的那些由弧侧的有界排空负责等,等不到会另打一条 `adoption_maintenance_drain_timeout`
269
+ // —— 两条读数合起来才是全貌,所以这一行不许自称「没有任何腿在写被迁的表」。
270
+ note: "an adoption arc is running in this process; the maintenance tick launches NO new legs for its duration. Legs already airborne when the arc began are waited on separately (bounded) — see adoption_maintenance_drain_timeout if that wait ran out (design/183 I1, in-process half)",
271
+ });
272
+ }
273
+ return;
274
+ }
275
+ quiesceLogged = false;
276
+ leg(probeClockSkew().then(() => probeClockSkewGuard.onSuccess(), probeClockSkewGuard.onError)); // S10
277
+ leg(runStore
243
278
  ?.reapStale(config.runStaleSec * 1000)
244
279
  .then(reapCount("runs_reaped_total", { kind: "stale" }))
245
- .then(() => runsReapStaleGuard.onSuccess(), runsReapStaleGuard.onError);
280
+ .then(() => runsReapStaleGuard.onSuccess(), runsReapStaleGuard.onError));
246
281
  // Durable F4 (design/45) + design/80 §3 inv#3 crash-safe backstop: CAS-expire checkpoints past their
247
282
  // deadline OR their absolute terminal_at_ms backstop (≈ deny), then fail the suspended run rows whose
248
283
  // checkpoint was thereby expired (release task_active = unlock the session). These run EVERY tick,
@@ -256,51 +291,51 @@ export function startReapers(ctx) {
256
291
  // 真 pin 只存在于 in-memory TtlSessionStore;本部署 durable checkpoint 恒配 durable session 后端
257
292
  // (pin=no-op),deny-sweep 走 resumeCheckpoint=core 内部 unpin ✓。SESSION_BACKEND=memory+durable
258
293
  // checkpoint 的 niche dev 组合下 abort-expire 臂会把被钉会话泄到进程终——显式接受,不为 dev 形加腿。
259
- void checkpointStore
294
+ leg(checkpointStore
260
295
  .reapExpired(Date.now())
261
296
  .then(reapCount("checkpoints_reaped_total", {}))
262
- .then(() => checkpointsReapExpiredGuard.onSuccess(), checkpointsReapExpiredGuard.onError);
263
- void runStore
297
+ .then(() => checkpointsReapExpiredGuard.onSuccess(), checkpointsReapExpiredGuard.onError));
298
+ leg(runStore
264
299
  ?.failSuspendedWithExpiredCheckpoint()
265
300
  .then(reapCount("runs_reaped_total", { kind: "expired_checkpoint" }))
266
- .then(() => runsFailSuspendedExpiredCheckpointGuard.onSuccess(), runsFailSuspendedExpiredCheckpointGuard.onError);
301
+ .then(() => runsFailSuspendedExpiredCheckpointGuard.onSuccess(), runsFailSuspendedExpiredCheckpointGuard.onError));
267
302
  // D-D SLA-timer: resolve-DENY human/irreversible_ask gates past their deadline (graceful — the model
268
303
  // continues with the denial), vs the abort reapExpired gives the other kinds. Bounded per tick.
269
- void getRunDenySweep()?.(Date.now())
270
- .then(() => runDenySweepGuard.onSuccess(), runDenySweepGuard.onError); // A10 搬运改写:晚绑取值(原 `runDenySweep?.`)
304
+ leg(getRunDenySweep()?.(Date.now())
305
+ .then(() => runDenySweepGuard.onSuccess(), runDenySweepGuard.onError)); // A10 搬运改写:晚绑取值(原 `runDenySweep?.`)
271
306
  }
272
307
  // The finer, OPT-IN time-based suspended-run reaper (APPROVAL_TIMEOUT_SEC). Stays gated — reapSuspended
273
308
  // with a 0 TTL would nuke ALL suspended rows (cutoff = now). The absolute backstop above is the safety
274
309
  // floor; this is the operator-chosen deadline. ([2354] A-5:F4 轮询店的 expireStale 腿随车道退役。)
275
310
  if (config.approvalTimeoutSec > 0) {
276
- void runStore
311
+ leg(runStore
277
312
  ?.reapSuspended(config.approvalTimeoutSec * 1000)
278
313
  .then(reapCount("runs_reaped_total", { kind: "suspended" }))
279
- .then(() => runsReapSuspendedGuard.onSuccess(), runsReapSuspendedGuard.onError);
314
+ .then(() => runsReapSuspendedGuard.onSuccess(), runsReapSuspendedGuard.onError));
280
315
  }
281
316
  // GC checkpoint_ctx rows whose checkpoint is gone (bound to the checkpoint lifecycle).
282
- void checkpointStore
317
+ leg(checkpointStore
283
318
  ?.reapCtx(Date.now() - config.runStaleSec * 1000)
284
- .then(() => checkpointReapCtxGuard.onSuccess(), checkpointReapCtxGuard.onError);
319
+ .then(() => checkpointReapCtxGuard.onSuccess(), checkpointReapCtxGuard.onError));
285
320
  rateLimiter?.sweep();
286
321
  if (costQuota instanceof CostQuota)
287
322
  costQuota.reap(); // TiDB variant self-reaps in its flush loop
288
- void toolResultStore
323
+ leg(toolResultStore
289
324
  ?.reapOlderThan?.(Date.now() - config.toolResultTtlSec * 1000)
290
- ?.then(() => toolResultReapOlderThanGuard.onSuccess(), toolResultReapOlderThanGuard.onError); // optional extra: SQL twins only (the local FileToolResultStore persists like transcripts)
325
+ ?.then(() => toolResultReapOlderThanGuard.onSuccess(), toolResultReapOlderThanGuard.onError)); // optional extra: SQL twins only (the local FileToolResultStore persists like transcripts)
291
326
  // 2c session-sync: GLOBALLY GC grace-window orphan blobs (standalone /sync/blobs PUTs that were never
292
327
  // imported are otherwise collected only on reap()/deleteBySession(), which never fire for a never-imported scope).
293
328
  // Bounds the standalone-PUT orphan-blob exhaustion (review finding) to the grace window. Durable twins only (local
294
329
  // omits the seam → optional-chained no-op).
295
- void fileSnapshotStore
330
+ leg(fileSnapshotStore
296
331
  ?.sweepOrphanBlobs?.()
297
- .then(() => fileSnapshotSweepOrphanBlobsGuard.onSuccess(), fileSnapshotSweepOrphanBlobsGuard.onError);
332
+ .then(() => fileSnapshotSweepOrphanBlobsGuard.onSuccess(), fileSnapshotSweepOrphanBlobsGuard.onError));
298
333
  // D-1 附件 TTL:上传后从未被任何 task 引用(session_id NULL)且超过 attachmentUnboundTtlMs 的行收割
299
334
  // (绑定行不在此收——随会话 E21 级联删)。best-effort,与其余 reaper 腿同姿。
300
- void taskAttachmentStore
335
+ leg(taskAttachmentStore
301
336
  ?.reapUnbound(Date.now() - config.attachmentUnboundTtlMs)
302
337
  .then(reapCount("attachments_reaped_total", {}))
303
- .then(() => attachmentReapUnboundGuard.onSuccess(), attachmentReapUnboundGuard.onError);
338
+ .then(() => attachmentReapUnboundGuard.onSuccess(), attachmentReapUnboundGuard.onError));
304
339
  // D-1 孤儿**对象**彻底 GC(clay 拍 2026-07-28):对象存储 × meta 行对账,grace 默认 1h(上传先行窗
305
340
  // 保护;`ATTACHMENT_ORPHAN_GRACE_MS=0` 关掉本腿——同 tick 邻居都有旋钮,复审 F10)。
306
341
  // 列举失败=本轮 warn 跳过(「列不出来」绝不当「没有孤儿」),下轮再试。
@@ -309,7 +344,7 @@ export function startReapers(ctx) {
309
344
  // wfRunReap/bgAgentReap 都有同款守卫。
310
345
  if (config.attachmentOrphanGraceMs > 0 && !attachmentSweepInFlight && taskAttachmentStore?.sweepOrphanObjects) {
311
346
  attachmentSweepInFlight = true;
312
- void taskAttachmentStore
347
+ leg(taskAttachmentStore
313
348
  .sweepOrphanObjects(config.attachmentOrphanGraceMs)
314
349
  .then((n) => { if (n > 0) {
315
350
  metrics.inc("attachment_orphan_objects_swept_total", {}, n);
@@ -329,21 +364,21 @@ export function startReapers(ctx) {
329
364
  // observability must never crash the tick — see comment above.
330
365
  }
331
366
  })
332
- .finally(() => { attachmentSweepInFlight = false; });
367
+ .finally(() => { attachmentSweepInFlight = false; }));
333
368
  }
334
369
  // P1d-β 2c session-sync: GC ABANDONED staged imports — staging-id session_event rows (`%#stg-%`) with NO
335
370
  // session_meta whose oldest row is older than the grace window (an in-flight stream stays fresh → never reaped).
336
371
  // Bounds the orphan-staging-row growth (a Phase B that opened a staging then never committed). Durable session
337
372
  // stores only (the local backend stages in memory → no durable rows; the seam is absent → optional-chained no-op).
338
- void backend?.session()?.sweepStagingSessions?.()
339
- .then(() => sessionSweepStagingGuard.onSuccess(), sessionSweepStagingGuard.onError);
373
+ leg(backend?.session()?.sweepStagingSessions?.()
374
+ .then(() => sessionSweepStagingGuard.onSuccess(), sessionSweepStagingGuard.onError));
340
375
  // Bake-runner backstop (§P2.7): fail any `running` bake whose lease went stale (the runner crashed mid-build),
341
376
  // append a synthetic terminal `done{failed}` so SSE readers settle, and force-release the single-flight lease.
342
377
  // staleMs (≈3× the 30s heartbeat) bounds a healthy slow build so it is never wrongly reaped.
343
- void imageBakes
378
+ leg(imageBakes
344
379
  ?.reapStaleBakes(config.imageBakes.staleMs)
345
380
  .then(reapCount("bakes_reaped_total", {}))
346
- .then(() => imageBakesReapStaleGuard.onSuccess(), imageBakesReapStaleGuard.onError);
381
+ .then(() => imageBakesReapStaleGuard.onSuccess(), imageBakesReapStaleGuard.onError));
347
382
  // SVC-3 worktree isolation: deregister worktrees orphaned by a process crash (the Runner never reached
348
383
  // destroy → `git worktree remove` never ran). `git worktree prune` cleans registrations whose dirs are
349
384
  // already gone. Best-effort, userland (core ships no post-kill Runner hook); never throws. Unset = no-op.
@@ -353,22 +388,22 @@ export function startReapers(ctx) {
353
388
  // 照抄 bgAgentReap 的 in-flight + finally 释放形。
354
389
  if (worktreeReap && !worktreeReapInFlight) {
355
390
  worktreeReapInFlight = true;
356
- void worktreeReap()
391
+ leg(worktreeReap()
357
392
  .then(() => worktreeReapGuard.onSuccess(), worktreeReapGuard.onError)
358
- .finally(() => (worktreeReapInFlight = false));
393
+ .finally(() => (worktreeReapInFlight = false)));
359
394
  }
360
395
  // SVC-1 (adversarial-review HIGH): PERIODIC notify-recovery sweep (not just at boot) — re-delivers a terminal
361
396
  // run whose in-process notify was lost, AND finalizes-as-abandoned a `running` run orphaned past the grace
362
397
  // window (core never resumes/reaps a prior `running` row, so nothing else would). Idempotent; best-effort.
363
- void workflowNotifyGate
398
+ leg(workflowNotifyGate
364
399
  ?.recover(workflowRecoverOpts)
365
- .then(() => workflowNotifyRecoverGuard.onSuccess(), workflowNotifyRecoverGuard.onError);
400
+ .then(() => workflowNotifyRecoverGuard.onSuccess(), workflowNotifyRecoverGuard.onError));
366
401
  // SVC-2 (adversarial-review HIGH): time-based GC of the workflow_journal table (the heaviest, TaskResult-bearing
367
402
  // one) — the per-run deleteByRun has no run-store reap hook, so this bounded sweep is what stops unbounded
368
403
  // growth. A resume of a journal older than the retention window re-runs live (resume is an optimization).
369
- void workflowJournalStore
404
+ leg(workflowJournalStore
370
405
  ?.reapExpired?.(Date.now(), config.workflowJournalRetentionMs)
371
- .then(() => workflowJournalReapExpiredGuard.onSuccess(), workflowJournalReapExpiredGuard.onError);
406
+ .then(() => workflowJournalReapExpiredGuard.onSuccess(), workflowJournalReapExpiredGuard.onError));
372
407
  // Retention (WorkflowRunStore never auto-purges, reap is explicit):
373
408
  // age out TERMINAL workflow_run rows across ALL scopes. SQL twins only (reapAllScopes is their DISTINCT-scope
374
409
  // extension; the contract itself has no cross-scope enumeration, and the File store keeps the transcripts-like
@@ -379,30 +414,30 @@ export function startReapers(ctx) {
379
414
  const sweep = sqlWorkflowRunStore?.reapAllScopes?.(Date.now(), { maxAgeMs: config.workflowRunRetentionMs });
380
415
  if (sweep) {
381
416
  wfRunReapInFlight = true;
382
- void sweep
417
+ leg(sweep
383
418
  .then(() => workflowRunReapAllScopesGuard.onSuccess(), workflowRunReapAllScopesGuard.onError)
384
- .finally(() => (wfRunReapInFlight = false));
419
+ .finally(() => (wfRunReapInFlight = false)));
385
420
  }
386
421
  // 1.108: SQL notify-journal retention rides the same knob — ACKED rows are pure history (pending rows are
387
422
  // the recovery backlog and are NEVER reaped; the orphan-grace sweep retires a stuck pending run).
388
- void workflowNotifyJournal
423
+ leg(workflowNotifyJournal
389
424
  ?.reapAcked?.(Date.now() - config.workflowRunRetentionMs)
390
- .then(() => workflowNotifyJournalReapAckedGuard.onSuccess(), workflowNotifyJournalReapAckedGuard.onError);
425
+ .then(() => workflowNotifyJournalReapAckedGuard.onSuccess(), workflowNotifyJournalReapAckedGuard.onError));
391
426
  }
392
427
  // core 1.364 durable bg-agent joint reap(定义在 interval 上方,契约注释在彼)。in-flight 守卫同
393
428
  // wfRun sweep(per-scope 串行循环,短 tick + 多 scope 下不叠罗汉)。
394
429
  if (reapBgAgents && !bgAgentReapInFlight) {
395
430
  bgAgentReapInFlight = true;
396
- void reapBgAgents()
431
+ leg(reapBgAgents()
397
432
  .then(() => bgAgentsReapGuard.onSuccess(), bgAgentsReapGuard.onError)
398
- .finally(() => (bgAgentReapInFlight = false));
433
+ .finally(() => (bgAgentReapInFlight = false)));
399
434
  }
400
435
  // [1522] MED2:agent_roster TTL 清理(core RB-23②③ 派给部署的半场)——SQL twins 扩展面
401
436
  // (reapOlderThan,duck probe;File/Memory 店 core 自带 maxAgeMs,无此面=no-op)。
402
- void rosterStore
437
+ leg(rosterStore
403
438
  ?.reapOlderThan?.(Date.now() - config.rosterRetentionMs)
404
439
  .then(reapCount("roster_rows_reaped_total", {}))
405
- .then(() => rosterReapOlderThanGuard.onSuccess(), rosterReapOlderThanGuard.onError);
440
+ .then(() => rosterReapOlderThanGuard.onSuccess(), rosterReapOlderThanGuard.onError));
406
441
  // 修8(三路复审 absorb-2,接线 (b)):periodic scratchpad sweep — 无 E21 purge 兜到的孤儿目录(session 从未
407
442
  // DELETE、purge 当次失败、local 后端无 purge coordinator)按 mtime 过期回收。SCRATCHPAD_SWEEP_TTL_MS
408
443
  // (default 7d,0=禁用)。activeSessionIds 不传:活跃判据的诚实边界在 env-facts.ts 的 sweep 文档——TTL 7d
@@ -413,18 +448,40 @@ export function startReapers(ctx) {
413
448
  // 库延迟增长,短 REAP_INTERVAL_SEC 下无守卫会逐 tick 叠罗汉压同一批行。
414
449
  if (approvalReconciler && !approvalReconcileInFlight) {
415
450
  approvalReconcileInFlight = true;
416
- void approvalReconciler
451
+ leg(approvalReconciler
417
452
  .runOnce(Date.now())
418
453
  .then(() => approvalReconcileGuard.onSuccess(), approvalReconcileGuard.onError)
419
- .finally(() => (approvalReconcileInFlight = false));
454
+ .finally(() => (approvalReconcileInFlight = false)));
455
+ }
456
+ // A-010.17:规则店的保留期腿。**只删可证已死的行**(过期导入票 + 超期孤儿 pending 记录)——
457
+ // 谓词、每轮上界、以及「为什么这条腿不需要旋钮」逐字见 `plugins/permission-rule-store-sql.ts`
458
+ // 的 `reapExpired` / `RULE_REAP_BATCH`。
459
+ // `?.` 的缺席臂 = File 车道没有这一面(如实登记在 `rules-consent.ts` 的接口注里,不是漏接)。
460
+ //
461
+ // 🔴 重入守卫(codex 对抗复审 R2 [high],验真后修):第一版按「两条等值 DELETE,时长不随在飞量增长」
462
+ // 判定无需守卫 —— 那句话**不成立**。`permission_rule_approval` 按设计永久留审计事实(只增不减),
463
+ // 而 MySQL 存量库在删库重建之前拿不到新加的清扫索引 ⇒ 这条腿的时长会随**审计史**增长。一旦越过
464
+ // tick 间隔,下一轮就叠在同一批行上。判据与邻居四腿(attachmentSweep / wfRunReap / bgAgentReap /
465
+ // worktreeReap)同形。
466
+ if (!permissionRuleReapInFlight) {
467
+ permissionRuleReapInFlight = true;
468
+ leg(permissionRuleStores
469
+ ?.reapExpired?.(Date.now())
470
+ .then(reapCount("permission_rules_reaped_total", {}))
471
+ .then(() => permissionRuleReapGuard.onSuccess(), permissionRuleReapGuard.onError)
472
+ .finally(() => (permissionRuleReapInFlight = false)));
473
+ // 店缺席(File 车道 / 关旋钮)⇒ 上面整条求值成 undefined、`finally` 永不跑 ⇒ 守卫要当场归位,
474
+ // 否则第一轮之后这条腿就被自己的守卫永久锁死(而它本来就该零调用,锁死是静默的)。
475
+ if (permissionRuleStores?.reapExpired === undefined)
476
+ permissionRuleReapInFlight = false;
420
477
  }
421
478
  if (config.scratchpadSweepTtlMs > 0) {
422
- void sweepStaleScratchpads(config.localDataRoot ?? localRoot, { olderThanMs: config.scratchpadSweepTtlMs })
479
+ leg(sweepStaleScratchpads(config.localDataRoot ?? localRoot, { olderThanMs: config.scratchpadSweepTtlMs })
423
480
  .then((removed) => {
424
481
  if (removed > 0)
425
482
  logger.info("scratchpads_swept", { removed });
426
483
  })
427
- .then(() => scratchpadSweepStaleGuard.onSuccess(), scratchpadSweepStaleGuard.onError);
484
+ .then(() => scratchpadSweepStaleGuard.onSuccess(), scratchpadSweepStaleGuard.onError));
428
485
  }
429
486
  }, config.reapIntervalSec * 1000);
430
487
  reaper.unref?.();
@@ -46,7 +46,7 @@ import { mcpForScenario } from "../config-center/facade.js";
46
46
  import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, normalizeSuggestNextPrompts, promptProfileFromBody, resolveTaskLimits, retainBackgroundProcessesFromBody, taskAgentsSpecFragment, toolMaterializeStrategyFromBody, toolNameListFromBody } from "../spec-fields.js";
47
47
  import { cwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
48
48
  import { resolveRequestMcp } from "../task-mcp.js";
49
- import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, coercePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, withPermissionMode } from "../task-settings.js";
49
+ import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, shellGateForMode, withPermissionMode } from "../task-settings.js";
50
50
  import { enableForkFromBody, normalizeRetainSubagentSessions, selfOrchestrationFromBody } from "../task-workflow.js";
51
51
  import { redactSecrets } from "../trace/redact.js";
52
52
  import { DeferredSandboxPathEnv, isSandboxPathAdjudicationLane, sandboxPathEnvSlots } from "./deferred-sandbox-path-env.js";
@@ -77,10 +77,15 @@ export function createResolveSpec(ctx) {
77
77
  // A10 留档件#2:六阶段流水线。顺序=原文顺序(每一阶段的体逐字搬运),阶段之间只传显式产出;
78
78
  // 六个阶段函数定义在本 `return` 之后(自上而下先读骨架、再读各阶段)。
79
79
  const gated = await gateScenarioAndAppend(body, auth, opts);
80
- const lane = bindSettingsCwdEnvAndModel(body, auth, gated);
80
+ // design/201 §6-1:本请求的**生效** permission mode —— 一次算,三处消费(hooks 载荷的
81
+ // `permission_mode`、阶段④的 shellGate 翻译写点、阶段⑤的 settings 折叠)。此前前两者各算一次,
82
+ // 翻译表要是再开第三个算点,「顶层键 vs bundle defaultMode 谁赢」这条优先序就有三份可以各自漂移的
83
+ // 副本,而漂移在这条轴上的形态是「壳发的 bundle bypass 在这一面生效、在那一面没生效」——三审同点。
84
+ const effMode = effectivePermissionMode(body, gated.parsedSettings.settings);
85
+ const lane = bindSettingsCwdEnvAndModel(body, auth, gated, effMode);
81
86
  const anchors = await resolveHistoryAnchors(body, auth);
82
- const spec = assembleSpecLiteral(body, auth, opts, { ...gated, ...lane, ...anchors });
83
- const folded = await foldGovernanceAndSettings(body, auth, spec, gated);
87
+ const spec = assembleSpecLiteral(body, auth, opts, { ...gated, ...lane, ...anchors }, effMode);
88
+ const folded = await foldGovernanceAndSettings(body, auth, spec, gated, effMode);
84
89
  const final = await applyImageFactsAndRouting(body, auth, folded.governed, {
85
90
  scenarioName: gated.scenarioName,
86
91
  scratchpadDir: folded.scratchpadDir,
@@ -204,7 +209,7 @@ export function createResolveSpec(ctx) {
204
209
  /** 阶段②(settings·cwd·env):吃 请求体 + auth + 阶段①的 objective/parsedSettings,吐 taskHooks、
205
210
  * additionalDirectories、档位展开后的 wireCatalog 与选中的 picked;副作用=per-session cwd/shellEnv 注册与
206
211
  * honored/ignored 日志、未知模型回落与无视觉模型降级的计数/告警。 */
207
- const bindSettingsCwdEnvAndModel = (body, auth, gated) => {
212
+ const bindSettingsCwdEnvAndModel = (body, auth, gated, effMode) => {
208
213
  const { objective, parsedSettings } = gated;
209
214
  // [#40 / TOC cwd seam] register the caller's launch dir so the HOST factory runs the agent there (read by
210
215
  // ctx.sessionId). 🔒 GATED: only the single-user host lane (cwdHonored) honors it; on any other lane / multi-tenant
@@ -259,8 +264,8 @@ export function createResolveSpec(ctx) {
259
264
  }
260
265
  // payload 的 permission_mode 用【生效】模式——body.permissionMode(显式请求)优先于
261
266
  // settings.permissions.defaultMode(与 spec 的 withPermissionMode 同一优先序),否则钩子读到的模式与
262
- // 实际裁决模式不一致(如 body=plan 时钩子仍看到 default)。
263
- const effMode = coercePermissionMode(body.permissionMode) ?? parsedSettings.settings?.permissions?.defaultMode;
267
+ // 实际裁决模式不一致(如 body=plan 时钩子仍看到 default)。design/201 起该优先序由
268
+ // `effectivePermissionMode` 单点裁定,本处只消费(形参 `effMode`),不再自算一份。
264
269
  return createTaskHooks(hc, {
265
270
  logger,
266
271
  sessionId: auth?.sessionId ?? "",
@@ -302,6 +307,8 @@ export function createResolveSpec(ctx) {
302
307
  // containment onto operator/other-tenant host paths). Unlike cwd/shellEnv (registered per-session for the host
303
308
  // FACTORY to read at env construction), additionalDirectories is a plain TaskSpec field core reads directly, so
304
309
  // it rides onto the spec below. Ignored (logged, never silent) off the host lane. Re-applies on resume (from body).
310
+ // 键在 `TaskRequestBody` 上显式声明(#205 件1)⇒ 直读,不再裸铸([2704] 边界必 schema)。
311
+ // 运行期校验保留:resume 腿重放的是**持久化 body**,不过 HTTP 400 门,类型说得再准也管不住盘上的字节。
305
312
  const rawAddDirs = body.additionalDirectories;
306
313
  const additionalDirectories = cwdHonored(config) ? parseAdditionalDirectories(rawAddDirs) : undefined;
307
314
  if (rawAddDirs !== undefined && !cwdHonored(config)) {
@@ -311,7 +318,7 @@ export function createResolveSpec(ctx) {
311
318
  // 与 additionalDirectories 同门(host 单用户车道)、同形校验(parseAdditionalDirectories:绝对路径
312
319
  // 数组 fail-loud)、同「忽略必留声」纪律;差异只在语义:只进 READ 面(classify 读边界 auto-allow +
313
320
  // read_file/grep 等 containment),写面照旧拒——读授权永不静默变写授权(core 侧保证,我们只透传)。
314
- const rawAddReadDirs = body.additionalReadDirectories;
321
+ const rawAddReadDirs = body.additionalReadDirectories; // 同上:直读(#205 件1)
315
322
  const additionalReadDirectories = cwdHonored(config) ? parseAdditionalDirectories(rawAddReadDirs) : undefined;
316
323
  if (rawAddReadDirs !== undefined && !cwdHonored(config)) {
317
324
  logger.warn("task_additional_read_directories_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
@@ -409,7 +416,7 @@ export function createResolveSpec(ctx) {
409
416
  };
410
417
  /** 阶段④(spec 字面量):吃 请求体 + auth + `opts.leg` + 前三阶段的全部产出,吐**尚未过治理层**的 TaskSpec
411
418
  * 字面量 —— 部署方拥有的 body→TaskSpec 映射本体(compactionModel 的 fresh 腿 400 也在这一段)。 */
412
- const assembleSpecLiteral = (body, auth, opts, parts) => {
419
+ const assembleSpecLiteral = (body, auth, opts, parts, effMode) => {
413
420
  const { scenarioName, cap, hands, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, additionalReadDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
414
421
  const spec = {
415
422
  // D-1:附件告知随 objective 进 durable 流(只有名字/mime/尺寸——**内容永不进流**,这正是
@@ -451,6 +458,8 @@ export function createResolveSpec(ctx) {
451
458
  // 历史复审轴B #5(1.254):compactionModel 已接([1479]①)而 design/145 配套旋钮 clampTolerance
452
459
  // 够不着=不对称。窄收单键(0..1 数值;其余 compaction 键仍是操作方轴,有意不开)。
453
460
  ...(() => {
461
+ // 键已在 `TaskRequestBody` 上声明为 `{ clampTolerance?: number }`(#205 件1)⇒ 直读;
462
+ // 下面的 typeof/区间校验照旧(resume 重放持久化 body 不过 HTTP 门)。
454
463
  const ct = body.compaction?.clampTolerance;
455
464
  return typeof ct === "number" && Number.isFinite(ct) && ct >= 0 && ct <= 1 ? { compaction: { clampTolerance: ct } } : {};
456
465
  })(),
@@ -508,6 +517,18 @@ export function createResolveSpec(ctx) {
508
517
  // out). USER-driven plan (permissionMode=plan) ADDS handsReadOnly on top via applyTaskSettings (which also sets
509
518
  // enablePlanMode, idempotent). Additive — widens nothing; the model self-selects when to use it.
510
519
  enablePlanMode: config.planModeEnabled ? true : undefined,
520
+ // design/201 §2:**显式** permission mode → shell 门档位的翻译(bypassPermissions ⇒ "off",
521
+ // auto/default/acceptEdits/plan ⇒ "classify";表逐字在 `shellGateForMode`)。这一档是 governance 的
522
+ // **base** —— 部署旋钮 `MANUAL_MODE_SHELL_GATE` 与 `AUTONOMY=ask` 在其后的 governance 拍只升不降地
523
+ // 覆盖它(#153 那条无条件施加的腿一行未动),所以客户端表态**减不掉**任何 operator 面的收紧。
524
+ //
525
+ // 🔴 **表态缺席 ⇒ 不写这个键**(不是写 "off",也不是写 "classify"):
526
+ // · 写 "classify" 会让今天 env 真缺席的**无壳直连**部署(多租户云 / OA / scan 车道 / SDK 直连)
527
+ // 在一个 minor 版本里静默收紧,而它们多半没有审批面 ⇒ 失败形是 headless auto-deny;
528
+ // · 写 "off" 与不写在 core 的**默认**上等价,但在本进程里不等价 —— 写下去它就成了 governance 的
529
+ // base,把 runtime-governance 那条「base 更严则省略」的防御臂喂上一个人造输入。
530
+ // 缺席不写 = 今日行为逐字节不变,车道缺省由壳恒 stamp mode 保证(设计 §3 前提③)。
531
+ ...(effMode ? { shellGate: shellGateForMode(effMode) } : {}),
511
532
  // Workflow super-set unlock (same shape as the rank-1 roster gap): per-task activation of the
512
533
  // LLM-authored workflow engine (core `run_workflow` + the workflow.ts agent/parallel/pipeline orchestration).
513
534
  // The engine is BUILT but the HTTP API never mapped a body field to `spec.selfOrchestration`, so run_workflow
@@ -572,6 +593,14 @@ export function createResolveSpec(ctx) {
572
593
  ...(typeof body.interactiveTools === "boolean"
573
594
  ? { interactiveTools: body.interactiveTools }
574
595
  : {}),
596
+ // [1909]⑧(core 5.23.0 `TaskSpec.oneShot`):这次**提交**是不是一次性的 —— 没有后续回合能接住异步
597
+ // 后台通知(headless `sema -p` 的进程随本回合退出)。core 只拿它改 run_workflow / 委派回执的**指引
598
+ // 文本**(告诉模型主动 block-wait,而不是「结束回合等通知」——后者在一次性进程里必丢结果),不授予
599
+ // 任何能力 ⇒ 无租户门,与 interactiveTools 同姿势直通。
600
+ // 🔴 **表态在场才写、缺席不写键**(与 shellGate 同形):不写 = core 默认(交互态)逐字节不变;写一个
601
+ // 「默认值」上去等于替没表态的调用方做了表态。非 boolean(resume 重放持久化 body / 提交面已 400)
602
+ // 同样不写键。四条 rebuild 腿读的是同一份持久化 body ⇒ 首跑与续跑天然同值。
603
+ ...(typeof body.oneShot === "boolean" ? { oneShot: body.oneShot } : {}),
575
604
  // design/131 (core 1.246): per-task resilience INTENT flags. allowDegrade/allowFailover are
576
605
  // caller-facing (a bench/eval run wants true failure shapes); bypassBreaker is operator-only (normalizer
577
606
  // drops it for non-operators — it punches through a SHARED breaker). All-absent = byte-compat.
@@ -816,7 +845,7 @@ export function createResolveSpec(ctx) {
816
845
  /** 阶段⑤(governance 折叠):吃 阶段④的 spec + 请求体 + auth + 阶段①的 append/settings 三量,吐 折完运营方
817
846
  * governance 与客户端 settings(tighten-only)后的 governed,外加两个下游还要用的量:本请求的 scratchpadDir
818
847
  * 与 hostSemanticsLane 判别。 */
819
- const foldGovernanceAndSettings = async (body, auth, spec, gated) => {
848
+ const foldGovernanceAndSettings = async (body, auth, spec, gated, effMode) => {
820
849
  const { appendLessPack, acceptedAppend, parsedSettings } = gated;
821
850
  // Runtime governance "second baton" (center §10): compile the operator's autonomy + commandPolicy onto the
822
851
  // spec TIGHTEN-ONLY. commandPolicy layers via combinePolicies onto the approval/durable baseline above (NOT a
@@ -871,8 +900,10 @@ export function createResolveSpec(ctx) {
871
900
  // five CC modes are honored as gate-SHAPE choices (see coercePermissionMode/deriveSettingsPolicy): `plan` ⇒
872
901
  // read-only hands + present_plan; `default`/`auto` ⇒ the fs-write ask gate; `acceptEdits` ⇒ the cwd-domain
873
902
  // variant; `bypassPermissions` ⇒ no mode gate (the deployment baseline is composed above and untouchable).
874
- const bodyMode = coercePermissionMode(body.permissionMode);
875
- let effectiveSettings = bodyMode ? withPermissionMode(parsedSettings.settings, bodyMode) : parsedSettings.settings;
903
+ // design/201 §6-1:折叠也读**同一只** effMode(顶层键赢过 bundle defaultMode 的裁定在
904
+ // `effectivePermissionMode` 里说一次)。effMode 来自 bundle 时这里把同值原样写回,是恒等折叠;
905
+ // 缺席时整条 settings 原样透传(无表态请求的 pre-[816] 行为不变)。
906
+ let effectiveSettings = effMode ? withPermissionMode(parsedSettings.settings, effMode) : parsedSettings.settings;
876
907
  // codex R3 second bypass: settings.outputStyle ALSO lands in spec.appendSystemPrompt (applyTaskSettings
877
908
  // fold) — on an append-less pack core would discard it silently, exactly like the top-level rider. Same
878
909
  // disposition: fresh submits 400 at the HTTP gate; here (the resume mirror + any leg the gate didn't see)
@@ -87,6 +87,20 @@ export interface RunnerDepsCtx {
87
87
  /** #154 车二:持久化权限规则店 provider(core `RunnerDeps.permissionRuleStore`)。缺席 ⇒ 引擎的
88
88
  * `permissionRules.storeWired` 如实报 false、`AskRequest.ruleSuggestions` 不铸(诚实缺席)。 */
89
89
  permissionRuleStore: RunnerDeps["permissionRuleStore"];
90
+ /**
91
+ * 交接件⑤ —— commit 尾注的署名座(`RunnerDeps.hands.commitCoAuthor`)。
92
+ *
93
+ * 🔴 为什么它属于**共享基座**而不是主 runner 的差异键:署名是**部署身份**([931]① clay 拍:
94
+ * 「署名 = 产品身份资产,归部署」),不是「哪一只 Runner 在跑」的属性。一个被委派出去的子代
95
+ * 提交进的是**同一个仓**、代表的是**同一个部署** —— 它的 commit 少一行 trailer 没有任何理由。
96
+ * 修前两条腿都漏:`main.ts` 与 `run-local.ts` 的 subRunner 都只在主 runner 上写了这一键,而两处
97
+ * 的「差异键」注释块逐条列了 sessionStore / checkpointStore / 四个座位,**都没提 hands**
98
+ * —— 也就是说它不是一次有理由的分歧,是漏配(与本函数头注记的 [1543]§三族A 同一个病族)。
99
+ * 由**调用方**给值(而不是在基座里按 `config` 现算):两条腿的判据本就不同(HTTP 腿按
100
+ * `configProvider === "local"` 分 branded/非 branded,run-local 恒是 Sema 本地形),
101
+ * 各自算一次、传进来一次,两只 Runner 自动同源。
102
+ */
103
+ hands: RunnerDeps["hands"];
90
104
  executionEnvFactory: RunnerDeps["executionEnvFactory"];
91
105
  lspManager: RunnerDeps["lspManager"];
92
106
  fleetBus: FleetEventBus;
@@ -102,7 +116,7 @@ export interface RunnerDepsCtx {
102
116
  }
103
117
  /** design/158 A10 留档发现②:main runner `RunnerDeps` 与 main.ts subRunner 字面量之间此前手工重复
104
118
  * 的 ~15 个键,类型标注见 {@link createSharedRunnerDeps} 头注。 */
105
- export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores">;
119
+ export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "hands" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores">;
106
120
  /**
107
121
  * design/158 A10 留档发现②(review 2026-07-29,[1543]§三族A 同源修补的延续):main runner 的
108
122
  * `RunnerDeps` 字面量(下方 `createRunnerDeps`)与 `main.ts` 里 subRunner 的 `new Runner({...})`
@@ -131,7 +145,7 @@ export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "
131
145
  * 属性,比再抽一层共享基座更强的同源保证),不重复收纳进这里。
132
146
  */
133
147
  /** 基座真实消费的窄面(Pick)——subRunner 调用点(main.ts)只需凑这 13 个字段,不必造全量 ctx。 */
134
- export type SharedRunnerDepsCtx = Pick<RunnerDepsCtx, "config" | "brain" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "deploymentHooks" | "toolResultStore" | "sessionPolicyStore" | "usageWindowStore" | "orgMemoryAdmission" | "sharedMemoryStores">;
148
+ export type SharedRunnerDepsCtx = Pick<RunnerDepsCtx, "config" | "brain" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "deploymentHooks" | "toolResultStore" | "sessionPolicyStore" | "hands" | "usageWindowStore" | "orgMemoryAdmission" | "sharedMemoryStores">;
135
149
  export declare function createSharedRunnerDeps(ctx: SharedRunnerDepsCtx): SharedRunnerDeps;
136
150
  export declare function createRunnerDeps(ctx: RunnerDepsCtx): RunnerDeps;
137
151
  //# sourceMappingURL=runner-deps.d.ts.map
@@ -50,6 +50,8 @@ export function createSharedRunnerDeps(ctx) {
50
50
  // [1070]① agent-team S1:持久名册 seam(具名 spawn advisory 写入;SendMessage 活注册表 miss 后咨询)。
51
51
  rosterStore: ctx.rosterStore ? ctx.rosterStore : undefined,
52
52
  hooks: ctx.deploymentHooks,
53
+ // 交接件⑤:署名座进基座 ⇒ 主/sub 两只 Runner 自动同源(理由见 RunnerDepsCtx.hands 的注)。
54
+ hands: ctx.hands,
53
55
  toolResultStore: ctx.toolResultStore,
54
56
  // E6: operator-tightened session tool rules — core folds them into the ToolPolicy FIRST (subtract-only) for tasks
55
57
  // carrying a sessionId (a delegated subagent has none → inherits no rules). Opt-in: undefined ⇒ no rules read.
@@ -200,10 +202,9 @@ export function createRunnerDeps(ctx) {
200
202
  // toolResultStore/sessionPolicyStore —— 共享基座展开,见 createSharedRunnerDeps 头注
201
203
  // (含每键各自的历史 rationale 注释,搬到了那个函数里,不在此重复)。
202
204
  ...sharedRunnerDeps,
203
- // [931]① clay 拍(core 1.300 BREAKING:缺省不署 Co-Authored-By,署名=产品身份资产归部署):
204
- // branded 形态(local provider = Sema 产品线,scenarios brandIdentity 同判据)commit 尾注接 Sema 署名;
205
- // brand 部署维持 core 新缺省(不署)。seam=RunnerDeps.hands.commitCoAuthor。
206
- hands: config.configProvider === "local" ? { commitCoAuthor: "Sema <noreply@vivi-ai.com>" } : undefined,
205
+ // 交接件⑤:`hands` 已随共享基座展开(`...sharedRunnerDeps`)—— 这里**不再**手写同名键,
206
+ // 否则它会 override 展开、把 subRunner 重新甩开(本文件头注写死的那条:「不要再在任一调用点
207
+ // 手写同名键」)。判据([931]① branded 才署名)搬到了唯一的取值点 main.ts 的 `commitHands`。
207
208
  // design/73 §1 (core 1.226 seam, clay 拍 2026-07-04 接): consume mechanical TaskOutcome facts into the
208
209
  // outcome ledger — tidb/pg = SQL rows (coreOutcomeToLedgerRow mapping + verbatim `core_outcome` JSON so
209
210
  // red-line ② oracleHadRedRun survives lossless), local = owner-only JSONL. Read-only v1: records facts,
package/dist/budget.js CHANGED
@@ -305,6 +305,28 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
305
305
  // not counted here: `compacted` is a TaskEvent riding the run stream, not a trace frame.
306
306
  metrics.inc("compaction_events_total", { outcome: e.kind.slice("compaction.".length), trigger: e.trigger ?? "none" });
307
307
  }
308
+ else if (e.kind === "permission.persisted_rule_allowed" || e.kind === "permission.rule_store_unreadable") {
309
+ // 🔴 [3372] core 5.23.0 提货批,codex 复审 round2 [medium](验真后修)。首版把这两帧登记成
310
+ // 「丢弃,已有等价信号」——**那条理由是假的**(亲验:`plugins/permission-rule-store-sql.ts` 里既没有
311
+ // `noteStoreError` 也没有 `onCorruptRead`),而它们又是**真可达**的(#154 车二把规则店接上了)。
312
+ // 一条假理由比没有理由更坏:台账的全部价值就在于它写的话是真的。
313
+ //
314
+ // 两帧各自答一个**别处答不出来**的问题,而且都在安全轴上:
315
+ // · `persisted_rule_allowed` —— design/179 明写它是 allow 侧的**归因通道**:「这次为什么没问我?」
316
+ // `permission_denied_total` 的补集答不了(它连「有没有走规则」都不知道),`tool_trace` 是默认关
317
+ // 的诊断旋钮、也不带规则归因。持久规则店是本装配里**唯一的放宽缝**,它每放行一次都该有账。
318
+ // · `rule_store_unreadable` —— 规则店读不出 ⇒ 该次调用按**零规则**裁决(诚实地问,而不是假装空店)。
319
+ // 后果是一场**审批风暴**:运维看到的是「卡突然变多」,而病因在店上。没有这个计数就只能靠猜。
320
+ // 标签有界:`event` 二取一(闭集,由上面的 `===` 判别式本身封闭)。规则原文/店的错误句**不进标签**
321
+ // (前者是用户数据、后者无界)——它们该去结构化日志,不是 Prometheus 基数。
322
+ metrics.inc("permission_rule_events_total", { event: e.kind.slice("permission.".length) });
323
+ }
324
+ // ── 未命中 = 该帧不进 /metrics ────────────────────────────────────────────────────────────────
325
+ // 这条 if/else 链没有 else:没命中的 `TraceEvent` 就地丢弃。对多数 kind 这是对的(计量面不该给每个
326
+ // 诊断帧开计数器),但「丢」必须是**裁定**而不是漏接。[3372](core 5.23.0 一次给安全轴加了三个
327
+ // `permission.*` 帧)之后,该族的逐条处置记在 `trace/core-keyset-guard.ts` 的 ⑦ 面并由 tsc 守着:
328
+ // core 再加一个 `permission.*` 帧,那里编译期先红,逼人裁「接还是不接」。其余族(compaction/brain/
329
+ // task/…)仍按本链的显式分支处置,无门。
308
330
  };
309
331
  }
310
332
  /**
@@ -552,8 +552,20 @@ export interface ServiceConfigFlat {
552
552
  approvalAutoBudget: number;
553
553
  /** design/80 D-E (inv#2 — safety asks are never budgetable): gated tools that ALWAYS require a human even when
554
554
  * the auto-budget is on — the "irreversible / never auto-approve" set. empty = the budget may auto-approve any
555
- * required tool. (In-service every gated ask is gate.kind="human", so this set is the ONLY way to keep an
556
- * irreversible tool human-gated under an auto-budget.) `APPROVAL_NEVER_AUTO`.
555
+ * required tool THIS layer sees. `APPROVAL_NEVER_AUTO`.
556
+ *
557
+ * 🔴 **NOT the only human-gate under an auto-budget**(2026-08-10 修口,#205 件2)。这里原来写的是
558
+ * "(In-service every gated ask is gate.kind=\"human\", so this set is the ONLY way to keep an irreversible
559
+ * tool human-gated under an auto-budget.)" —— 两句都已过期,且过期方向是**高估本旋钮的责任**:
560
+ * · design/80 D-2 起,带 egress / irreversibility 静态标记的工具铸的是 `irreversible_ask`,不是 `human`;
561
+ * · core 5.24.0(#130/#131/#120)起,带 `requiresRealApproval` 的 ask 一律铸
562
+ * `irreversible_ask` + `gate.realApproval:{origin}` + checkpoint v8 —— **不再铸可预算的 `human`**。
563
+ * 而 `requiresRealApproval` 的生产者(`createTranscriptIntegrityPolicy` / `createUnverifiableDeletePolicy`)
564
+ * 被 core 在 `prepare-task.js` 里**无条件**焊进每一条策略链,所以这条路径在**每一个**部署上都活着,
565
+ * 不是 org 治理部署的专属面。
566
+ * ⇒ 本旋钮承担的是**本层**(`createDurableAskPolicy` 的 per-leg 计数预算)的 inv#2;安全类 ask 另有
567
+ * core 侧那两层守卫兜底,两层各自独立、不互为前提。消费侧钉:`test/durable-approval-policy.test.ts`
568
+ * 的「#205 件2」describe(含反向锚:普通调用仍走预算自动批)。
557
569
  *
558
570
  * 🔴 **NOT required to be a subset of `approvalRequire`**(2026-07-31 修口)。这里原来写的是
559
571
  * "Subset of approvalRequire",而 `approval.ts` 的 inv#2 执行面逐字相反:
@@ -869,19 +881,27 @@ export interface ServiceConfigFlat {
869
881
  toolApprovalEnabled: boolean;
870
882
  /**
871
883
  * #154 车二(core design/179 持久化权限规则「不再询问」车道)**总开关**。`PERMISSION_RULES_ENABLED`,
872
- * **默认 OFF**。
884
+ * **#203 起默认 ON**(clay [3396] 裁)。
885
+ *
886
+ * 🔴 它曾经默认 OFF,而那个默认有**唯一**一条理由(codex 车二 round7 [high] 一):这条车道**只会加
887
+ * 放行、不会减**,而车二**没有撤销面**——列出 / 删除自己已存规则的口子没建,core 的
888
+ * `removePersistedRule` 在包里但本仓零调用点。一次误导入的全局规则因此会一直生效,租户自己拿它没
889
+ * 办法,只能找运维改库。#203 交付的正是那条撤销面本身(`GET /v1/rules` + `DELETE /v1/rules`,后者
890
+ * 恒经 `removePersistedRule` 产墓碑)加上 operator 的越权回收腿 ⇒ **前置条件兑现,默认翻转**。
891
+ * 记这段史不是怀旧:哪天有人想把某条「只加放行」的车道默认打开,判据就是这一条 —— 收得回来才敢开。
873
892
  *
874
- * 🔴 为什么是显式 opt-in 而不是「有 SQL 后端就自动上」(codex 交叉复审 round7 [high] 一,验真后收):
875
- * 这条车道**只会加放行、不会减**,而 v1 **还没有撤销面**(列出 / 删除自己已存规则的口子没建;core 的
876
- * `removePersistedRule` 在包里,但本仓零调用点)。一次误导入的全局规则因此会一直生效,租户自己拿它
877
- * 没办法。在撤销面落地之前,**默认不开**是这条轴唯一诚实的姿势;开它的部署等于明说「我接受这条状态,
878
- * 并且知道现在只能靠运维改库来收回」。
893
+ * 显式 `PERMISSION_RULES_ENABLED=false` 仍然关得掉(默认翻转不吃部署侧的表态);升级到本版的既有
894
+ * 部署若库里已有规则桶,boot 期会打一条 `permission_rules_activated_by_default` 诊断行,理由与三条
895
+ * 判据见 `boot/permission-rules-audit.ts`。
879
896
  *
880
897
  * 关 ⇒ 规则店根本不装配:`RunnerDeps.permissionRuleStore` 缺席(引擎的 `permissionRules.storeWired`
881
- * 如实报 false)、ask 帧不带 `ruleSuggestions`、回决的 `persistRule` 拒、`POST /v1/rules/cc-import/*`
882
- * 两口 501。三张表仍随中央 `ensureSchema` 建(纯 CREATE、零行),这不是行为面。
898
+ * 如实报 false)、ask 帧不带 `ruleSuggestions`、回决的 `persistRule` 拒、`/v1/rules/*` 全族 501。
899
+ * 三张表仍随中央 `ensureSchema` 建(纯 CREATE、零行),这不是行为面。
883
900
  */
884
901
  permissionRulesEnabled: boolean;
902
+ /** 运维是否**显式**设过 `PERMISSION_RULES_ENABLED`(取值之外的一位,同 {@link dbBackendExplicit} 的
903
+ * 姿势)。唯一消费者 = boot 期休眠行审计:对显式表过态的部署说「N 只桶随默认 ON 激活」是假话。 */
904
+ permissionRulesEnabledExplicit: boolean;
885
905
  /** #151 车2(design/172 §3.3 D3,窗长三元的安全余量):`ToolApprovalCoordinator` 的可选 `windowMarginMs`
886
906
  * 构造项——有效窗 = `min(ttlMs, legRemainingMs − 本值)`,余量不足 ⇒ 不开窗直接走窗到期同路(park)。
887
907
  * 仅在装配点把 `ToolApprovalRunContext.legDeadlineMonotonic` 传给协调器(车3 的活)时才实际生效——本
@@ -1024,7 +1044,7 @@ export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "ses
1024
1044
  export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "cascadeLadder" | "degrade">;
1025
1045
  /** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
1026
1046
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
1027
- export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "streamAskWindowMarginMs" | "streamApproval" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
1047
+ export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
1028
1048
  /** 组:memory(记忆面 + TOC 同步腿)。 */
1029
1049
  export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memorySync" | "memoryOrgAdmissionMode" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
1030
1050
  /** 组:auth(鉴权 / 身份 / 治理棒)。`commandPolicy` 只有 sema-registry 腿(无 env 标量形),故 env 解析