@sema-agent/server 7.1.0 → 7.3.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 (76) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +6 -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 +167 -0
  11. package/dist/approval-reconciler.js +307 -0
  12. package/dist/boot/coordinators.d.ts +1 -0
  13. package/dist/boot/coordinators.js +36 -3
  14. package/dist/boot/lexical-path-env.d.ts +14 -0
  15. package/dist/boot/lexical-path-env.js +116 -0
  16. package/dist/boot/org-memory.d.ts +8 -5
  17. package/dist/boot/org-memory.js +23 -12
  18. package/dist/boot/reapers.d.ts +34 -0
  19. package/dist/boot/reapers.js +198 -23
  20. package/dist/boot/resolve-spec.d.ts +16 -1
  21. package/dist/boot/resolve-spec.js +104 -46
  22. package/dist/config-center/facade.d.ts +1 -1
  23. package/dist/config-center/facade.js +1 -1
  24. package/dist/config-center/http-client.d.ts +19 -0
  25. package/dist/config-center/http-client.js +81 -32
  26. package/dist/config-types.d.ts +76 -7
  27. package/dist/config.d.ts +1 -0
  28. package/dist/config.js +134 -4
  29. package/dist/elicitation.d.ts +4 -0
  30. package/dist/elicitation.js +2 -2
  31. package/dist/http/routes/capabilities.js +18 -2
  32. package/dist/http/routes/runs.d.ts +1 -0
  33. package/dist/http/routes/runs.js +548 -16
  34. package/dist/http/routes/tasks.js +190 -12
  35. package/dist/http/server.d.ts +1 -1
  36. package/dist/http/server.js +124 -8
  37. package/dist/http/sse-log.d.ts +51 -0
  38. package/dist/http/sse-log.js +64 -0
  39. package/dist/http/wire-types.d.ts +20 -5
  40. package/dist/leader/wire.js +4 -0
  41. package/dist/main.js +7 -4
  42. package/dist/org-memory-admission.d.ts +2 -1
  43. package/dist/org-memory-admission.js +10 -4
  44. package/dist/parked-decide.js +5 -2
  45. package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
  46. package/dist/plugins/approval-ask-store-memory.js +299 -0
  47. package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
  48. package/dist/plugins/approval-ask-store-sql.js +705 -0
  49. package/dist/plugins/background-agent-store-sql.js +20 -1
  50. package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
  51. package/dist/plugins/checkpoint-store-sql.js +297 -16
  52. package/dist/plugins/local-checkpoint-store.d.ts +6 -5
  53. package/dist/plugins/local-checkpoint-store.js +4 -0
  54. package/dist/plugins/pg-pool.js +11 -0
  55. package/dist/plugins/store-backend.d.ts +18 -0
  56. package/dist/plugins/store-backend.js +10 -0
  57. package/dist/plugins/tidb-pool.js +27 -4
  58. package/dist/question.d.ts +18 -3
  59. package/dist/question.js +20 -6
  60. package/dist/runs.d.ts +16 -1
  61. package/dist/runs.js +61 -3
  62. package/dist/runtime-caps-resolver.d.ts +7 -1
  63. package/dist/runtime-caps-resolver.js +65 -3
  64. package/dist/runtime-governance.d.ts +11 -4
  65. package/dist/runtime-governance.js +16 -5
  66. package/dist/spec-fields.d.ts +4 -0
  67. package/dist/spec-fields.js +6 -0
  68. package/dist/task-settings.d.ts +35 -15
  69. package/dist/task-settings.js +19 -5
  70. package/dist/tool-approval.d.ts +296 -3
  71. package/dist/tool-approval.js +1066 -50
  72. package/dist/trace/core-keyset-guard.d.ts +2 -2
  73. package/dist/trace/ledger-sink.js +14 -1
  74. package/dist/trace/project.d.ts +55 -0
  75. package/dist/trace/project.js +135 -0
  76. package/package.json +4 -3
