@sema-agent/server 7.2.0 → 7.4.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 (140) hide show
  1. package/README.md +2 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +26 -1
  4. package/dist/approval-ask-machine.d.ts +39 -0
  5. package/dist/approval-ask-machine.js +101 -0
  6. package/dist/approval-card.d.ts +244 -0
  7. package/dist/approval-card.js +237 -0
  8. package/dist/approval-deny-reasons.d.ts +56 -0
  9. package/dist/approval-deny-reasons.js +54 -0
  10. package/dist/approval-reconciler.d.ts +174 -0
  11. package/dist/approval-reconciler.js +307 -0
  12. package/dist/boot/coordinators.d.ts +1 -0
  13. package/dist/boot/coordinators.js +39 -4
  14. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  15. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  16. package/dist/boot/execution-env.js +11 -1
  17. package/dist/boot/lexical-path-env.d.ts +10 -0
  18. package/dist/boot/lexical-path-env.js +88 -0
  19. package/dist/boot/reapers.d.ts +34 -0
  20. package/dist/boot/reapers.js +198 -23
  21. package/dist/boot/resolve-spec.js +97 -33
  22. package/dist/capabilities/center-prompts.js +4 -1
  23. package/dist/capabilities/oa-tools.d.ts +15 -0
  24. package/dist/capabilities/oa-tools.js +54 -0
  25. package/dist/config-types.d.ts +68 -1
  26. package/dist/config.d.ts +1 -0
  27. package/dist/config.js +138 -2
  28. package/dist/elicitation.d.ts +4 -0
  29. package/dist/elicitation.js +7 -3
  30. package/dist/finance/cost-taxonomy.d.ts +34 -0
  31. package/dist/finance/cost-taxonomy.js +26 -0
  32. package/dist/hooks/hook-runner.js +32 -0
  33. package/dist/http/routes/capabilities.js +14 -0
  34. package/dist/http/routes/diagnostics.d.ts +84 -0
  35. package/dist/http/routes/diagnostics.js +140 -0
  36. package/dist/http/routes/runs.d.ts +1 -0
  37. package/dist/http/routes/runs.js +548 -16
  38. package/dist/http/routes/tasks.js +175 -12
  39. package/dist/http/server.d.ts +6 -1
  40. package/dist/http/server.js +120 -4
  41. package/dist/http/sse-log.d.ts +51 -0
  42. package/dist/http/sse-log.js +64 -0
  43. package/dist/http/wire-types.d.ts +20 -5
  44. package/dist/main.js +35 -4
  45. package/dist/observability/fail-open.d.ts +98 -0
  46. package/dist/observability/fail-open.js +216 -0
  47. package/dist/observability/prompt-manifest.d.ts +13 -0
  48. package/dist/observability/prompt-manifest.js +8 -0
  49. package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
  50. package/dist/plugins/approval-ask-store-memory.js +299 -0
  51. package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
  52. package/dist/plugins/approval-ask-store-sql.js +705 -0
  53. package/dist/plugins/approval-store-sql.d.ts +116 -0
  54. package/dist/plugins/approval-store-sql.js +151 -0
  55. package/dist/plugins/background-agent-store-sql.js +20 -1
  56. package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
  57. package/dist/plugins/checkpoint-store-sql.js +297 -16
  58. package/dist/plugins/file-workflow-journal-store.d.ts +12 -0
  59. package/dist/plugins/file-workflow-journal-store.js +12 -0
  60. package/dist/plugins/local-checkpoint-store.d.ts +6 -5
  61. package/dist/plugins/local-checkpoint-store.js +4 -0
  62. package/dist/plugins/pg-approval-store.d.ts +9 -0
  63. package/dist/plugins/pg-approval-store.js +9 -0
  64. package/dist/plugins/pg-breaker-state.d.ts +8 -0
  65. package/dist/plugins/pg-breaker-state.js +8 -0
  66. package/dist/plugins/pg-checkpoint-store.d.ts +10 -0
  67. package/dist/plugins/pg-checkpoint-store.js +10 -0
  68. package/dist/plugins/pg-file-snapshot-store.d.ts +8 -0
  69. package/dist/plugins/pg-file-snapshot-store.js +8 -0
  70. package/dist/plugins/pg-image-bake.d.ts +12 -0
  71. package/dist/plugins/pg-image-bake.js +11 -0
  72. package/dist/plugins/pg-image-index.d.ts +12 -0
  73. package/dist/plugins/pg-image-index.js +11 -0
  74. package/dist/plugins/pg-outcome-ledger.d.ts +12 -0
  75. package/dist/plugins/pg-outcome-ledger.js +11 -0
  76. package/dist/plugins/pg-pool.js +11 -0
  77. package/dist/plugins/pg-resume-anchor-store.d.ts +7 -0
  78. package/dist/plugins/pg-resume-anchor-store.js +7 -0
  79. package/dist/plugins/pg-run-store.d.ts +9 -0
  80. package/dist/plugins/pg-run-store.js +9 -0
  81. package/dist/plugins/pg-session-policy-store.d.ts +7 -0
  82. package/dist/plugins/pg-session-policy-store.js +7 -0
  83. package/dist/plugins/pg-session-store.d.ts +12 -0
  84. package/dist/plugins/pg-session-store.js +12 -0
  85. package/dist/plugins/pg-tool-result-store.d.ts +9 -0
  86. package/dist/plugins/pg-tool-result-store.js +9 -0
  87. package/dist/plugins/pg-workflow-journal-store.d.ts +9 -0
  88. package/dist/plugins/pg-workflow-journal-store.js +9 -0
  89. package/dist/plugins/pg-workflow-run-store.d.ts +9 -0
  90. package/dist/plugins/pg-workflow-run-store.js +9 -0
  91. package/dist/plugins/store-backend.d.ts +18 -0
  92. package/dist/plugins/store-backend.js +10 -0
  93. package/dist/plugins/tidb-approval-store.d.ts +8 -0
  94. package/dist/plugins/tidb-approval-store.js +8 -0
  95. package/dist/plugins/tidb-breaker-state.d.ts +7 -0
  96. package/dist/plugins/tidb-breaker-state.js +7 -0
  97. package/dist/plugins/tidb-checkpoint-store.d.ts +9 -0
  98. package/dist/plugins/tidb-checkpoint-store.js +9 -0
  99. package/dist/plugins/tidb-file-snapshot-store.d.ts +8 -0
  100. package/dist/plugins/tidb-file-snapshot-store.js +8 -0
  101. package/dist/plugins/tidb-image-bake.d.ts +12 -0
  102. package/dist/plugins/tidb-image-bake.js +11 -0
  103. package/dist/plugins/tidb-image-index.d.ts +12 -0
  104. package/dist/plugins/tidb-image-index.js +11 -0
  105. package/dist/plugins/tidb-outcome-ledger.d.ts +12 -0
  106. package/dist/plugins/tidb-outcome-ledger.js +12 -0
  107. package/dist/plugins/tidb-pool.js +27 -4
  108. package/dist/plugins/tidb-resume-anchor-store.d.ts +7 -0
  109. package/dist/plugins/tidb-resume-anchor-store.js +7 -0
  110. package/dist/plugins/tidb-run-store.d.ts +10 -0
  111. package/dist/plugins/tidb-run-store.js +9 -0
  112. package/dist/plugins/tidb-session-policy-store.d.ts +7 -0
  113. package/dist/plugins/tidb-session-policy-store.js +7 -0
  114. package/dist/plugins/tidb-tool-result-store.d.ts +8 -0
  115. package/dist/plugins/tidb-tool-result-store.js +10 -0
  116. package/dist/plugins/tidb-workflow-journal-store.d.ts +9 -0
  117. package/dist/plugins/tidb-workflow-journal-store.js +9 -0
  118. package/dist/plugins/tidb-workflow-run-store.d.ts +10 -0
  119. package/dist/plugins/tidb-workflow-run-store.js +10 -0
  120. package/dist/plugins/workflow-journal-limits.d.ts +12 -0
  121. package/dist/plugins/workflow-journal-limits.js +12 -0
  122. package/dist/question.d.ts +21 -14
  123. package/dist/question.js +83 -34
  124. package/dist/runs.d.ts +16 -1
  125. package/dist/runs.js +61 -3
  126. package/dist/runtime-caps-resolver.d.ts +7 -1
  127. package/dist/runtime-caps-resolver.js +65 -3
  128. package/dist/sema-registry.d.ts +41 -0
  129. package/dist/sema-registry.js +40 -0
  130. package/dist/spec-fields.d.ts +4 -0
  131. package/dist/spec-fields.js +6 -0
  132. package/dist/task-settings.d.ts +36 -15
  133. package/dist/task-settings.js +19 -5
  134. package/dist/tool-approval.d.ts +296 -3
  135. package/dist/tool-approval.js +1074 -50
  136. package/dist/trace/core-keyset-guard.d.ts +2 -2
  137. package/dist/trace/ledger-sink.js +14 -1
  138. package/dist/trace/project.d.ts +90 -0
  139. package/dist/trace/project.js +188 -0
  140. package/package.json +5 -4