@@ -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";
@@ -25,11 +25,44 @@ export function createLiveCoordinators(ctx) {
25
25
  // `spec.onAsk = boundAsk(ctx)`(server.ts 装配点)——core 继承链把闭包冻给委派子代,**bg/嵌套子代的
26
26
  // ask 浮到宿主 live 流**(带 sourceTaskId);真正无宿主流的腿(durable-submit/headless resume)才留
27
27
  // 「unavailable → durable park;无 park 设施 core fail-closed deny」。durable 部署 G1 语义不变。
28
- const toolApproval = config.toolApprovalEnabled ? new ToolApprovalCoordinator() : undefined;
29
28
  // [1.294 G1] durable 姿势必须在 runnerDeps 字面量之前可判(onAsk 的「在场性」本身就是 core suspendAsk 的
30
29
  // 分路条件,不能再靠闭包惰性读)——判据与 checkpointStore 的构造条件同源(backend?.checkpoint 存在 ∧
31
30
  // DURABLE_APPROVAL;store 本体已上移至 subRunner 构造前,[1535] 断点② 双 Runner 挂载)。
31
+ // 🔴 #151 车3 刀 3b:本行**上移**到协调器构造之前 —— 它同时是流内审批协议的「park 设施在场性」判据
32
+ // (§8.4),而协调器要在构造那一刻就知道自己带不带 askStore。位置即契约的原约束(必须在 RunnerDeps
33
+ // 字面量之前)不变,只是又提前了几行。
32
34
  const durableEnabled = backend?.checkpoint !== undefined && config.durableApproval === true;
35
+ // #151 车3 刀 3b(设计稿 §2.3 + §8.4):协议上场判据走**单一谓词**(与 /v1/capabilities 的
36
+ // `streamApproval` 格、与回决端点的 501 门同一个符号,见 resolveStreamApprovalGate 顶注)。
37
+ const streamApprovalGate = resolveStreamApprovalGate({
38
+ toolApprovalEnabled: config.toolApprovalEnabled === true,
39
+ streamApprovalEnabled: config.streamApproval.enabled,
40
+ backend,
41
+ parkFacility: durableEnabled,
42
+ });
43
+ // §8.4:协议**开着**却上不了场 = 合法部署形(不是配置错误)⇒ 一行 **info**(不是 warn),把原因说明白。
44
+ // 反过来「开关本来就没开」不打日志:那是默认形,每次启动刷一行噪音没有信息量。
45
+ if (config.streamApproval.enabled && !streamApprovalGate.active) {
46
+ logger.info("stream_approval_disabled", {
47
+ reason: streamApprovalGate.reason,
48
+ backendKind: backend?.kind ?? null,
49
+ checkpointStore: backend?.checkpoint !== undefined,
50
+ durableApproval: config.durableApproval === true,
51
+ toolApproval: config.toolApprovalEnabled === true,
52
+ });
53
+ }
54
+ const toolApproval = config.toolApprovalEnabled
55
+ ? new ToolApprovalCoordinator({
56
+ // 协议上场 ⇒ 持久层 + 60s 窗(§7:`STREAM_ASK_WINDOW_MS` **只在开关内**生效,
57
+ // `DEFAULT_APPROVAL_TTL_MS` 一个字不动);不上场 ⇒ 两者都不传,窗保持既有 5min(D1 逐字不变)。
58
+ ...(streamApprovalGate.active ? { askStore: streamApprovalGate.askStore, ttlMs: config.streamApproval.windowMs } : {}),
59
+ // 余量旋钮恒传(既有键,窗长三元公式的第二项;legDeadlineMonotonic 缺席时它不参与计算)。
60
+ windowMarginMs: config.streamAskWindowMarginMs,
61
+ // X-2 写侧准入门两帽(协议未上场时门本身不参与,见 ToolApprovalCoordinator.admit)。
62
+ admitMaxPerTask: config.streamApproval.admitMaxPerTask,
63
+ admitMaxPerOwner: config.streamApproval.admitMaxPerOwner,
64
+ })
65
+ : undefined;
33
66
  // SendUserFile(真 CC 契约,clay 2026-07-14)两种 lane 形态,其余缺席=诚实(工具不进 roster):
34
67
  // - host lane:本地盘读+服务端上传,【单用户 only】——host 多租户=任意路径读→公网 URL 的 exfil 面,
35
68
  // clay 拍永久关死,不留口子。路径=绝对或相对 process.cwd(用户目录语义,与 CC 一致)。
@@ -92,6 +125,6 @@ export function createLiveCoordinators(ctx) {
92
125
  : rawSend;
93
126
  return sendUserFileTool({ send, emitter: sendUserFileEmitter });
94
127
  })();
95
- return { elicitation, question, toolApproval, durableEnabled, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec };
128
+ return { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec };
96
129
  }
97
130
  //# sourceMappingURL=coordinators.js.map
@@ -0,0 +1,14 @@
1
+ import { FileError, StubExecutionEnv, type Result } from "@sema-agent/core";
2
+ /**
3
+ * 无文件系统的路径裁决 env(见文件头)。只覆写 `absolutePath` 一面,其余全部继承 `StubExecutionEnv`
4
+ * 的 `not_supported` ——**继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会
5
+ * 随 `StubExecutionEnv` 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。
6
+ */
7
+ export declare class LexicalPathExecutionEnv extends StubExecutionEnv {
8
+ absolutePath(path: string): Promise<Result<string, FileError>>;
9
+ /** 词法文件系统是**空的**:没有任何路径存在。见文件头「绝对路径」那条的 🔴 段——这一面是 core 5.14.0
10
+ * 把 `exists` 报错改判 `unresolvedSymlink`(⇒ sensitive policy deny)之后,「不看盘」这条公理在新契约
11
+ * 下的**唯一**诚实表达;报错会被读成「探测失败」,而本 env 从来不探测。 */
12
+ exists(_path: string): Promise<Result<boolean, FileError>>;
13
+ }
14
+ //# sourceMappingURL=lexical-path-env.d.ts.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * #156 —— 非 host lane(e2b/k8s/ssh/adb/local-docker)写门的**纯路径规则**过渡形所用的裁决 env。
3
+ *
4
+ * ## 它解决的缺口
5
+ *
6
+ * core 的 `createFsWriteGatePolicy` / `createSensitivePathPolicy` 都把写目标交给
7
+ * `canonicalizeTarget(env, …)` 去「问 fs 要真身」。host lane 上那个 env 就是 hand 工具真正写的那块盘,
8
+ * 答案可信。沙箱 lane 不是:per-task 的沙箱 env 由 core 的 `executionEnvFactory` 在 spec **之后**才铸,
9
+ * spec 期手边只有 worker 本机的 fs——拿它去裁沙箱里的路径会答错(exists/symlink 全是别人机器上的事实),
10
+ * 而**错误的 allow 比没有门更糟**。所以 [816]/[820] 当年在沙箱 lane 诚实不挂门(wiring=undefined ⇒
11
+ * 三个模式臂全回落 base 规则,写门整条缺席)。#153 的 ③ 号注把这笔记成了余款,#156 是它的过渡还款。
12
+ *
13
+ * ## 这个 env 是什么
14
+ *
15
+ * 一个**没有文件系统**的 env:除 `absolutePath` 外的每一个 fs 面(exists / canonicalPath / fileInfo /
16
+ * read* / write* / listDir / createDir / …)与 shell 面都继承 core 的 `StubExecutionEnv`,
17
+ * 恒返回 `not_supported` 错误(`readLink` 是可选面,Stub 干脆没有——core 把缺席当「symlink 不可解」,
18
+ * 且该分支在本 env 恒不可达:exists 先错就进了 canonicalizeNewPath)——这不是伪装,这就是本 env 的诚实回答:**spec 期我们确实不知道沙箱里
19
+ * 那块盘上有什么**。`absolutePath` 是唯一被赋予真实语义的一面,而它本来就是纯路径运算(不碰盘):
20
+ * POSIX 绝对路径做词法归一后原样奉还,其余一律「不知道」。
21
+ *
22
+ * ## 由此得到的裁决语义(core dist 亲读推出,`tools/fs/safety.js` canonicalizeTarget)
23
+ *
24
+ * · 绝对路径(`/…`):`absolutePath` ok ⇒ core 进 exists 探测 ⇒ 我们答 `ok(false)`(「本 env 的词法文件
25
+ * 系统里什么都不存在」)⇒ core 走 `canonicalizeNewPath`,那里逐级 exists 同样 false ⇒ 原样返回我们给的
26
+ * 词法绝对路径。于是 canonical key = **词法归一后的路径**,`canonicalPath` / `fileInfo` / `readLink`
27
+ * 三面在这条路上根本不可达。
28
+ * 🔴 `exists` 为什么必须**答 false 而不是报错**(core 5.14.0 提货批实测,A/B 已取证):5.13.0 的
29
+ * `canonicalizeTarget` 把 `!(exists.ok && exists.value)` 一律折成「尚不存在」;5.14.0 改成 exists **报错**
30
+ * 即 `{ok:false, unresolvedSymlink:true}`(fail-loud 硬化,CHANGELOG 未披露),而
31
+ * `createSensitivePathPolicy` 对 `unresolvedSymlink` 的处置是 **deny**。两者相乘 ⇒ 沙箱 lane 上
32
+ * **每一个**写目标(含 `/work/proj/normal.txt`)都变成 deny,写门从「有门」退化成「全关」。
33
+ * 对真 env 那条 fail-closed 是对的;对本 env 不是——「盘上有什么」在这里不是探测失败,而是本设计的
34
+ * 定义域之外:词法裁决的公理就是**不看盘**。所以 `exists` 答 false 是这个 env 的诚实答案,不是伪装。
35
+ * (代价仍是文件头「已知残余面」里那条 symlink 不可见,与 5.13 下完全相同,没有新增放行面。)
36
+ * ⚖️ codex 复审 2026-08-06 对本改动提了「不该把未知 fs 状态说成确定不存在」(建议保持 fail-closed)。
37
+ * **逐条核对后不采纳**,理由三条,都可验证:
38
+ * ① 它不是本批引入的洞 —— A/B 实测 5.13.0 的 `canonicalizeTarget` 是
39
+ * `if (!(exists.ok && exists.value)) → canonicalizeNewPath`,exists **报错**与「不存在」同罪。
40
+ * 本改动**恢复**该语义,没有放宽任何一条既有裁决(85 条 task-settings 钉逐条为证)。
41
+ * ② 建议的「保持 fail-closed」在 5.14 下的真实后果是 `createSensitivePathPolicy` 对**每一个**写目标
42
+ * deny —— 沙箱 lane 的写门从「有门」变成「全关」,agent 在 e2b/k8s 里一个文件都写不了。那不是
43
+ * 更安全的门,是这条 lane 停摆;而本过渡形当初立项的整个理由就是「无门」不可接受。
44
+ * ③ 它指出的真残余(域内软链 + 会话豁免下可得 allow)是 2026-08-05 就成文登记的已知代价,不是新面。
45
+ * 真正的终局解在下面「终局 seam」那段:core 5.14.0 的 `HookToolContext.env` 已到货(提货单⑥),
46
+ * 沙箱 lane 可以在**工具执行时刻**拿真沙箱 env 裁决 —— 那一件独立成批,本批不夹带。
47
+ * · 相对路径 / `~/…` / Windows 盘符形:`absolutePath` 返回错误 ⇒ `canon.ok=false` ⇒ 写门直接
48
+ * `ask`(fail-closed)。沙箱的 cwd 在 spec 期不可知,这正是我们要的偏置——**不猜沙箱工作目录**。
49
+ * · 反斜杠 UNC 形(`\\host\share\…`)是唯一**不经过**本 env 的形:core 的 `canonicalizeTarget` 在入口
50
+ * 就把它短路成 `ok:true, key=原样`(复审 2026-08-05 dist 亲读+实测)。结果不变——写门无放行域可越,
51
+ * 落 defaultWrite 的 `ask`;sensitive 段匹配按 `[\\/]` 双分隔符切段,`\\host\share\.env` 照样 deny。
52
+ * · sensitivePatterns 的 deny 腿照常施加:它按 canonical key 的**路径段**做 glob 匹配,词法 key 足够,
53
+ * 且在 `combinePolicies` 折叠里 deny 恒胜(session 豁免 / accept 域越不过)。
54
+ * · `isExempt`(会话「本会话不再询问」探针)是 name-keyed 的,不做 fs 裁决,任何 lane 都安全,照接。
55
+ *
56
+ * ## 已知残余面(过渡形的边界,不是疏漏)
57
+ *
58
+ * · **symlink 形**:词法裁决看不见符号链接。沙箱里一个名字普通的软链可以指向守卫段(`.ssh` 等),
59
+ * 我们只会给 `ask` 而不是 `deny`;host lane 上 core 会 canonicalize 出真身并 deny。相应地,core 的
60
+ * `unresolvedSymlink ⇒ deny` 那条腿在本 env 下永不触发。注意豁免会话下的口径(codex 复审 E):
61
+ * name-keyed 豁免不看路径,这类词法无害的软链目标在豁免会话里是 **allow** 而非 ask——这是本残余面
62
+ * 在「操作员已授 don't-ask-again」情形下的完整代价,host lane 同情形仍会 deny。
63
+ * · **exempt/accept 域**:沙箱 lane 既无可信 cwd 也无 scratchpad 对应物,故本过渡形**一个自动放行域都
64
+ * 不铸**(见 resolve-spec 的 wiring)。代价是 acceptEdits 在沙箱 lane 退化成与 default 同形(全 ask);
65
+ * 这是 fail-safe 方向,与 host lane「accept 域解析不出 ⇒ 该域不生效」的哨兵先例同口径。
66
+ * · **`cwd` 字段**:继承 `StubExecutionEnv` 的 `"/"` 占位。本 env 只活在 policy 折叠里,core 的两个
67
+ * policy 都不读 `env.cwd`(相对路径基准走 `rootPath` 形参,而我们不给)——它不代表沙箱的工作目录。
68
+ *
69
+ * ## 终局 seam
70
+ *
71
+ * 正解是让写门在**工具执行时刻**拿到真沙箱 env:core [2751] 排期的 `HookToolContext.env` 只读能力窄面
72
+ * **已随 core 5.14.0 到货**(`canonicalPath`/`exists`/`readLink`/`cwd()` 四原语,逐成员在场即真有该原语;
73
+ * 注意 `cwd()` 是 env 自己的工作目录、**不是**任务跟踪 cwd)。接它 = 本过渡形整体退役换正解,沙箱 lane
74
+ * 与 host lane 走同一条真 fs 裁决,上面三条残余面一并消失。**本批不做**(它改的是裁决时刻与 wiring 形状,
75
+ * 属独立设计件);在它落地之前,上面三条残余面按原样有效。
76
+ */
77
+ import { posix } from "node:path";
78
+ import { FileError, StubExecutionEnv, err, ok } from "@sema-agent/core";
79
+ /** 词法归一:纯字符串运算,折 `.` / `..` / 重复分隔符,不碰 fs。
80
+ * `posix.normalize` 把 `..` 在根部截断(`/../x` → `/x`),与「沙箱根之上没有东西」的语义一致。
81
+ * 尾部分隔符统一剥掉(根 `/` 除外),让同一目标只有一个 key ——前缀判域靠的就是 key 的唯一性。
82
+ * (core 内部有同形的 `normalizeAbsPathLexically`,但未从包根导出;此处是 Node 标准库的等价运算,
83
+ * 不是它的抄本——若日后 core 导出,这里应改为直接复用。) */
84
+ function normalizeAbsolutePathLexically(path) {
85
+ const collapsed = posix.normalize(path.replace(/^\/+/, "/"));
86
+ return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
87
+ }
88
+ /**
89
+ * 无文件系统的路径裁决 env(见文件头)。只覆写 `absolutePath` 一面,其余全部继承 `StubExecutionEnv`
90
+ * 的 `not_supported` ——**继承而非逐一手写**是刻意的:core 日后给 `ExecutionEnv` 加必填面时,新面会
91
+ * 随 `StubExecutionEnv` 一起到位并保持同一个诚实答案,不会在这里留下一个悄悄编出来的假答案。
92
+ */
93
+ export class LexicalPathExecutionEnv extends StubExecutionEnv {
94
+ absolutePath(path) {
95
+ // POSIX 绝对形是沙箱 lane(全 Linux)上 hand 工具的书面契约形。其余一切——相对路径、`~`、
96
+ // `C:\…`——在 spec 期都无法诚实解析成一个沙箱内的绝对路径,返回错误让 core 落 ask。
97
+ // (反斜杠 UNC 形根本到不了这里:core 在 canonicalizeTarget 入口短路,见文件头「由此得到的裁决语义」。)
98
+ // codex 复审 A(2026-08-05):含 NUL 的字符串不是合法 POSIX 路径(任何 fs 面都写不进去),但词法归一
99
+ // 会照样给它铸出 canonical key——`\0` 尾巴让守卫段 glob 失配,豁免会话下还能拿 allow。拒收让 canon
100
+ // 在豁免咨询**之前**就失败 ⇒ 恒 ask。
101
+ if (path.includes("\u0000")) {
102
+ return Promise.resolve(err(new FileError("not_supported", "this environment adjudicates paths lexically and rejects a path containing a NUL byte (not a representable POSIX path)", path)));
103
+ }
104
+ if (!path.startsWith("/")) {
105
+ return Promise.resolve(err(new FileError("not_supported", "this environment adjudicates paths lexically and cannot resolve a non-absolute path (the sandbox working directory is unknown at spec time)", path)));
106
+ }
107
+ return Promise.resolve(ok(normalizeAbsolutePathLexically(path)));
108
+ }
109
+ /** 词法文件系统是**空的**:没有任何路径存在。见文件头「绝对路径」那条的 🔴 段——这一面是 core 5.14.0
110
+ * 把 `exists` 报错改判 `unresolvedSymlink`(⇒ sensitive policy deny)之后,「不看盘」这条公理在新契约
111
+ * 下的**唯一**诚实表达;报错会被读成「探测失败」,而本 env 从来不探测。 */
112
+ exists(_path) {
113
+ return Promise.resolve(ok(false));
114
+ }
115
+ }
116
+ //# sourceMappingURL=lexical-path-env.js.map
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * 目录源三态(单选,授权面不做双源合并):
6
6
  * · center 在场(configCenter 且非 dryRun)⇒ 远程腿:per-principal caps 响应的 `orgMemory` 段