@@ -0,0 +1,307 @@
1
+ import { DENY_REASONS, VOID_REASONS } from "./approval-deny-reasons.js";
2
+ import { buildRevokeFrame } from "./approval-card.js";
3
+ import { encodeCheckpointScope } from "./security.js";
4
+ /** 判据 2 的「非 suspended 终局」词表(§8 A-1;`RunRecord["status"]` = `"running" | TaskStatus`,
5
+ * TaskStatus = completed|blocked|failed|suspended|needs_review)。`suspended`/`needs_review`/`running`
6
+ * 都不是终局 ⇒ 落判据 3 保持。 */
7
+ const TERMINAL_RUN_STATUS = new Set(["completed", "failed", "blocked"]);
8
+ /** core 给「被取消」的 run 打的 `errorCode`(§9 C3 的取消判别键)。 */
9
+ const CANCELLED_ERROR_CODE = "cancelled";
10
+ /**
11
+ * 🔴 收敛器每一次 store/读口调用的墙钟上限(codex 交叉复审 round3 R3-2,2026-08-06 真缺陷)。
12
+ *
13
+ * 为什么必须有:`runOnce` 被 reaper 腿的 **in-flight 守卫**包着(一次只跑一轮)。一个挂死的依赖
14
+ * (checkpoint 店 / run 店 / ask 店任意一个黑洞化)会让 `runOnce` 永不返回 ⇒ 守卫从此再不放行 ⇒
15
+ * **全部** approval 行的对账永久停摆,而这正是本模块存在的目的(崩溃恢复)。超时后单行进 per-row
16
+ * catch(warn + 计数 + 留在原态下轮重试),其余行照常推进 —— 一条坏依赖不许饿死整段扫描。
17
+ * 10s:远大于任何健康查询,又远小于 reaper tick 的实际容忍度。
18
+ */
19
+ const RECONCILE_STORE_TIMEOUT_MS = 10_000;
20
+ /**
21
+ * 🔴 **每段**扫描的墙钟预算(codex 交叉复审 round4 R4-2,2026-08-06 验真)。
22
+ *
23
+ * 单看 per-call deadline 是不够的:行是**串行**处理的,一个黑洞化的依赖会让每行都吃满
24
+ * {@link RECONCILE_STORE_TIMEOUT_MS}。默认 batch=200 时 `runOnce` 要跑掉半小时量级,上帽 10000 行更是
25
+ * 一天量级 —— 而 reaper 的 in-flight 守卫在这期间会**跳过每一个 tick**,孤儿段(段二)甚至一次都轮不到。
26
+ * 于是「一个依赖挂了」被放大成「整个对账面停摆数小时」,正好是本模块要防的那件事。
27
+ *
28
+ * 修法:**每段各自**一份预算(不是共享一份)——段一吃光也绝不影响段二开跑(孤儿代打是崩溃恢复的兜底,
29
+ * 优先级不比判据表低)。超预算即停本段,剩下的行下轮接着扫(`deferReconcile` 的队列轮转保证「下轮」
30
+ * 真的能轮到新行),并记一条 warn。
31
+ */
32
+ const RECONCILE_SEGMENT_BUDGET_MS = 30_000;
33
+ /** 给一次读/写套墙钟上限(超时以 Error 拒绝,由 per-row catch 接住)。定时器 `unref`,绝不持住进程;
34
+ * 竞速输的那一路由 `Promise.race` 自己的 handler 接住,不会变成 unhandled rejection。 */
35
+ function withDeadline(op, label, timeoutMs = RECONCILE_STORE_TIMEOUT_MS) {
36
+ return Promise.race([
37
+ op,
38
+ new Promise((_resolve, reject) => {
39
+ const t = setTimeout(() => reject(new Error(`approval reconcile ${label} timed out after ${timeoutMs}ms`)), timeoutMs);
40
+ t.unref?.();
41
+ }),
42
+ ]);
43
+ }
44
+ /**
45
+ * 判据 1 的**硬谓词**(纯函数,§9 C2 + §8 D-2)。
46
+ *
47
+ * 返回选中的候选,或 `undefined` = 不命中。逐条:
48
+ * - `unparseable` 候选直接出局(读不出 ⇒ 不确定 ⇒ 不命中);
49
+ * - `boundCallId` 必须逐字等于 `ask.toolCallId`(读口已按它查,这里是纵深防御);
50
+ * - **因果下界**:`cp.createdAtMs >= ask.createdAtMs`(park 不可能早于它要 park 的那次 ask);
51
+ * - **hash 双等**:两侧都必须在场且相等 —— 任一侧缺席即不命中(禁「能取到时才比」的可选谓词)。
52
+ *
53
+ * 多候选时的取舍:优先 `status === "pending"`(活着的那张 gate),否则取最早的一条(读口按
54
+ * `created_at ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
55
+ * 一个还能被 resume 的坐标上,对壳更有用。
56
+ */
57
+ export function selectGateCandidate(ask, candidates) {
58
+ const askHash = ask.boundInputHash;
59
+ if (askHash === null)
60
+ return undefined; // hash 缺席 = 结构上永不满足判据 1(见 AskRow.boundInputHash 顶注)
61
+ const matches = candidates.filter((c) => c.unparseable !== true && c.boundCallId === ask.toolCallId && c.createdAtMs >= ask.createdAtMs && c.boundInputHash !== null && c.boundInputHash === askHash);
62
+ return matches.find((c) => c.status === "pending") ?? matches[0];
63
+ }
64
+ /** 判据表 v2 的判定(纯函数;顺序 = 设计稿 §9 尾的五臂汇总,注见文件头)。 */
65
+ export function decideReconcileAction(input) {
66
+ const { ask, candidates, run, batchState, nowMs, adhocGraceMs, orphanTtlMs } = input;
67
+ // ① identity ∧ hash 双等
68
+ if (input.allowBind !== false) {
69
+ const match = selectGateCandidate(ask, candidates);
70
+ if (match) {
71
+ return { kind: "bind", gate: { gateToken: match.token, gateBoundCallId: match.boundCallId, gateBoundInputHash: match.boundInputHash } };
72
+ }
73
+ }
74
+ // 批已 ABORTED = 取消腿走过 ⇒ abort 语义(§9 C3),与 run 终局与否无关:批一旦 ABORTED,这只 ask 的
75
+ // 投递面已经被整批撤掉,再判 run 状态没有意义。
76
+ if (batchState === "ABORTED")
77
+ return { kind: "void", reason: VOID_REASONS.BATCH_ABORTED };
78
+ // 🔴 bind-once 落选者(codex round2 R2-3):批已绑给**别人** ⇒ 本行结构上再也赢不了 `bindBatch`。
79
+ // 它是**当下就可判**的终局(VOID),不是「等等看」——落 hold 会让它滞留到 run 终局,然后被判据 2 误报
80
+ // 成路由失败。判据要求 `boundAskId` 真的读到且不是自己:读不到(null)时不据此下判(未知 ≠ 落选)。
81
+ if (batchState === "ROUTING_BOUND" && input.batchBoundAskId != null && input.batchBoundAskId !== ask.askId) {
82
+ return { kind: "void", reason: VOID_REASONS.BATCH_BOUND_ELSEWHERE };
83
+ }
84
+ // ② run 终局分臂
85
+ if (run !== null && TERMINAL_RUN_STATUS.has(run.status)) {
86
+ if (run.status === "failed" && run.errorCode === CANCELLED_ERROR_CODE) {
87
+ return { kind: "void", reason: VOID_REASONS.RUN_CANCELLED };
88
+ }
89
+ return { kind: "deny", reason: DENY_REASONS.ROUTING_FAILURE };
90
+ }
91
+ // ④ adhoc 双谓词(§8 C-2):结构判别 + 窗过 + 宽限。**不是** DENIED(约束②禁超时 denial)。
92
+ if (run === null && ask.sessionId === ask.taskId && nowMs > ask.expiresAtMs + adhocGraceMs) {
93
+ return { kind: "void", reason: VOID_REASONS.ADHOC_LEG_NO_DURABLE_DOMAIN };
94
+ }
95
+ // ⑤ 遗孤最终可判(§8 A-1 放宽形):量 immutable 的 createdAtMs。放在 ③ 之前判 —— 它是**所有**
96
+ // 「本轮判不出终局」的行的共同兜底,包括 run 一直在跑的那一支。
97
+ if (nowMs - ask.createdAtMs >= orphanTtlMs)
98
+ return { kind: "void", reason: VOID_REASONS.ORPHAN_TTL_EXCEEDED };
99
+ // ③ else:保持 PARKING(候下轮;执行器负责 deferReconcile 排队尾)。`candidates` 在这一臂被显式忽略
100
+ // ——判据 1 已经判过且没命中,这里不做任何「差不多算匹配」的兜底。
101
+ void candidates;
102
+ return { kind: "hold" };
103
+ }
104
+ export function createApprovalReconciler(deps) {
105
+ const { askStore, checkpoints, runs, logger, metrics, batchLimit, pendingGraceMs, adhocGraceMs, orphanTtlMs, emitRevoke } = deps;
106
+ /** 撤卡帧发射(live only;失败 catch + warn,**绝不回滚 CAS** —— 帧是通知,真源是行)。空名单不发。 */
107
+ const revoke = (row, askIds, reason, nowMs) => {
108
+ if (askIds.length === 0 || !emitRevoke)
109
+ return;
110
+ try {
111
+ emitRevoke(buildRevokeFrame(row.batchId, askIds, reason, nowMs), { owner: row.owner, sessionId: row.sessionId, taskId: row.taskId });
112
+ }
113
+ catch (err) {
114
+ try {
115
+ logger.warn("approval_revoke_emit_failed", { batchId: row.batchId, count: askIds.length, err: err instanceof Error ? err.message : String(err) });
116
+ }
117
+ catch {
118
+ // 可观测面自身绝不能变成故障源(reapers.ts createThrottledReaperCatch 同款纪律)。
119
+ }
120
+ }
121
+ };
122
+ /** 一条 `PARKING` 行的收敛(读事实 → 纯判定 → 一次 CAS)。返回本行落到哪一臂(供统计)。 */
123
+ const reconcileOne = async (ask, nowMs) => {
124
+ // ⓪ 批态**先读**(codex round3 R3-2):它是唯一一个能让本行「结构上再也不可能 bind」的事实,而且是
125
+ // 一次主键读。批已 `ABORTED` 或已绑给**别人** ⇒ 判据 1 的 `bindBatch` 谓词(`ROUTING_UNBOUND`)
126
+ // 注定不成立 ⇒ 跳过 checkpoint/run 两次读**结论不变**,却少挂两个可能黑洞化的依赖(原先的次序下,
127
+ // 一条挂死的 checkpoint 店会把这类**当下就可判**的行连同整个 `runOnce` 一起拖住,再经 reaper 的
128
+ // in-flight 守卫把后续所有 tick 一并封死)。
129
+ const batchRow = await withDeadline(askStore.getBatch(ask.batchId), "getBatch");
130
+ let batchState = batchRow?.state ?? "MISSING";
131
+ let batchBoundAskId = batchRow?.boundAskId ?? null;
132
+ const irreversibleBatch = batchState === "ABORTED" || (batchState === "ROUTING_BOUND" && batchBoundAskId !== null && batchBoundAskId !== ask.askId);
133
+ // 判据 1:窄谓词精确查(scope 必填 —— 租户门是读口自己的责任,§8 D-3)。因果下界直接下推成
134
+ // `sinceMs`,SQL 侧就把早于本 ask 的 checkpoint 排掉了(`selectGateCandidate` 里还会再判一次,
135
+ // 纵深防御)。
136
+ if (checkpoints && !irreversibleBatch) {
137
+ const candidates = await withDeadline(checkpoints.findCheckpointCandidatesForAsk(encodeCheckpointScope(ask.owner), ask.sessionId, ask.toolCallId, ask.createdAtMs), "findCheckpointCandidatesForAsk");
138
+ const match = selectGateCandidate(ask, candidates);
139
+ if (match) {
140
+ // 🔴 `bindBatch` 内部**一次**完成 `PARKING→PARKED` + gate 落行 + 兄弟连坐 VOID(§8 A-2:正文
141
+ // 「先 transitionAsk 再 bindBatch」是死锁形,已作废)。只调它一次。
142
+ const res = await withDeadline(askStore.bindBatch(ask.batchId, ask.askId, {
143
+ gateToken: match.token,
144
+ gateBoundCallId: match.boundCallId,
145
+ gateBoundInputHash: match.boundInputHash,
146
+ }), "bindBatch");
147
+ if (res.ok) {
148
+ revoke(ask, res.voidedSiblings, "superseded_by_park", nowMs);
149
+ return "parked";
150
+ }
151
+ // 判别式失败臂(§9 C3):拿到批的**真实**态 + 中选者,降级续判 —— `ABORTED` ⇒ VOID(abort 语义),
152
+ // `ROUTING_BOUND` 且中选者是兄弟 ⇒ VOID(bind-once 落选者,R2-3),其余按 ②③④⑤。
153
+ batchState = res.batchState;
154
+ batchBoundAskId = res.boundAskId ?? null;
155
+ }
156
+ }
157
+ // run 行只在批**还可能**让本行走下去时才读(不可逆批态下它对结论零影响,见 ⓪)。
158
+ const run = runs && !irreversibleBatch ? ((await withDeadline(runs.getRun(ask.taskId), "getRun")) ?? null) : null;
159
+ // 判据 1 已经在上面单独判过(命中即 `bindBatch`,不命中/绑失败都落这里)⇒ `allowBind: false`。
160
+ const action = decideReconcileAction({ ask, candidates: [], run, batchState, batchBoundAskId, nowMs, adhocGraceMs, orphanTtlMs, allowBind: false });
161
+ switch (action.kind) {
162
+ case "bind": {
163
+ // `allowBind: false` 下结构上不可达;留一条防御性 hold,绝不在这里第二次调 bindBatch。
164
+ await withDeadline(askStore.deferReconcile(ask.askId, "PARKING", ask.version, nowMs), "deferReconcile");
165
+ return "held";
166
+ }
167
+ case "deny": {
168
+ // 🔴 全仓唯一的 `DENIED` 写点(grep 钉)。归因是判据 2 的**终局证据**,不是超时推断。
169
+ // CAS 输(行在本轮判定期间被别人收走)= 正常,如实记成 held(没动过行),下轮重扫。
170
+ const won = await withDeadline(askStore.transitionAsk(ask.askId, "PARKING", "DENIED", { deniedReason: action.reason, updatedAtMs: nowMs }), "transitionAsk(DENIED)");
171
+ return won ? "denied" : "held";
172
+ }
173
+ case "void": {
174
+ // 🔴 codex 交叉复审 C3(2026-08-06 真缺陷):**必须看 CAS 的返回值**。原先无条件记账 + 发撤卡帧
175
+ // ——本行若在判定与写入之间被另一个副本 `bindBatch` 收成 PARKED,这次 CAS 干净地输,行**没有**
176
+ // 被翻成 VOID,而帧却告诉壳「这张卡作废了」⇒ 直接违反撤卡帧守恒(名单 ≡ 真被翻成 VOID 的集合),
177
+ // 壳会清掉一张仍然有效、还在等 gate 的卡。输 ⇒ 什么都不做,下轮按新态重判。
178
+ const won = await withDeadline(askStore.transitionAsk(ask.askId, "PARKING", "VOID", { deniedReason: action.reason, updatedAtMs: nowMs }), "transitionAsk(VOID)");
179
+ if (!won)
180
+ return "held";
181
+ if (action.reason === VOID_REASONS.ORPHAN_TTL_EXCEEDED) {
182
+ logger.warn("approval_ask_orphan_voided", { askId: ask.askId, taskId: ask.taskId, ageMs: nowMs - ask.createdAtMs, ttlMs: orphanTtlMs });
183
+ }
184
+ // 单行 VOID 也是撤卡:壳上那张卡必须消失(名单 = 这一行自己)。
185
+ // reason 取 `"aborted"`:v1 的闭集只有两员,而 `"superseded_by_park"` 的字面语义是「**被别人的
186
+ // park 连坐**」——②④⑤ 三条臂(run 取消 / 批 ABORTED / adhoc 无对账域 / 遗孤 TTL)都不是那回事,
187
+ // 硬套会把归因写成谎。`"aborted"`(这条腿死了)是两员里唯一诚实的那个;真正的细分归因在**行上**
188
+ // (`denied_reason` 落的是 VOID_REASONS 的具体值),帧只负责让壳清卡。
189
+ // (存疑登记:v2 若要让壳按成因分渲,给 reason 闭集加一个 `"voided"` 成员即可,属 wire 加员。)
190
+ // 落选者(R2-3)是**真的**被别人的 park 顶掉 ⇒ 用 `superseded_by_park`;其余单行 VOID 用 `"aborted"`
191
+ // (两员闭集里唯一诚实的那个,见上注)。
192
+ revoke(ask, [ask.askId], action.reason === VOID_REASONS.BATCH_BOUND_ELSEWHERE ? "superseded_by_park" : "aborted", nowMs);
193
+ return "voided";
194
+ }
195
+ case "hold": {
196
+ // 队列轮转(§8 D-4 / §9 C5):推 `updated_at_ms` 排到队尾。CAS 输(版本已被别人推进)= 正常,
197
+ // 本轮判定已过期,下轮重来。
198
+ await withDeadline(askStore.deferReconcile(ask.askId, "PARKING", ask.version, nowMs), "deferReconcile");
199
+ return "held";
200
+ }
201
+ }
202
+ };
203
+ return {
204
+ async runOnce(nowMs) {
205
+ const stats = { scanned: 0, unmatchableNoHash: 0, parked: 0, denied: 0, voided: 0, held: 0, failed: 0, budgetExhausted: 0, orphansExpired: 0 };
206
+ // ── 段一:PARKING 判据表 ────────────────────────────────────────────────────────────────
207
+ const parking = await withDeadline(askStore.listByState("PARKING", batchLimit), "listByState(PARKING)");
208
+ stats.scanned = parking.length;
209
+ const parkingDeadline = Date.now() + RECONCILE_SEGMENT_BUDGET_MS; // R4-2:段一预算
210
+ for (const ask of parking) {
211
+ if (Date.now() >= parkingDeadline) {
212
+ stats.budgetExhausted += 1;
213
+ try {
214
+ logger.warn("approval_reconcile_budget_exhausted", { segment: "PARKING", processed: stats.parked + stats.denied + stats.voided + stats.held + stats.failed, scanned: stats.scanned, budgetMs: RECONCILE_SEGMENT_BUDGET_MS });
215
+ }
216
+ catch {
217
+ /* 可观测面绝不成为故障源 */
218
+ }
219
+ break; // 剩下的行下轮接着扫(队列轮转保证轮得到),绝不霸着 in-flight 守卫不放
220
+ }
221
+ if (ask.boundInputHash === null)
222
+ stats.unmatchableNoHash += 1; // 判据 1 盲区(见字段顶注)
223
+ try {
224
+ const outcome = await reconcileOne(ask, nowMs);
225
+ if (outcome === "parked")
226
+ stats.parked += 1;
227
+ else if (outcome === "denied")
228
+ stats.denied += 1;
229
+ else if (outcome === "voided")
230
+ stats.voided += 1;
231
+ else
232
+ stats.held += 1;
233
+ }
234
+ catch (err) {
235
+ // 🔴 per-row 隔离(邻居 bg-agent per-scope 先例):一条行的坏 blob / 一次瞬时错误不许饿死同批
236
+ // 其余 199 行。失败响亮(warn + 计数),行留在 PARKING 下轮重试。
237
+ stats.failed += 1;
238
+ try {
239
+ logger.warn("approval_reconcile_row_failed", { askId: ask.askId, taskId: ask.taskId, err: err instanceof Error ? err.message : String(err) });
240
+ }
241
+ catch {
242
+ /* 可观测面绝不成为故障源 */
243
+ }
244
+ }
245
+ }
246
+ // ── 段二:孤儿 STREAM_PENDING 代打 expire(§1.3 + §8 D-1.1 宽限)──────────────────────────
247
+ // 🔴 **不是第五竞争者**:它就是窗到期竞争者在崩溃后的补位,同一 CAS 同一转移。宽限保证活属主的
248
+ // 本地 timer 常态必胜(reaper 退回纯崩溃兜底),不与在场闭包抢。
249
+ const pending = await withDeadline(askStore.listByState("STREAM_PENDING", batchLimit), "listByState(STREAM_PENDING)");
250
+ const orphanDeadline = Date.now() + RECONCILE_SEGMENT_BUDGET_MS; // R4-2:段二**独立**预算(段一吃光也照跑)
251
+ for (const row of pending) {
252
+ if (Date.now() >= orphanDeadline) {
253
+ stats.budgetExhausted += 1;
254
+ try {
255
+ logger.warn("approval_reconcile_budget_exhausted", { segment: "STREAM_PENDING", expired: stats.orphansExpired, scanned: pending.length, budgetMs: RECONCILE_SEGMENT_BUDGET_MS });
256
+ }
257
+ catch {
258
+ /* 同上 */
259
+ }
260
+ break;
261
+ }
262
+ if (row.expiresAtMs + pendingGraceMs >= nowMs)
263
+ continue; // 窗未过 / 还在宽限内:属主在场,不碰
264
+ try {
265
+ const res = await withDeadline(askStore.expireAsk(row.askId, row.batchId), "expireAsk(orphan)");
266
+ if (!res.won)
267
+ continue; // 干净地输 = 属主刚刚赢了,正常
268
+ stats.orphansExpired += 1;
269
+ revoke(row, res.voidedSiblings, "superseded_by_park", nowMs);
270
+ }
271
+ catch (err) {
272
+ stats.failed += 1;
273
+ try {
274
+ logger.warn("approval_orphan_expire_failed", { askId: row.askId, taskId: row.taskId, err: err instanceof Error ? err.message : String(err) });
275
+ }
276
+ catch {
277
+ /* 同上 */
278
+ }
279
+ }
280
+ }
281
+ // 判据 1 盲区响亮化:有行结构上无法命中就每 tick 报一次(不是每行一次——刷屏没有信息量)。
282
+ // 静默地把「没法比」当成「比过了、不匹配」正是这条要治的病(它下游就是一个不诚实的 DENIED)。
283
+ if (stats.unmatchableNoHash > 0) {
284
+ try {
285
+ logger.warn("approval_reconcile_rows_unmatchable", {
286
+ rows: stats.unmatchableNoHash,
287
+ scanned: stats.scanned,
288
+ reason: "bound_input_hash is NULL — criterion 1 (identity ∧ hash) cannot match; rows fall through to the run-terminal / adhoc / orphan arms",
289
+ });
290
+ }
291
+ catch {
292
+ /* 可观测面绝不成为故障源 */
293
+ }
294
+ }
295
+ // 健康 tick 保持安静(邻居 reapCount 同姿势):只有真动了行才记一条。
296
+ if (stats.parked + stats.denied + stats.voided + stats.orphansExpired > 0) {
297
+ metrics?.inc("approval_reconciled_total", { outcome: "parked" }, stats.parked);
298
+ metrics?.inc("approval_reconciled_total", { outcome: "denied" }, stats.denied);
299
+ metrics?.inc("approval_reconciled_total", { outcome: "void" }, stats.voided);
300
+ metrics?.inc("approval_reconciled_total", { outcome: "orphan_expired" }, stats.orphansExpired);
301
+ logger.info("approval_reconcile_swept", { ...stats });
302
+ }
303
+ return stats;
304
+ },
305
+ };
306
+ }
307
+ //# sourceMappingURL=approval-reconciler.js.map
@@ -26,6 +26,7 @@ export declare function createLiveCoordinators(ctx: LiveCoordinatorsCtx): {
26
26
  question: QuestionCoordinator | undefined;
27
27
  toolApproval: ToolApprovalCoordinator | undefined;
28
28
  durableEnabled: boolean;
29
+ streamApprovalGate: import("../tool-approval.js").StreamApprovalGate;
29
30
  sendUserFileEmitter: SendUserFileEmitter | undefined;
30
31
  sendFileLedger: import("../plugins/send-file-ledger.js").SendFileLedger | undefined;
31
32
  sendUserFileToolSpec: import("@sema-agent/core").ToolSpec<import("typebox").TSchema> | undefined;
@@ -1,6 +1,6 @@
1
1
  import { ElicitationCoordinator } from "../elicitation.js";
2
2
  import { QuestionCoordinator } from "../question.js";
3
- import { ToolApprovalCoordinator } from "../tool-approval.js";
3
+ import { ToolApprovalCoordinator, resolveStreamApprovalGate } from "../tool-approval.js";
4
4
  import { SendUserFileEmitter, SEND_USER_FILE_MAX_BYTES, sendUserFileTool } from "../capabilities/send-user-file-tool.js";
5
5
  import { createSandboxFileSend, TaskEnvRegistry } from "../capabilities/sandbox-file-send.js";
6
6
  import { createSendUserFileIssuer } from "../plugins/send-user-file.js";
@@ -17,7 +17,9 @@ export function createLiveCoordinators(ctx) {
17
17
  // `RunnerDeps.onQuestion` (core mounts the AskUserQuestion tool + routes live asks here) AND consumed by the HTTP layer
18
18
  // (the `POST /v1/questions/:id/respond` route + the per-run ALS context wraps on the streaming legs). Absent ⇒ core
19
19
  // mounts the tool with the headless default (a run never hangs; the model just can't get a live answer).
20
- const question = config.askQuestionEnabled ? new QuestionCoordinator() : undefined;
20
+ // #166 裁2 接线:三旋钮(QUESTION_TTL_MS / QUESTION_MAX_*_PER_RUN) config 通到协调器;缺席=构造器
21
+ // 内建 DEFAULT_QUESTION_THROTTLE,默认值恒等钉在 test/question-unavailable.test.ts。
22
+ const question = config.askQuestionEnabled ? new QuestionCoordinator(config.questionThrottle) : undefined;
21
23
  // [816]/[820]②: the live tool-approval HITL coordinator — the sibling of `question` on core's `RunnerDeps.onAsk`
22
24
  // seam (1.290 sync-ask leg). Present ⇒ a policy `ask` on a LIVE streaming leg becomes a `tool_approval` frame the
23
25
  // shell renders as the CC three-choice card, answered via POST /v1/tool-approvals/:id/respond.
@@ -25,11 +27,44 @@ export function createLiveCoordinators(ctx) {
25
27
  // `spec.onAsk = boundAsk(ctx)`(server.ts 装配点)——core 继承链把闭包冻给委派子代,**bg/嵌套子代的
26
28
  // ask 浮到宿主 live 流**(带 sourceTaskId);真正无宿主流的腿(durable-submit/headless resume)才留
27
29
  // 「unavailable → durable park;无 park 设施 core fail-closed deny」。durable 部署 G1 语义不变。
28
- const toolApproval = config.toolApprovalEnabled ? new ToolApprovalCoordinator() : undefined;
29
30
  // [1.294 G1] durable 姿势必须在 runnerDeps 字面量之前可判(onAsk 的「在场性」本身就是 core suspendAsk 的
30
31
  // 分路条件,不能再靠闭包惰性读)——判据与 checkpointStore 的构造条件同源(backend?.checkpoint 存在 ∧
31
32
  // DURABLE_APPROVAL;store 本体已上移至 subRunner 构造前,[1535] 断点② 双 Runner 挂载)。
33
+ // 🔴 #151 车3 刀 3b:本行**上移**到协调器构造之前 —— 它同时是流内审批协议的「park 设施在场性」判据
34
+ // (§8.4),而协调器要在构造那一刻就知道自己带不带 askStore。位置即契约的原约束(必须在 RunnerDeps
35
+ // 字面量之前)不变,只是又提前了几行。
32
36
  const durableEnabled = backend?.checkpoint !== undefined && config.durableApproval === true;
37
+ // #151 车3 刀 3b(设计稿 §2.3 + §8.4):协议上场判据走**单一谓词**(与 /v1/capabilities 的
38
+ // `streamApproval` 格、与回决端点的 501 门同一个符号,见 resolveStreamApprovalGate 顶注)。
39
+ const streamApprovalGate = resolveStreamApprovalGate({
40
+ toolApprovalEnabled: config.toolApprovalEnabled === true,
41
+ streamApprovalEnabled: config.streamApproval.enabled,
42
+ backend,
43
+ parkFacility: durableEnabled,
44
+ });
45
+ // §8.4:协议**开着**却上不了场 = 合法部署形(不是配置错误)⇒ 一行 **info**(不是 warn),把原因说明白。
46
+ // 反过来「开关本来就没开」不打日志:那是默认形,每次启动刷一行噪音没有信息量。
47
+ if (config.streamApproval.enabled && !streamApprovalGate.active) {
48
+ logger.info("stream_approval_disabled", {
49
+ reason: streamApprovalGate.reason,
50
+ backendKind: backend?.kind ?? null,
51
+ checkpointStore: backend?.checkpoint !== undefined,
52
+ durableApproval: config.durableApproval === true,
53
+ toolApproval: config.toolApprovalEnabled === true,
54
+ });
55
+ }
56
+ const toolApproval = config.toolApprovalEnabled
57
+ ? new ToolApprovalCoordinator({
58
+ // 协议上场 ⇒ 持久层 + 60s 窗(§7:`STREAM_ASK_WINDOW_MS` **只在开关内**生效,
59
+ // `DEFAULT_APPROVAL_TTL_MS` 一个字不动);不上场 ⇒ 两者都不传,窗保持既有 5min(D1 逐字不变)。
60
+ ...(streamApprovalGate.active ? { askStore: streamApprovalGate.askStore, ttlMs: config.streamApproval.windowMs } : {}),
61
+ // 余量旋钮恒传(既有键,窗长三元公式的第二项;legDeadlineMonotonic 缺席时它不参与计算)。
62
+ windowMarginMs: config.streamAskWindowMarginMs,
63
+ // X-2 写侧准入门两帽(协议未上场时门本身不参与,见 ToolApprovalCoordinator.admit)。
64
+ admitMaxPerTask: config.streamApproval.admitMaxPerTask,
65
+ admitMaxPerOwner: config.streamApproval.admitMaxPerOwner,
66
+ })
67
+ : undefined;
33
68
  // SendUserFile(真 CC 契约,clay 2026-07-14)两种 lane 形态,其余缺席=诚实(工具不进 roster):
34
69
  // - host lane:本地盘读+服务端上传,【单用户 only】——host 多租户=任意路径读→公网 URL 的 exfil 面,
35
70
  // clay 拍永久关死,不留口子。路径=绝对或相对 process.cwd(用户目录语义,与 CC 一致)。
@@ -92,6 +127,6 @@ export function createLiveCoordinators(ctx) {
92
127
  : rawSend;
93
128
  return sendUserFileTool({ send, emitter: sendUserFileEmitter });
94
129
  })();
95
- return { elicitation, question, toolApproval, durableEnabled, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec };
130
+ return { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec };
96
131
  }
97
132
  //# sourceMappingURL=coordinators.js.map
@@ -0,0 +1,99 @@
1
+ import { FileError, StubExecutionEnv, type ExecutionEnv, type ExecutionEnvFactory, type FileInfo, type Result } from "@sema-agent/core";
2
+ /** 哪些 lane 用本模块的代理裁决写门 —— `REMOTE_EXEC` 未设(进程内 host)与显式 `host` 之外的全部。
3
+ * 与 resolve-spec 的 `hostSemanticsLane` 是同一判别式的两面,取值处**只此一个**(两处各写一份正是漂移的成因)。 */
4
+ export declare function isSandboxPathAdjudicationLane(provider: string | undefined): boolean;
5
+ /** slot 取值的三态。`ok:false` 的两支都是 fail-closed,分开只为让留痕说得出**哪一种**缺席。 */
6
+ export type SandboxPathEnvLookup = {
7
+ readonly ok: true;
8
+ readonly env: ExecutionEnv;
9
+ } | {
10
+ readonly ok: false;
11
+ readonly reason: "no_session_id" | "unbound";
12
+ };
13
+ /**
14
+ * per-session 的真 env 登记簿 —— 工厂装饰器写,写门代理读。
15
+ *
16
+ * **同键改铸 = 顶替**(不排队、不判歧义)。依据是一条被两道门执法的不变量:一个 session 同时只有一条
17
+ * 活腿(HTTP 层 `conflict.session_active_run` + core 的 session acquire),而委派子代用的是**另一个**
18
+ * sessionId。所以工厂为某 session 铸出新 env 的那一刻,同键上还留着的登记按构造已经是死腿——最典型的
19
+ * 产地是 **park-only 车道**(非 suspendable 的远程 env:durable park 既不 `suspendVM` 也不 `destroy`,
20
+ * core 的 `teardownOwnedEnv` 在 checkpoint token 在场时整条跳过)。留着它只会让 resume 腿整轮拿不到
21
+ * 真 env(写门恒 fail-closed);顶替按**实例身份**收口,被顶掉那一方迟到的注销全部落空,不误伤后继。
22
+ */
23
+ export declare class SandboxPathEnvSlots {
24
+ /** **弱持有**:登记簿只在「别人还用着这个 env」期间指向它。跑着的任务由 core 强持有(`prepared.ownedEnv`),
25
+ * 所以在场腿的裁决永远解得出;而一条 park 后再没人来 resume 的死腿,其 env 一旦无人引用即可被回收,
26
+ * 条目在下一次清扫时消失。强持有会把它们连同各自的 adapter 连接一起钉死到进程结束,并在 4096 条之后
27
+ * 让**每一个**新 session 拿不到真 env(守卫集下 = 每一次结构化写都 deny)—— 那是一道会自己关上的门。 */
28
+ private readonly bySession;
29
+ /** 登记一个刚铸出的 env(同键顶替),返回**幂等**的注销闭包;到达上限而拒收时返回 `undefined`。
30
+ * 注销按**实例身份**摘除,所以一个迟到的 destroy 不会误伤同键的后继 env。 */
31
+ bind(sessionId: string, env: ExecutionEnv): (() => void) | undefined;
32
+ resolve(sessionId: string | undefined): SandboxPathEnvLookup;
33
+ /** 登记簿规模(上限行为的可观测面;测试与运维探针用)。含尚未清扫的死条目。 */
34
+ get boundSessions(): number;
35
+ /** 清掉 env 已被回收的条目。只在触到上限时跑一遍(O(n) 的代价换掉一次拒收)。 */
36
+ private sweepCollected;
37
+ }
38
+ /** 进程内唯一的登记簿:工厂装饰器(boot/execution-env.ts)与写门 wiring(boot/resolve-spec.ts)分处
39
+ * 装配链两端,而中间的 core 只肯传 `ExecutionEnvFactoryContext`——两端共享同一个实例是它们唯一的会合点。
40
+ * 测试要隔离时自建 {@link SandboxPathEnvSlots} 实例注入即可(两个消费点都收可选参)。 */
41
+ export declare const sandboxPathEnvSlots: SandboxPathEnvSlots;
42
+ /** 结构化留痕面(与服务 `logger` 同形,调用处 optional-chain —— 与 remote-scratchpad 装饰器一致)。 */
43
+ export interface SandboxPathEnvLogger {
44
+ warn?(event: string, fields?: Record<string, unknown>): void;
45
+ }
46
+ /**
47
+ * 装饰工厂:每铸出一个 env 就按 `ctx.sessionId` 登记(同键顶替,见 {@link SandboxPathEnvSlots}),
48
+ * 终态时注销。
49
+ *
50
+ * 挂**最外层**(装配链尾):内层装饰器可能**换掉**env 实例(worktree 隔离的 `rootEnvAt`)或改写它的
51
+ * 方法(scratchpad 的 exec/canonicalPath 前置),写门要裁决的是 core 最终拿到手的那一个。
52
+ *
53
+ * 生命周期三面(顺序即语义):
54
+ * · `destroy` ⇒ 注销。
55
+ * · `suspendVM` 成功 ⇒ 注销。core 在挂起时**跳过** destroy(runtask 留着 env 做快照),所以这是可挂起
56
+ * 车道唯一的及时腾位点:挂起期该 session 没有活腿,登记留着只是白占登记簿容量。
57
+ * · `resumeVM` 成功 ⇒ **重新登记**。挂起并不必然终结这一轮:`commitSuspendSaga` 的 checkpoint 写失败
58
+ * 臂会 `resumeVM` + `postResumeInit` 把**同一个实例**复活,然后作废本次挂起让 run 继续跑
59
+ * (core dist 亲读)。只注销不复登记,一次瞬时 checkpoint 故障就会让这条 run 之后每一次写都失去真身
60
+ * 裁决(方向 fail-closed,但整轮写面被毒死)。复登记幂等:仍在场时是 no-op,所以正常 resume 腿
61
+ * (新 env 在工厂处已登记、随后被 core `resumeVM` 复原快照)照旧不动;后继腿已接管该 session 时也不抢回。
62
+ *
63
+ * 两个终态面都缺席的 env(五条沙箱 adapter 都不是这一形)照样登记——正确性优先于回收:不登记等于
64
+ * 让那条 lane 的写门恒 fail-closed。回收兜底=登记簿自己的上限。
65
+ */
66
+ export declare function withSandboxPathEnvSlot(factory: ExecutionEnvFactory, slots: SandboxPathEnvSlots, logger?: SandboxPathEnvLogger): ExecutionEnvFactory;
67
+ export interface DeferredSandboxPathEnvOptions {
68
+ /** slot 键。缺席(= 部署没接 authorizer,`main.ts` 不产生这种形)⇒ 转发面恒 fail-closed。 */
69
+ sessionId: string | undefined;
70
+ slots: SandboxPathEnvSlots;
71
+ logger?: SandboxPathEnvLogger;
72
+ }
73
+ /**
74
+ * 写门用的 ExecutionEnv 代理(见文件头)。fs **读**原语转发 slot 里的真 env,`absolutePath` 保留词法形,
75
+ * 其余一切(写面 / shell 面 / listDir …)继承 `StubExecutionEnv` 的 `not_supported`。
76
+ *
77
+ * **继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会随 `StubExecutionEnv`
78
+ * 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。
79
+ */
80
+ export declare class DeferredSandboxPathEnv extends StubExecutionEnv {
81
+ private readonly sessionId;
82
+ private readonly slots;
83
+ private readonly logger;
84
+ /** 每实例一次的留痕闸:裁决面每个写目标要走 3-4 次转发,逐次 warn 会把日志刷成噪声。 */
85
+ private warned;
86
+ constructor(opts: DeferredSandboxPathEnvOptions);
87
+ /** 取真 env;取不到就地铸 fail-closed 错误(并留痕一次)。 */
88
+ private bound;
89
+ /** 词法一面,**不**转发 —— 理由见文件头裁定 1。 */
90
+ absolutePath(path: string): Promise<Result<string, FileError>>;
91
+ exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
92
+ canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
93
+ fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
94
+ /** `readLink` 在 `ExecutionEnv` 上是**可选**面。本代理恒定义它,于是 core 永远走不到自己的
95
+ * 「env 没有 readLink」腿——真 env 缺这一面时由此处答错误,落到 core 同一条 `unresolvedSymlink` 出口,
96
+ * 裁决结果与那条腿完全一致。 */
97
+ readLink(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
98
+ }
99
+ //# sourceMappingURL=deferred-sandbox-path-env.d.ts.map