7
- * (fetchPrincipalCaps schema 读+透传;段键缺席=旧 center 能力握手 目录记 section_absent 瞬时)。
7
+ * ( `fetchPrincipalOrgMemory` —— caps 腿同一条 HTTP 请求形与同一道 principal 回声核,但**只
8
+ * 投影 org 段**读,治理面值漂移不连坐;段键缺席=旧 center 能力握手 ⇒ 目录记 section_absent 瞬时)。
8
9
  * **独立 fetch,不复用 caps 缓存条目**(C3:org 授权段自带判别联合缓存,两张表的失败域互不干扰
9
10
  * ——caps 腿的 deny 缓存/304 复用机制一旦共享,org 面的 gen 拒退与退避语义就会被 caps 语义污染)。
10
11
  * 流量记账:每 principal 每 grant TTL(60s)至多多一拍 caps 拉取,有界。
@@ -14,10 +15,12 @@
14
15
  * · 都缺 ⇒ resolver 缺席:core 对 request-origin org scope 铸 `memory.admission_required`(fail-closed),
15
16
  * deployment-origin 走 `deploymentMemoryScopes` 自证通道不受影响(单用户零迁移,v4 §0 伤害①)。
16
17
  *
17
- * 能力探测(C12,fail-loud):多租户(requirePrincipal)部署的 projects 登记簿里挂了 org 键 ——这些
18
- * 键在多租户下按 request-origin 盖章(N2 部署形态维)——而目录源缺席(含 configCenter dryRun 形)
19
- * 启动报错:该部署的每个 projectId 请求都会在 prepare 期被整拒,「audit 在跑但恒拒」不是可运行
20
- * 状态,响亮拒启动比静默全拒服务诚实(clay 三裁 [2687]:直接 BREAKING,不留兼容臂)。
18
+ * 能力探测(C12,fail-loud):多租户(requirePrincipal)部署 **且记忆面真点亮**(MEMORY_ENGINE +
19
+ * backend file——见下方 `memoryPlaneLive` 注) projects 登记簿里挂了 org 键 ——这些键在多租户下
20
+ * request-origin 盖章(N2 部署形态维)——而目录源缺席(含 configCenter dryRun 形)⇒ 启动报错:
21
+ * 该部署的每个 projectId 请求都会在 prepare 期被整拒,「audit 在跑但恒拒」不是可运行状态,响亮拒
22
+ * 启动比静默全拒服务诚实(clay 三裁 [2687]:直接 BREAKING,不留兼容臂)。记忆面 dark 的部署里这些
23
+ * 键根本到不了准入门,不在探测范围(复审 D-1)。
21
24
  */
22
25
  import type { RunnerDeps } from "@sema-agent/core";
23
26
  import type { ServiceConfig } from "../config.js";
@@ -1,4 +1,4 @@
1
- import { fetchPrincipalCaps } from "../config-center/http-client.js";
1
+ import { fetchPrincipalOrgMemory } from "../config-center/http-client.js";
2
2
  import { createMemoryScopeAdmission, createOrgMemoryDirectory, parseOrgDirectoryStatic } from "../org-memory-admission.js";
3
3
  const ORG_PREFIX = "org:";
4
4
  /** 部署自证 org scope 集(core 准入门的 deployment origin 词表):env 钉的 MEMORY_SCOPE(org 形时)
@@ -31,14 +31,11 @@ export function createOrgMemoryAdmissionWiring(opts) {
31
31
  }
32
32
  const directory = centerLeg !== undefined
33
33
  ? createOrgMemoryDirectory({
34
- fetchSection: async (principal) => {
35
- // etag 恒缺席 fetchPrincipalCaps 永不走 304 臂(返回 null 的唯一途径是条件请求),
36
- // 这里的 null 防御臂只为类型收口;C8 回声核与 schema 校验都在 fetchPrincipalCaps 内。
37
- const r = await fetchPrincipalCaps(centerLeg.baseUrl, centerLeg.token, principal, undefined, fetch, centerLeg.worker);
38
- if (r === null)
39
- throw new Error("config-center returned 304 to a non-conditional org-directory fetch");
40
- return r.orgMemory;
41
- },
34
+ // 复审 D-2 修:走 org 段专用读取口而不是整封 caps 读。两者同一条 HTTP 腿、同一道 C8 回声核,
35
+ // 差别只在**解析投影**:org 腿只读 principal+orgMemory 两字段,治理面(runtimeCaps/budget)
36
+ // 值漂移因此不再连坐 org 授权面(此前:一封 runtimeCaps 漂移的响应会让形状完全正确的 org
37
+ // 读不出来 目录记 fetch_failed 准入瞬时拒)。C3「两张表失败域互不干扰」至此是双向的。
38
+ fetchSection: (principal) => fetchPrincipalOrgMemory(centerLeg.baseUrl, centerLeg.token, principal, fetch, centerLeg.worker),
42
39
  grantTtlMs: config.memoryOrgGrantTtlMs,
43
40
  unavailableBackoffMs: config.memoryOrgUnavailableBackoffMs,
44
41
  })
@@ -49,8 +46,17 @@ export function createOrgMemoryAdmissionWiring(opts) {
49
46
  unavailableBackoffMs: config.memoryOrgUnavailableBackoffMs,
50
47
  })
51
48
  : undefined;
52
- // C12 能力探测:多租户 + 登记簿有 org 用面 + 目录源缺席 ⇒ 拒启动(见文件头注)。
53
- if (directory === undefined && config.requirePrincipal === true) {
49
+ // C12 能力探测:多租户 + **记忆面真点亮** + 登记簿有 org 用面 + 目录源缺席 ⇒ 拒启动(见文件头注)。
50
+ //
51
+ // 复审 D-1(7.1.0 后修):探测原先少了「记忆面是否真点亮」这一维,是假阳。多租户下记忆引擎只在
52
+ // `MEMORY_ENGINE` 开 **且** backend 非 file 时才装(boot/stores.ts:file 形在 requirePrincipal 下恒
53
+ // 走 memoryEngineBackendFor() = undefined,「file 底座无租户隔离」);记忆面 dark ⇒ resolve-spec 的
54
+ // `memoryEngine ? memorySpecForRequest(...) : undefined` 根本不铸 spec.memory ⇒ 登记簿里的 org 键
55
+ // 一次也到不了 core 准入门。此时拒启动既拦下了一个本来跑得好好的部署,报错串里那句「every request
56
+ // selecting these projects would be refused at prepare」也是假陈述——探测的成立前提就是「这些键真会
57
+ // 被请求带到准入门」。
58
+ const memoryPlaneLive = config.memoryEngineEnabled && config.memoryEngineBackend !== "file";
59
+ if (directory === undefined && config.requirePrincipal === true && memoryPlaneLive) {
54
60
  const orgProjects = Object.entries(config.projects)
55
61
  .filter(([, p]) => (p.defaultScopes ?? []).some((s) => s.startsWith(ORG_PREFIX)))
56
62
  .map(([id]) => id);
@@ -64,8 +70,13 @@ export function createOrgMemoryAdmissionWiring(opts) {
64
70
  ? createMemoryScopeAdmission(directory, {
65
71
  mode: config.memoryOrgAdmissionMode,
66
72
  // §5 观测:outcome 闭集 metric + 审计日志线(per-principal 归因走日志,principal 不入 metric label)。
73
+ // 复审 D-3(7.1.0 后修)两处:①`mode` 进 metric label —— 不然 audit 模式记的 would-refuse 与
74
+ // enforce 模式的真拒在同一条 `outcome="denied"` 曲线上不可辨,而「照算不真拒、看曲线」正是
75
+ // audit 这个诊断位存在的唯一用途(二值闭集,基数代价为零);②principal 真的进日志行 —— 归因
76
+ // 由 resolver 塞进 details(此前 details 里根本没有 principal,「per-principal 归因走日志」
77
+ // 这句注释与 CHANGELOG 7.0.0 的同款公开陈述都不成立:运维只看得到「某个 org scope 被拒」)。
67
78
  onOutcome: (outcome, details) => {
68
- metrics.inc("memory_admission_total", { outcome });
79
+ metrics.inc("memory_admission_total", { outcome, mode: config.memoryOrgAdmissionMode });
69
80
  if (outcome !== "ok" || details !== undefined)
70
81
  logger.warn("memory_admission_outcome", { outcome, ...details });
71
82
  },