@sema-agent/server 7.12.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 (51) hide show
  1. package/USAGE.md +30 -2
  2. package/dist/adoption/plan.d.ts +38 -4
  3. package/dist/adoption/plan.js +72 -0
  4. package/dist/adoption/quiesce.d.ts +70 -0
  5. package/dist/adoption/quiesce.js +148 -0
  6. package/dist/adoption/runner.js +63 -5
  7. package/dist/adoption/sql.d.ts +15 -0
  8. package/dist/adoption/sql.js +18 -0
  9. package/dist/adoption/wire.d.ts +7 -1
  10. package/dist/adoption/wire.js +6 -0
  11. package/dist/approval-card.d.ts +5 -0
  12. package/dist/approval-card.js +22 -0
  13. package/dist/boot/permission-rules-audit.js +29 -1
  14. package/dist/boot/reapers.d.ts +15 -0
  15. package/dist/boot/reapers.js +101 -44
  16. package/dist/boot/runner-deps.d.ts +16 -2
  17. package/dist/boot/runner-deps.js +5 -4
  18. package/dist/config-types.d.ts +14 -2
  19. package/dist/http/active-run-conflict.d.ts +33 -8
  20. package/dist/http/active-run-conflict.js +37 -2
  21. package/dist/http/routes/adoption.js +25 -2
  22. package/dist/http/routes/approvals-assistant.js +33 -3
  23. package/dist/http/routes/capabilities.js +25 -1
  24. package/dist/http/routes/images.js +18 -0
  25. package/dist/http/routes/runs.js +21 -5
  26. package/dist/http/routes/tasks.js +18 -6
  27. package/dist/http/server.d.ts +26 -8
  28. package/dist/http/server.js +102 -16
  29. package/dist/main.js +46 -6
  30. package/dist/observability/fail-open.d.ts +4 -0
  31. package/dist/observability/fail-open.js +4 -0
  32. package/dist/plugins/adoption-log-sql.d.ts +40 -0
  33. package/dist/plugins/adoption-log-sql.js +69 -2
  34. package/dist/plugins/file-run-store.d.ts +85 -1
  35. package/dist/plugins/file-run-store.js +450 -17
  36. package/dist/plugins/permission-rule-store-sql.d.ts +45 -0
  37. package/dist/plugins/permission-rule-store-sql.js +60 -2
  38. package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
  39. package/dist/plugins/shared-memory-store-sql.js +55 -18
  40. package/dist/plugins/sql-driver.d.ts +19 -0
  41. package/dist/plugins/sql-driver.js +12 -0
  42. package/dist/plugins/store-backend.js +24 -1
  43. package/dist/rules-consent.d.ts +33 -4
  44. package/dist/rules-consent.js +43 -2
  45. package/dist/run-local.js +6 -2
  46. package/dist/runtime-governance.d.ts +33 -0
  47. package/dist/runtime-governance.js +32 -0
  48. package/dist/tool-approval.d.ts +32 -0
  49. package/dist/tool-approval.js +20 -0
  50. package/dist/trace/core-keyset-guard.d.ts +1 -1
  51. package/package.json +3 -3
@@ -26,7 +26,10 @@ import { redactSecrets } from "../trace/redact.js";
26
26
  import { IdempotencyCache, scopedIdempotencyKey } from "./idempotency.js";
27
27
  export { scopedIdempotencyKey };
28
28
  import { streamSseLog } from "./sse-log.js";
29
- import { clampVerifyRounds, verifyRoundsFromBody } from "./verify-rounds.js"; // design/97 R9: the shape's neutral leaf (route-ctx.ts type-imports VerifyRoundsSpec from here, not from this file)
29
+ // design/97 R9: the shape's neutral leaf (route-ctx.ts type-imports VerifyRoundsSpec from here, not from this file).
30
+ // `clampVerifyRounds` used to be re-exported from here for the tests; that re-export was deleted (8566348) and the
31
+ // import sat on as a dead name — tests import it from `verify-rounds.js` directly, which is the single owner.
32
+ import { verifyRoundsFromBody } from "./verify-rounds.js";
30
33
  import { handleCapabilities } from "./routes/capabilities.js";
31
34
  import { handleObservability } from "./routes/observability.js";
32
35
  import { handleDiagnostics } from "./routes/diagnostics.js";
@@ -135,7 +138,14 @@ function isCheckpointReopenedFailure(r) {
135
138
  // named alias so the existing call sites read clearly; the JWT logic now lives in one place that createAuthorizer
136
139
  // shares, so spec.principal (governance + cost) and these observability/owner sites can never drift apart again.
137
140
  /** design/158 A9 装配表(顺序 = 拆分前 `handle()` 里各域出现的先后,逐行同序;真正的 `return` 在 handle 内,
138
- * 这里只做**签名一致性**的编译期钉:任何域入口漂了形,是编译红,不是运行时静默 404)。 */
141
+ * 这里只做**签名一致性**的编译期钉:任何域入口漂了形,是编译红,不是运行时静默 404)。
142
+ *
143
+ * 🔴 A-010.24(验真后修):上面那句「逐行同序」此前是**一句自述,没有任何东西执法**,而它已经不成立 ——
144
+ * `handleDiagnostics` 从来没进过这张表,车二着陆后 `handleRules` / `handleSharedMemory` 的先后也反了。
145
+ * 类型钉不住这两类漂:少一个成员、调换两个成员,`RouteHandler[]` 全都收下。而这张表是评审时唯一会被
146
+ * 当成「域全集 + 域次序」来读的清单(新域该插哪儿、某域在谁之后才可达),它给错答案是要害。
147
+ * 执法面现在真的有了:`test/route-shape-roster.test.ts` 的**层D**从 `handle()` 的真源码抽出域调用序,
148
+ * 与本表**逐项**对拍 —— 漏员或乱序都指名道姓地红。改 handle() 的次序时,这张表要跟着改。 */
139
149
  const ROUTE_DOMAINS = [
140
150
  handleTraceUsage,
141
151
  handleWorkflows,
@@ -150,10 +160,11 @@ const ROUTE_DOMAINS = [
150
160
  handleImages,
151
161
  handleApprovalsAssistant,
152
162
  handleObservability,
163
+ handleDiagnostics,
153
164
  handleAdoption,
154
165
  handleMemoryPolicy,
155
- handleSharedMemory,
156
166
  handleRules,
167
+ handleSharedMemory,
157
168
  handleSessionsList,
158
169
  handleSessions,
159
170
  handleSessionSync,
@@ -528,10 +539,16 @@ export function createHttpServer(rawDeps) {
528
539
  // The bake door (POST /v1/images/bakes*) is build-host-RCE-capable and authed by the Bearer-token-no-cookie
529
540
  // model (§P2.4b) — it MUST also refuse when no service token is configured (else a forged principal header
530
541
  // alone could reach an operator-gated RCE door).
531
- if (req.method === "POST" &&
532
- !anyServiceAuth &&
542
+ // A-010.23 (#209 件5) adds the same sibling arm for the REWRITE doors (`isCredentialGatedRewrite`:
543
+ // adoption + rules/cc-import + rules revoke) — same sentence, same threat, different verbs: one rekeys every
544
+ // row a principal owns, one mints that principal's DURABLE allow rules, one deletes them. They stay OUT of
545
+ // `isBillableSubmitPath` on purpose (billable=false; see that predicate's twin comment), and that family is
546
+ // METHOD-AWARE (its revoke verb is DELETE) so it is evaluated OUTSIDE the POST conjunct — same shape as
547
+ // `isDestructiveSessionWrite` below.
548
+ if (!anyServiceAuth &&
533
549
  !deps.config.allowUnauthedWrites &&
534
- (isBillableSubmitPath(url) || url.startsWith("/v1/images/bakes"))) {
550
+ ((req.method === "POST" && (isBillableSubmitPath(url) || url.startsWith("/v1/images/bakes"))) ||
551
+ isCredentialGatedRewrite(req.method ?? "", url))) {
535
552
  sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions");
536
553
  return;
537
554
  }
@@ -2187,9 +2204,13 @@ export function createHttpServer(rawDeps) {
2187
2204
  return { status: 200, body: { taskId, sessionId, status: "failed", errorCode: "cancelled" } };
2188
2205
  }
2189
2206
  // We flipped the row to `running` (claimedRow). Two CheckpointError classes diverge here (D-1, core 1.101):
2190
- // • TERMINAL (already_resolved / not_found / gate_mismatch / unsupported_version): the checkpoint is
2207
+ // • TERMINAL (already_resolved / not_found / gate_mismatch): the checkpoint is
2191
2208
  // consumed or gone — drive the row terminal now (releasing task_active) instead of leaving a zombie
2192
2209
  // `running` row for reapStale to mislabel and hold the session lock for ~runStaleSec. (Original case.)
2210
+ // ⚠️ `unsupported_version` USED to sit in this bullet unconditionally (its checkpoint does NOT go
2211
+ // away — that was the registered orphan debt). As of #209 / core 5.25.0 it SPLITS on
2212
+ // `detail.reason`: a recognised worker-swap word joins the retriable class below; an absent /
2213
+ // unrecognised word stays TERMINAL. Read the block under the closed set before touching it.
2193
2214
  // • RETRIABLE PRE-CAS (invalid_outcome = binding mismatch / reopen_revote = env_failed must replay the
2194
2215
  // persisted winner / reopened_concurrently = a concurrent resolve-reopen advanced the rev): the
2195
2216
  // checkpoint STAYS pending and the operator re-fetches + re-/decides. Driving the row terminal would
@@ -2207,7 +2228,34 @@ export function createHttpServer(rawDeps) {
2207
2228
  // 会释放 task_active 并孤儿化那条仍 pending 的卡 —— 一张还能点的卡当场变死件。
2208
2229
  e.code === "resume.constraint_rejected" ||
2209
2230
  e.code === "resume.constraint_unprojectable" ||
2210
- e.code.startsWith("wake."); // design/144:wake 拒绝(gate_pending/nothing_to_deliver)全是 PRE-CAS,checkpoint 保持 pending——驱 terminal 会孤儿化仍在等的 park
2231
+ e.code.startsWith("wake.") || // design/144:wake 拒绝(gate_pending/nothing_to_deliver)全是 PRE-CAS,checkpoint 保持 pending——驱 terminal 会孤儿化仍在等的 park
2232
+ // 🟢 **`checkpoint.unsupported_version` 的一臂精确 retriable**(#209 件2,core 5.25.0 提货批
2233
+ // [3443];取代 #205 件4② 那条「维持 TERMINAL + 孤儿代价在案」的登记账)。
2234
+ //
2235
+ // 旧账的形状(留着,因为它解释了为什么这条臂长这样):该码在 core 上有**三个** PRE-CAS 铸点
2236
+ // (版本超上限 / org 治理行落到无 `permissionRuleOrg` 的 worker / 带 remote `workspaceHandle` 的行
2237
+ // 落到无 `executionEnvFactory` 的 Runner),三形卡一律留 pending 但**可恢复性不同**,而 core 当时
2238
+ // 同码同 `detail`(都不带)⇒ 下游只剩「按报文文案猜」或「按 checkpoint 形状反推」两条路,两版修法
2239
+ // 各被一轮 codex 对抗复审验伪(blanket-retriable 让无手臂永挂 task_active;按 `gate.realApproval`
2240
+ // 分流不健全 —— core 把该位放 gate、`workspaceHandle` 放 state,同一条行可以两样都带)。按宪法
2241
+ // 「源头修复,禁下游旁路」全回退,并把判别式列成上游请托([3434] 请托 1)。
2242
+ //
2243
+ // 请托已兑现:core 5.25.0 起三个铸点各带 `detail.reason`(`runtask.js:3706/3721/3733` 亲读),
2244
+ // d.ts 逐字说三者都是「retryable on a newer / factory-wired / org-wired worker」。⇒ 本臂**读词表**
2245
+ // (`WORKER_SWAP_REDEEMABLE`,闭集、新词 tsc 红),不做任何形状推断:
2246
+ // · 认得的三个词 ⇒ retriable:行重新 park suspended,那条仍 pending 的卡不再被孤儿化
2247
+ // (旧账里「行 failed 而卡活着、下一次 /decide → 404、只能等 `terminal_at_ms`」的代价至此销掉)。
2248
+ // · 词缺席 / 认不得 ⇒ **维持 TERMINAL**(旧行为逐字保留)。这不是保守的装饰:旧引擎的裸抛与
2249
+ // 将来某个我们还没读过的新臂都落在这一支,而把它们默认成 retriable 会把一条**真的没救**的行
2250
+ // 永久 park 在 suspended 上攥着 task_active —— 那正是 codex R1-[high] 验伪掉的那一版。
2251
+ // 🔴 形③(`env_factory_missing`)按 core 的词判 retriable,而不是按 #205 那条「选 Runner 由
2252
+ // `handsLanes.laneOf(spec)` 按场景判,重投永远同样失败」的推断:那条推断说的是**同一份部署配置下**
2253
+ // 重投无用,而 core 的词说的是「接上 `executionEnvFactory` 的 worker 兑得掉」—— 那是一次运维动作,
2254
+ // 与 `governed_unwired` 要求接 `permissionRuleOrg` 完全同族。两者都不是「换个副本再点一次」。
2255
+ // 期间行停在 suspended 攥着会话锁,与它 park 时的状态**逐字相同**(这条腿只是没能把它推进),
2256
+ // 而 TERMINAL 那一支要用「释放锁」换「卡变死件」,方向更坏。
2257
+ // 行为面钉:`test/durable-resume-http.test.ts` 的「#209 件2」三正格 + 三反极格。
2258
+ (e.code === "checkpoint.unsupported_version" && checkpointRowRedeemableElsewhere(e.detail?.reason));
2211
2259
  if (claimedRow && taskId && deps.runStore) {
2212
2260
  // Re-park on the SAME gate family the outcome targeted: a plan_review retry must re-park `needs_review`
2213
2261
  // (keep the row's review status + the lock), not `suspended` — else GET /v1/assistant/tasks mislabels it.
@@ -2798,14 +2846,19 @@ export function createHttpServer(rawDeps) {
2798
2846
  // value AS an http.Server — listen/close/etc. — while main.ts's reaper reads server.denyExpiredApprovals).
2799
2847
  return Object.assign(server, { denyExpiredApprovals });
2800
2848
  }
2801
- /** SSE: replay durable events after Last-Event-ID, then tail until the run is terminal (or stale). */
2802
- /**
2803
- * The differences a concrete log (task_run | image_bake) feeds the ONE resumable SSE reader (P2.8). Everything
2804
- * the reader does — Last-Event-ID/`?from=` resume, the 416 retention boundary, the per-poll concurrent
2805
- * status+events read, the terminal re-fetch (the terminal event lands in the gap before the status flips), the
2806
- * stale fallback, the 15-min cap, the 15s idle heartbeat — is provider-agnostic and lives in `streamSseLog`.
2807
- * 🔴 The task_run provider MUST keep the existing wire bytes EXACTLY (center's relay + 730+ tests depend on it).
2808
- */
2849
+ const WORKER_SWAP_REDEEMABLE = {
2850
+ version_newer: true,
2851
+ env_factory_missing: true,
2852
+ governed_unwired: true,
2853
+ real_approval_damaged: false,
2854
+ real_approval_forged: false,
2855
+ constraint_chain_missing: false,
2856
+ };
2857
+ /** 判别符缺席(旧引擎裸抛)或词表不认得 ⇒ **false**,即维持修前的 TERMINAL 归属。fail-closed 的方向在
2858
+ * 这里是「不宽容默认」:一个我们还没读过的新臂不该因为一个 `?? true` 被悄悄改判成「重试吧」。 */
2859
+ function checkpointRowRedeemableElsewhere(reason) {
2860
+ return reason !== undefined && WORKER_SWAP_REDEEMABLE[reason] === true;
2861
+ }
2809
2862
  /** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
2810
2863
  * guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
2811
2864
  * via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
@@ -2827,6 +2880,39 @@ export function isBillableSubmitPath(url) {
2827
2880
  SESSION_WAKE_RE.test(url) ||
2828
2881
  url.startsWith("/v1/approvals"));
2829
2882
  }
2883
+ /**
2884
+ * A-010.23(#209 件5)—— **改写门**:不烧模型、但在**没有任何 service credential** 的部署形下必须与
2885
+ * billable / bake 两族一样 fail-closed 的写门。谓词与 `isBillableSubmitPath` **刻意分开**:这几扇门
2886
+ * `billable=false`(零模型工作),塞进那张名单会让它们连带吃 drain 与 model-roster-pending 两道 503 ——
2887
+ * 而收编与规则读写在排空期/roster 未落时做完全无害。
2888
+ *
2889
+ * 判据(两个合取项,缺一不进本表)= 「授权的唯一输入是 principal 头」∧「动作是**持久改写**」:
2890
+ * · `POST /v1/adoption` —— operator-only 的一次性部署级动作,把一个 principal 名下**每一行**重写到另一个
2891
+ * 身份;授权判据 `explicitOperatorOk(gatedPrincipal(...))`。
2892
+ * · `POST /v1/rules/cc-import/*` —— principal lane 的**持久 allow 规则**铸造口;一条持久 allow 规则对后续
2893
+ * 同命令的 classify 档 ask 常驻消音(core #144 之后仍然如此,只是不再消 mandated 的那些)。
2894
+ * · `DELETE /v1/rules` —— 同一个规则店的**撤销**口。方向相反(收紧)但同样是持久改写,而且它多一条
2895
+ * operator 越权域(`?principal=` / body 的 `principal` 让 operator 收回**任一**租户的规则)⇒ 伪造一个
2896
+ * 列在 `OPERATOR_PRINCIPALS` 里的头,就能把每一位租户的规则一次清空。
2897
+ * 无 service token 的部署上 `verifiedPrincipal` 走的是「BFF/gated:头已由上游验过」那一支,而这一形恰恰
2898
+ * 是**没有**那个上游 —— 于是伪造一个头就能收编别人、替任意租户种下常驻放行、或替他们全删。bake 门当年
2899
+ * 补的就是同一句话,`isDestructiveSessionWrite` 从逐条路由白名单改成族判定也是同一句话(那次的措辞逐字是
2900
+ * 「a forged principal header alone could **tighten/DoS** a session's tools」——撤销口落的正是 DoS 那一半)。
2901
+ *
2902
+ * 🔴 **方法感知**(codex 对抗复审 R2-[high],验真后修):撤销走的是 `DELETE`,而本门最初只挂在 `POST` 的
2903
+ * 合取里 —— 于是一个自称「持久改写」的谓词把爆炸半径最大的那条动词漏在门外,正是 `isDestructiveSessionWrite`
2904
+ * 当年被抓到的同一形。签名因此收方法,与那只谓词逐字同形。
2905
+ * 🔴 读面不进本表:`GET /v1/adoption/:id` 与 `GET /v1/rules` 各有自己的属主/operator 门,本门只拦写。
2906
+ * 行为面钉:`test/rewrite-door-service-token-gate.test.ts`(四扇门各一正一反 + 逃生口格 + 读面负控)。
2907
+ */
2908
+ export function isCredentialGatedRewrite(method, url) {
2909
+ const m = method.toUpperCase();
2910
+ if (m === "POST")
2911
+ return url === "/v1/adoption" || url.startsWith("/v1/rules/cc-import/");
2912
+ if (m === "DELETE")
2913
+ return url === "/v1/rules";
2914
+ return false;
2915
+ }
2830
2916
  // ⚠️ SSE 帧纪律(2026-07-12):center BFF 中继凭「data 无 type 字段」识别心跳帧并吞掉
2831
2917
  // (AgentEvent 是 closed oneof 全带 type,故可控)。**未来新增任何 SSE data 帧必须带 type 字段**,否则会被
2832
2918
  // 中继当心跳吞掉、永远到不了前端。heartbeat 帧(`event: heartbeat` + `data: {}`)是唯一豁免。
package/dist/main.js CHANGED
@@ -189,6 +189,23 @@ async function main() {
189
189
  // 🔴 总开关在最前(codex round7 [high] 一):关 ⇒ 整条车道根本不装配(店/车道/帧键/两口一起消失)。
190
190
  const permissionRuleStores = config.permissionRulesEnabled && backend ? backend.permissionRule() : undefined;
191
191
  const ruleConsent = permissionRuleStores ? createRuleConsentLane(permissionRuleStores) : undefined;
192
+ // 🔴 A-010.10(验真后修):**旋钮开着却上不了场 ⇒ 打一行**,与同族旋钮 `STREAM_APPROVAL_ENABLED` 的
193
+ // `stream_approval_disabled` 一行(`boot/coordinators.ts`)对称。
194
+ // 此前这一格全程静默:`PERMISSION_RULES_ENABLED=true` 的部署若没有 backend、或 backend 没实装规则店,
195
+ // 车道就悄悄整条不装配 —— 运维看到的是「设了旋钮,可 `/v1/rules` 恒 501、卡上恒无候选」,而启动日志
196
+ // 里一个字都没有。判据逐字照那条先例:①**info 不是 warn**(这是合法部署形,不是配置错误);
197
+ // ②旋钮本来就没开的部署**不打**(显式关掉的人自己知道为什么,一行噪音零信息);
198
+ // ③原因写成机读位(哪一项不成立),不是一句人话。
199
+ // 与紧随其后的 `auditDormantPermissionRules` 是两件事、互不遮蔽:那条说的是「车道上了场,并且库里有
200
+ // 既有桶被默认 ON 唤醒」,这条说的是「车道压根没上场」——两者的合取恒假,永远只会打其中一行。
201
+ if (config.permissionRulesEnabled && permissionRuleStores === undefined) {
202
+ logger.info("permission_rules_lane_unavailable", {
203
+ knob: "PERMISSION_RULES_ENABLED",
204
+ reason: backend === undefined ? "no_backend" : "backend_has_no_rule_store",
205
+ backendKind: backend?.kind ?? null,
206
+ note: "PERMISSION_RULES_ENABLED is on but no permission-rule store is wired — /v1/rules/* answer 501, approval cards carry no rule suggestions, and respond's persistRule is refused with rule_lane_unavailable",
207
+ });
208
+ }
192
209
  // #203 §3(设计稿 v2 F4 残余):默认 ON 会把**既有**规则桶一并唤醒 —— 在启动日志里把它说出来。
193
210
  // 三条判据(店缺席 / 零桶 / 运维显式表过态 ⇒ 都不打行)与失败方向(数不出来只 warn,绝不拒启、
194
211
  // 也绝不编一个 0)逐字见 `boot/permission-rules-audit.ts`。
@@ -240,8 +257,15 @@ async function main() {
240
257
  // ⚠️ 刻意**不动** `toolResultStore` 本身:那个键的「present ⇔ durable 后端在场」语义还管着 E21 purge、
241
258
  // reaper 清扫、leader 装配与 /health 的接线自述(改它会让无 backend 的部署自述成 "shared(sql)" = 谎)。
242
259
  const runnerOffloadStore = toolResultStore ?? new InMemoryToolResultStore({ maxTotalChars: 64_000_000 });
260
+ // 交接件⑤:commit 尾注署名座**算一次**,主/sub 两只 Runner 经共享基座同源。
261
+ // [931]① clay 拍(core 1.300 BREAKING:缺省不署 Co-Authored-By,署名=产品身份资产归部署):
262
+ // branded 形态(local provider = Sema 产品线,scenarios brandIdentity 同判据)commit 尾注接 Sema 署名;
263
+ // 非 brand 部署维持 core 新缺省(不署)。判据逐字不变,变的只是它现在**也**喂给 subRunner ——
264
+ // 一个被委派的子代提交进的是同一个仓、代表同一个部署,它的 commit 少一行 trailer 没有理由。
265
+ const commitHands = config.configProvider === "local" ? { commitCoAuthor: "Sema <noreply@vivi-ai.com>" } : undefined;
243
266
  // design/158 A10:RunnerDeps 装配段搬到 src/boot/runner-deps.ts(逐字;runStore 晚绑改取值,见该文件头注)。
244
267
  const runnerDeps = createRunnerDeps({
268
+ hands: commitHands,
245
269
  config, logger, metrics, localRoot, promptSource: configCenter.promptSource, rosterStore, backgroundAgentStore, mailboxStore, usageWindowStore, brain,
246
270
  pricing, tracer, outcomeSink, elicitation, question, toolApproval, sessionStore, memoryEngine,
247
271
  memorySyncRunner, toolResultStore: runnerOffloadStore, sessionPolicyStore, runtimeCapsResolver, fileSnapshotStore,
@@ -274,11 +298,26 @@ async function main() {
274
298
  // 与 resource suspend 都会写),续跑时被路由到这只无手 Runner,core 在 **CAS 之前**抛
275
299
  // `CheckpointError("checkpoint.unsupported_version", "checkpoint has a remote workspaceHandle but no
276
300
  // RunnerDeps.executionEnvFactory is wired to rebuild the env")`(runtask.js:3704)。
277
- // 本仓的分类表把该码归 **TERMINAL**(http/server.ts CheckpointError 分支):run 行被 `setTerminal`
278
- // failed 并**释放** task_active,响应 409 携带该 errorCode —— 会话**不会**被占住到 TTL,运维重提该
279
- // 任务即可。故这是**响亮的、有界的**升级窗代价,不是静默损坏;但它是行为面的升级注记,发车说明必须写。
280
- // ⚠️ 未覆盖登记:本形需要「真 durable + 带 workspaceHandle 的旧 checkpoint」才能驱动,本批未建格
281
- // (属真双库/迁移测试面)—— 移交主会话裁定是否要做 checkpoint 感知的续跑选路或启动期预检。
301
+ // 🟢 **裁定已翻新(#209 件2,core 5.25.0 提货批 [3443]):本形现在归 RETRIABLE。**
302
+ // 沿革留档(免下次重开同一个问题):该码在 core 上共三个铸点 —— 本形 / 版本超上限 / org 治理行
303
+ // 落到无 `permissionRuleOrg` 接线的 worker —— 三形都是 PRE-CAS、卡一律留 pending,但**可恢复性
304
+ // 不同**;#205 当时 core **不给判别式**(同码、`detail` 同样缺席),两版下游修法各被一轮 codex
305
+ // 对抗复审验伪(①三形一律 retriable ②按 `gate.realApproval` 分流),按「源头修复禁下游旁路」全
306
+ // 回退、维持 TERMINAL,并把判别式列成上游请托([3434] 请托 1)。
307
+ // 请托已兑现:core 5.25.0 给三个铸点各带 `CheckpointError.detail.reason`,本形 = `env_factory_missing`,
308
+ // d.ts 逐字「retryable on a factory-wired worker」。⇒ `http/server.ts` 的分类表现在**读词表**判
309
+ // (`WORKER_SWAP_REDEEMABLE`,闭集、core 加词 tsc 红),不做形状推断;本形的行**重新 park
310
+ // suspended**、那条仍 pending 的卡不再被孤儿化(#205 登记的孤儿代价至此销账)。
311
+ // ⚠️ 与旧裁定的差别在**代价的方向**,如实说清:修前拿「释放 task_active」换「卡变死件(下一次
312
+ // /decide → 404,只能等 `terminal_at_ms`)」;修后行停在 suspended 攥着会话锁,与它 park 时逐字
313
+ // 相同,直到运维给这条车道接上 `executionEnvFactory`(或把该场景路由回有手 Runner)。#205 当年
314
+ // 驳掉 blanket-retriable 的那条理由(`handsLanes.laneOf(spec)` 按场景判 ⇒ 换副本重投必同样失败)
315
+ // 仍然成立,但它说的是「换个副本再点一次没用」,而 core 的词说的是「接上 factory 的 worker 兑得掉」
316
+ // —— 那是一次**运维动作**,与 `governed_unwired` 要求接 `permissionRuleOrg` 完全同族。
317
+ // 升级窗仍是**响亮的**(409 + 该 errorCode + `retriable: true`),不是静默损坏;发车说明照写。
318
+ // 论证与钉:`http/server.ts` 分类表下方那段 + `test/durable-resume-http.test.ts` 的「#209 件2」六格。
319
+ // ⚠️ 未覆盖登记(原样保留):本形需要「真 durable 店 + 带 workspaceHandle 的旧 checkpoint」才能
320
+ // 驱动,真店端到端仍属真双库/迁移测试面,未建。
282
321
  const handslessRunner = new Runner(withoutExecutionEnv(runnerDeps));
283
322
  // codex R10: TRUE ⇒ the Runner just froze a PRIVATE tier-expanded catalog copy (core runtask.js constructor,
284
323
  // dist-read) — in-place model-plane mutation no longer reaches it, so refresh-time plane changes must be
@@ -404,6 +443,7 @@ async function main() {
404
443
  // 基座只有一份)。差异键在展开后显式列出,每个都有为何不同的理由(见 boot/runner-deps.ts 头注)。
405
444
  ...createSharedRunnerDeps({
406
445
  config,
446
+ hands: commitHands, // 交接件⑤:与主 runner 同一个值(基座保证同源)
407
447
  brain,
408
448
  pricing,
409
449
  tracer,
@@ -758,7 +798,7 @@ async function main() {
758
798
  config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore,
759
799
  rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap,
760
800
  workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore,
761
- backgroundAgentStore, mailboxStore, toolApproval,
801
+ backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores,
762
802
  getRunDenySweep: () => runDenySweep,
763
803
  });
764
804
  // Optional OTLP/HTTP metrics export (1.37). Periodically pushes the registry to an OTel collector;
@@ -57,6 +57,10 @@ export declare const FAIL_OPEN_TAGS: {
57
57
  readonly cls: "F";
58
58
  readonly note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。";
59
59
  };
60
+ readonly "server.run-store.stale-claim-quarantine-unremoved": {
61
+ readonly cls: "F";
62
+ readonly note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。";
63
+ };
60
64
  };
61
65
  /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
62
66
  export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
@@ -80,6 +80,10 @@ export const FAIL_OPEN_TAGS = {
80
80
  cls: "F",
81
81
  note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。",
82
82
  },
83
+ "server.run-store.stale-claim-quarantine-unremoved": {
84
+ cls: "F",
85
+ note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。",
86
+ },
83
87
  };
84
88
  /**
85
89
  * 断流丢帧该记哪个 tag —— **按帧型分类**,纯函数(与写流的那条闭包解耦,才单测得动)。
@@ -117,6 +117,31 @@ export declare function ensurePgAdoptionLogSchema(q: (text: string, params?: unk
117
117
  * (PG 那条腿本来就走 hash 出来的 int4 对象键,这里只是把 MySQL 腿补齐到同一姿势。)
118
118
  */
119
119
  export declare function adoptionLockName(fromPrincipal: string): string;
120
+ /**
121
+ * 把弧锁名限定到**一个库**的键空间(A-010.13,验真后修)。
122
+ *
123
+ * 🔴 病:两条腿的锁键空间**不等价**。MySQL/TiDB 的 `GET_LOCK` 名字是**服务器实例全局**的
124
+ * (`performance_schema.metadata_locks` 里就是一个进程级命名空间,与你连的是哪个 schema 无关);
125
+ * PG 的 advisory lock 是**每数据库**的(`pg_locks` 的 database 列参与身份)。而锁名此前只 hash 了
126
+ * `fromPrincipal`、前缀写死 `sema_adoption:` —— 于是同一台 MySQL 上并存的两套部署
127
+ * (`aiagent_prod` / `aiagent_staging`,同一个源身份)会**互相抢同一把锁**:一边正在跑弧,另一边
128
+ * 取不到锁、按「在飞」回一份 `stalled` 回执 —— 一个与它自己的库状态完全对不上的答复。切到 PG 同样
129
+ * 两套部署却各跑各的。同一份代码、同一份配置,两个后端上语义不同 = 方言不等价。
130
+ *
131
+ * 🔴 姿势:限定符 = **当前数据库名**(MySQL `DATABASE()` / PG `current_database()`),与 principal
132
+ * 一起进 hash 原像。于是 MySQL 腿被收窄到与 PG 腿同一个键空间语义(每库一把),而不是把 PG 放宽到
133
+ * 实例全局(放宽的那个方向会让**同库**的两副本以为各自持锁,那是真丢互斥,方向错得多)。
134
+ *
135
+ * 🔴 为什么仍然 hash 而不是 `${db}/${name}` 直接拼:`GET_LOCK` 的名字上限是 **64 字符**,而库名没有
136
+ * 本仓能保证的上限。拼接形会让一个长库名的部署在**取锁那一步**才失败,而那时意图行已经落库 ——
137
+ * 每一次重发、每一次 boot 续跑都确定性地再撞一次同一堵墙(与 `adoptionLockName` 自己头注里那条
138
+ * codex R3-F3 是同一个病)。hash 后定长 46 字符,与库名长度无关。
139
+ * 分隔符取 **NUL**(源码里写成转义 `\u0000`,不落裸控制字节 —— 那会让 grep 把整个文件当二进制):
140
+ * 库名可以含 `/`、空格这类字符(MySQL 反引号标识符 / PG 双引号标识符都允许),拿它们当分隔符时
141
+ * `(db="a", name="b/c")` 与 `(db="a/b", name="c")` 会铸出**同一个原像** —— 弱分隔符拼接的经典撞形。
142
+ * NUL 在两种引擎的标识符里都不合法,所以它是真的不可能出现在任何一段里。
143
+ */
144
+ export declare function qualifyAdoptionLockName(database: string, name: string): string;
120
145
  /**
121
146
  * 收编日志店(单文件双方言,SqlDriver 形——checkpoint-store / approval-ask-store 同款)。
122
147
  *
@@ -173,12 +198,27 @@ export declare class SqlAdoptionLogStore implements AdoptionLogStore {
173
198
  finalizeAdopted(adoptionId: string, expectPhase: number, reportJson: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
174
199
  /** 目的地冲突 ⇒ rejected 终态(源/目的地两侧字节零变更;phase 停在 INTENT)。 */
175
200
  finalizeRejected(adoptionId: string, code: AdoptionRejectCode, detail: string, nowMs: number, exec?: SqlExec): Promise<boolean>;
201
+ /**
202
+ * 本连接所在**数据库**的名字 = 弧锁的键空间限定符(A-010.13,理由见 {@link qualifyAdoptionLockName})。
203
+ *
204
+ * 在**交出来的那条连接**上问(不向池要第二条 —— 弧全程单连接,见 `AdoptionLogStore.getById` 的注),
205
+ * 结果缓存在店上:库名在一条驱动的生命周期里不会变,而每次取锁多打一个往返是白付的。
206
+ *
207
+ * 读不出来 ⇒ **响亮抛**,不回落到未限定的旧名字:那正是本条要消灭的形,静默回落等于「修了个寂寞」
208
+ * 且只在多部署共库的那台机器上才发作(安全轴禁静默 fail-open 的同族判据)。
209
+ */
210
+ private lockKeyspace?;
211
+ private resolveLockKeyspace;
176
212
  /**
177
213
  * 收编弧的 advisory 锁(183 I1 的 form b 形:「与任何活写互斥」在 SQL 侧 = 一次只有一个副本在推这条弧)。
178
214
  *
179
215
  * **非阻塞取 + 有界轮询**(tidb-pool 的 `acquireEnsureSchemaLock` 同款判据:阻塞式 GET_LOCK 会把并发
180
216
  * 调用方全部park 在 TiDB 的悲观锁行上,既耗它的重试预算又饿死应用自己的 FOR UPDATE 路)。取不到 ⇒
181
217
  * 返回 `undefined`,调用方按「在飞」应答 —— **不假装成功,也不无限等**。
218
+ *
219
+ * 🔴 入参 `name` 是**逻辑**锁名(`adoptionLockName(fromPrincipal)`);真正发给引擎的是它被
220
+ * {@link qualifyAdoptionLockName} 限定到本库之后的形(A-010.13:两方言键空间不等价)。限定发生在
221
+ * **这里**而不是调用方,因为「键空间是每库还是每实例」是**存储层**的知识,协议层(runner)不该知道。
182
222
  */
183
223
  withLock<T>(name: string, fn: (conn: SqlTxConn) => Promise<T>, attempts?: number, sleepMs?: number): Promise<T | undefined>;
184
224
  }
@@ -92,6 +92,33 @@ function pgLockObjectKey(name) {
92
92
  export function adoptionLockName(fromPrincipal) {
93
93
  return `sema_adoption:${createHash("sha256").update(fromPrincipal).digest("hex").slice(0, 32)}`;
94
94
  }
95
+ /**
96
+ * 把弧锁名限定到**一个库**的键空间(A-010.13,验真后修)。
97
+ *
98
+ * 🔴 病:两条腿的锁键空间**不等价**。MySQL/TiDB 的 `GET_LOCK` 名字是**服务器实例全局**的
99
+ * (`performance_schema.metadata_locks` 里就是一个进程级命名空间,与你连的是哪个 schema 无关);
100
+ * PG 的 advisory lock 是**每数据库**的(`pg_locks` 的 database 列参与身份)。而锁名此前只 hash 了
101
+ * `fromPrincipal`、前缀写死 `sema_adoption:` —— 于是同一台 MySQL 上并存的两套部署
102
+ * (`aiagent_prod` / `aiagent_staging`,同一个源身份)会**互相抢同一把锁**:一边正在跑弧,另一边
103
+ * 取不到锁、按「在飞」回一份 `stalled` 回执 —— 一个与它自己的库状态完全对不上的答复。切到 PG 同样
104
+ * 两套部署却各跑各的。同一份代码、同一份配置,两个后端上语义不同 = 方言不等价。
105
+ *
106
+ * 🔴 姿势:限定符 = **当前数据库名**(MySQL `DATABASE()` / PG `current_database()`),与 principal
107
+ * 一起进 hash 原像。于是 MySQL 腿被收窄到与 PG 腿同一个键空间语义(每库一把),而不是把 PG 放宽到
108
+ * 实例全局(放宽的那个方向会让**同库**的两副本以为各自持锁,那是真丢互斥,方向错得多)。
109
+ *
110
+ * 🔴 为什么仍然 hash 而不是 `${db}/${name}` 直接拼:`GET_LOCK` 的名字上限是 **64 字符**,而库名没有
111
+ * 本仓能保证的上限。拼接形会让一个长库名的部署在**取锁那一步**才失败,而那时意图行已经落库 ——
112
+ * 每一次重发、每一次 boot 续跑都确定性地再撞一次同一堵墙(与 `adoptionLockName` 自己头注里那条
113
+ * codex R3-F3 是同一个病)。hash 后定长 46 字符,与库名长度无关。
114
+ * 分隔符取 **NUL**(源码里写成转义 `\u0000`,不落裸控制字节 —— 那会让 grep 把整个文件当二进制):
115
+ * 库名可以含 `/`、空格这类字符(MySQL 反引号标识符 / PG 双引号标识符都允许),拿它们当分隔符时
116
+ * `(db="a", name="b/c")` 与 `(db="a/b", name="c")` 会铸出**同一个原像** —— 弱分隔符拼接的经典撞形。
117
+ * NUL 在两种引擎的标识符里都不合法,所以它是真的不可能出现在任何一段里。
118
+ */
119
+ export function qualifyAdoptionLockName(database, name) {
120
+ return `sema_adoption:${createHash("sha256").update(`${database}\u0000${name}`, "utf8").digest("hex").slice(0, 32)}`;
121
+ }
95
122
  /**
96
123
  * 收编日志店(单文件双方言,SqlDriver 形——checkpoint-store / approval-ask-store 同款)。
97
124
  *
@@ -221,19 +248,59 @@ export class SqlAdoptionLogStore {
221
248
  const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = ?, reject_detail = ?, updated_at_ms = ? WHERE adoption_id = ? AND state = 'in_flight'`, `UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = $1, reject_detail = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND state = 'in_flight'`), [code, detail, nowMs, adoptionId]);
222
249
  return res.affected === 1;
223
250
  }
251
+ /**
252
+ * 本连接所在**数据库**的名字 = 弧锁的键空间限定符(A-010.13,理由见 {@link qualifyAdoptionLockName})。
253
+ *
254
+ * 在**交出来的那条连接**上问(不向池要第二条 —— 弧全程单连接,见 `AdoptionLogStore.getById` 的注),
255
+ * 结果缓存在店上:库名在一条驱动的生命周期里不会变,而每次取锁多打一个往返是白付的。
256
+ *
257
+ * 读不出来 ⇒ **响亮抛**,不回落到未限定的旧名字:那正是本条要消灭的形,静默回落等于「修了个寂寞」
258
+ * 且只在多部署共库的那台机器上才发作(安全轴禁静默 fail-open 的同族判据)。
259
+ */
260
+ lockKeyspace;
261
+ resolveLockKeyspace(conn) {
262
+ // 🔴 缓存的是**这一次尝试**,而失败的那一次必须从缓存里消失(codex 对抗复审 R1 [medium],验真后修)。
263
+ // 病:`??=` 把 promise 本身钉住,于是**一次瞬时**的 `SELECT DATABASE()` 失败(连接抖动、库短暂不可
264
+ // 达)会被永久缓存 —— 此后每一条弧都复用那个已拒的 promise、连库都不再问一次。而意图行是在取锁
265
+ // **之前**就落库的,所以首条弧永远停在 in_flight、每次 boot 续跑再撞同一堵墙,整台副本的收编面
266
+ // 直到重启为止都是死的。一次抖动升级成一次停机,方向完全反了。
267
+ // 修:只有**解析成功**的读数留在缓存里;拒绝时清掉,下一次真的重问。
268
+ const attempt = (this.lockKeyspace ??= (async () => {
269
+ const res = await conn.query(this.q("SELECT DATABASE() AS db", "SELECT current_database() AS db"));
270
+ const db = res.rows[0]?.db;
271
+ if (typeof db !== "string" || db === "") {
272
+ throw new Error("adoption: could not resolve the current database name, so the arc lock's key space cannot be qualified — refusing to take an UNQUALIFIED lock (on MySQL, GET_LOCK names are server-instance global: an unqualified name would collide with any other deployment sharing this server)");
273
+ }
274
+ return db;
275
+ })());
276
+ // 清缓存要**认准这一次**(`=== attempt`):否则一次迟到的失败会把别人刚缓存好的成功读数抹掉。
277
+ return attempt.catch((err) => {
278
+ if (this.lockKeyspace === attempt)
279
+ this.lockKeyspace = undefined;
280
+ throw err;
281
+ });
282
+ }
224
283
  /**
225
284
  * 收编弧的 advisory 锁(183 I1 的 form b 形:「与任何活写互斥」在 SQL 侧 = 一次只有一个副本在推这条弧)。
226
285
  *
227
286
  * **非阻塞取 + 有界轮询**(tidb-pool 的 `acquireEnsureSchemaLock` 同款判据:阻塞式 GET_LOCK 会把并发
228
287
  * 调用方全部park 在 TiDB 的悲观锁行上,既耗它的重试预算又饿死应用自己的 FOR UPDATE 路)。取不到 ⇒
229
288
  * 返回 `undefined`,调用方按「在飞」应答 —— **不假装成功,也不无限等**。
289
+ *
290
+ * 🔴 入参 `name` 是**逻辑**锁名(`adoptionLockName(fromPrincipal)`);真正发给引擎的是它被
291
+ * {@link qualifyAdoptionLockName} 限定到本库之后的形(A-010.13:两方言键空间不等价)。限定发生在
292
+ * **这里**而不是调用方,因为「键空间是每库还是每实例」是**存储层**的知识,协议层(runner)不该知道。
230
293
  */
231
294
  async withLock(name, fn, attempts = 20, sleepMs = 100) {
232
295
  const conn = await this.db.connect();
233
296
  let held = false;
297
+ // 🔴 在 try **之外**声明:释放腿(finally)必须用**取锁时用过的那一个**名字。第一版把它 const 在
298
+ // try 里,于是 finally 只能回头用未限定的 `name` —— 取的是 A、放的是 B,锁会一直持到连接回收。
299
+ let lockName = "";
234
300
  try {
301
+ lockName = qualifyAdoptionLockName(await this.resolveLockKeyspace(conn), name);
235
302
  for (let i = 0; i < attempts && !held; i += 1) {
236
- const res = await conn.query(this.q("SELECT GET_LOCK(?, 0) AS got", "SELECT pg_try_advisory_lock($1, $2) AS got"), this.db.dialect === "tidb" ? [name] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(name)]);
303
+ const res = await conn.query(this.q("SELECT GET_LOCK(?, 0) AS got", "SELECT pg_try_advisory_lock($1, $2) AS got"), this.db.dialect === "tidb" ? [lockName] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(lockName)]);
237
304
  const got = res.rows[0]?.got;
238
305
  held = got === 1 || got === true || got === "1";
239
306
  if (!held)
@@ -248,7 +315,7 @@ export class SqlAdoptionLogStore {
248
315
  finally {
249
316
  if (held) {
250
317
  try {
251
- await conn.query(this.q("SELECT RELEASE_LOCK(?) AS released", "SELECT pg_advisory_unlock($1, $2) AS released"), this.db.dialect === "tidb" ? [name] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(name)]);
318
+ await conn.query(this.q("SELECT RELEASE_LOCK(?) AS released", "SELECT pg_advisory_unlock($1, $2) AS released"), this.db.dialect === "tidb" ? [lockName] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(lockName)]);
252
319
  }
253
320
  catch (releaseErr) {
254
321
  // 锁在连接关闭时会自动释放,所以释放失败不该盖掉真正的结果 —— 但同样不空吞:
@@ -22,7 +22,10 @@
22
22
  * An in-memory INDEX (the same Maps MemoryRunStore holds) is hydrated on construction by scanning each runs/<taskId>/
23
23
  * run.json + the active claim files — so listRuns/listSessions/getActiveTaskId are O(in-mem) with byte-identical keyset sort to the SQL/Memory
24
24
  * twins. Every write touches BOTH the disk and the index on one path. Single-process (the FileStorageBackend boot lock
25
- * guarantees one writer per data dir), so there is no per-op lock. The checkpoint-coupled suspended-run reapers run
25
+ * guarantees one writer per data dir), so there is no per-op lock with ONE exception: #213's claim acquisition
26
+ * (`withClaimLock`) runs under a per-session in-process critical section, because judging a claim stale involves
27
+ * awaits (the checkpoint probe) and the claim + run row must commit as one unit (the SQL twin's transaction). The
28
+ * checkpoint-coupled suspended-run reapers run
26
29
  * off an injected {@link RunStoreCheckpointProbe} ([868]; wired by the local backend where checkpoint + run store
27
30
  * share one root) and degrade to honest no-ops when the probe is absent — exactly like MemoryRunStore.
28
31
  */
@@ -45,6 +48,10 @@ export declare class FileRunStore {
45
48
  /** [868] the checkpoint-table stand-in (see memory-run-store.ts {@link RunStoreCheckpointProbe} — the full
46
49
  * rationale lives on the interface); absent ⇒ the suspended-run reapers stay honest NO-OPs. */
47
50
  private checkpointProbe?;
51
+ /** #213 —— 本实例的 claim 身份(见 {@link ClaimOwner} 的选型注)。 */
52
+ private readonly claimOwner;
53
+ /** #213 —— sessionId → 该会话 claim 获取的临界区链尾(见 {@link withClaimLock})。 */
54
+ private readonly claimLocks;
48
55
  constructor(root: string);
49
56
  private runDir;
50
57
  private runJsonPath;
@@ -73,6 +80,8 @@ export declare class FileRunStore {
73
80
  ok: false;
74
81
  activeTaskId: string;
75
82
  }>;
83
+ /** #213 —— createRun 临界区的后半段:落 claim 索引 + run 行(两者一体,行写失败即回滚 claim)。 */
84
+ private commitNewRun;
76
85
  requestCancel(taskId: string, owner: string | null): Promise<boolean>;
77
86
  isCancelRequested(taskId: string, owner: string | null): Promise<boolean>;
78
87
  requestPreempt(taskId: string, owner: string | null): Promise<boolean>;
@@ -179,6 +188,81 @@ export declare class FileRunStore {
179
188
  * pending gate is excluded by the NOT-pending clause).
180
189
  */
181
190
  failSuspendedWithExpiredCheckpoint(): Promise<number>;
191
+ /**
192
+ * #213 —— 裁定/接管路径上唯一许可的取行方式:**盘优先**,盘上没有才回退内存索引。
193
+ *
194
+ * 复审 R2 [high]:反过来(索引优先)会在「同进程两只实例、两边索引都热」时出事 —— A 把 park 行
195
+ * `markResuming` 成 running 并消费掉 checkpoint,B 的索引里还留着**旧的 park 对象**;B 于是读不到
196
+ * 变化、拿自己那份陈旧对象通过复核,把 A 正在跑的行盖成 failed 再放锁。盘是唯一权威,判据只能读盘。
197
+ * 回退内存索引只为「行还没落盘」这一种形(理论上不该出现,留着不让判据凭空缺行)。
198
+ * 代价:只在 EEXIST 争用路径上多一次读文件,常路零开销。
199
+ */
200
+ private authoritativeRow;
201
+ /** #213 —— 越过内存索引直接读盘上的那一行(索引可能比另一实例的写晚一步;判据必须以盘为准)。
202
+ * 读不出/坏行 ⇒ undefined,与 hydrate 的跳过口径一致。 */
203
+ private readRowFromDisk;
204
+ /**
205
+ * A1/codex R2 —— 盘上扫该会话最新的非终局行:撕裂 claim(解析不出记录)唯一还能认主的通道。
206
+ * 内存索引不可用作判据 —— 同进程冷索引姊妹实例的行可能晚于本实例 hydrate 才落盘(L9/[1684] 同族),
207
+ * 判据必须以盘为准。O(盘上 run 数),只在「claim 解析不出记录」这条罕见路径上走,常路零开销。
208
+ * 行的读法与 hydrate/readRowFromDisk 同一套宽容口径(坏行跳过,不毒判定)。
209
+ */
210
+ private newestNonTerminalRowOnDisk;
211
+ /** 本进程要落盘的 claim 形(带持有者身份 —— #213 的活性证据)。 */
212
+ private claimRecord;
213
+ /**
214
+ * #213 —— **每会话**的 claim 获取临界区。
215
+ *
216
+ * 为什么必须有:接管的判定链里有 `await`(checkpoint probe 的两条 EXISTS 谓词),两条并发 `createRun`
217
+ * 会在同一枚陈旧 claim 上**各判各的**、然后**各接管各的** —— 第二条的 `rename` 搬走的是第一条刚写下的
218
+ * **新** claim,于是两条都拿到 ok:true,单活不变式被并发撕开。(这不是纸面推演:L7 那一格先红,就是
219
+ * 这条路径。`rename` 只保证「同一个源路径只成功一次」,它**不是**对被判定那份内容的 CAS。)
220
+ *
221
+ * 为什么进程内互斥就够:这条车道是「一个数据根一个写者」——`FileStorageBackend` 的 `root/LOCK` 让第二个
222
+ * 活进程 fail fast。跨进程并发在本车道**不存在**;而这正是接管判据本身所依赖的同一条不变式
223
+ * (`reclaimOrphanedAtBoot` 也全靠它),所以这里没有引入新的假设。`rename` 仍然留着当廉价二道闸:
224
+ * 判定与接管之间 claim 若已被别腿正常释放,它给 ENOENT,整轮重来。
225
+ *
226
+ * 形状:每 session 一条 promise 链,后来者 await 前一位;链尾归零时删表项(不无界增长)。
227
+ */
228
+ private withClaimLock;
229
+ /** #213 —— 临界区内的 claim 获取本体:铸不下就裁定持有者,判陈旧则接管后重铸。有界重试(论证见 takeOverClaim)。 */
230
+ private acquireClaim;
231
+ /**
232
+ * #213 —— 撞上 EEXIST 之后,对**现持有者**的裁定。默认方向是 `live`(挡住):只有能**证明**
233
+ * 持有者已死的形才判 `stale`。证不出来一律 fail-closed —— 单活不变式比自愈更重要。
234
+ *
235
+ * 三族:
236
+ * ① 行不在 / 行已终局 —— claim 是残骸(R9 回滚失手、跨进程窗)。`sweepClaims` 本该收掉,
237
+ * 它只在 boot 与几条 reaper 上跑;这里就地补齐。
238
+ * ② 行 `running` —— 有没有在飞的驱动腿,取决于**谁**写的这枚 claim:本进程写的 ⇒ 驱动腿就在
239
+ * 本进程内存里 ⇒ 真活主;别的进程写的 ⇒ 那个进程已死(boot 单写者锁作证)⇒ 驱动腿随它一起没了,
240
+ * 行永远不会自己走到终局。这与 `reclaimOrphanedAtBoot` 的判决**逐字同源**,只是时点不同。
241
+ * ③ 行 `suspended`/`needs_review`(park)—— park 按契约**没有在飞的腿**(腿返回 suspended 后就退出了,
242
+ * resume 腿会先 `markResuming` 翻回 running),所以这一族与「谁写的」无关,只问一件事:
243
+ * **还有东西可续吗**。有 pending ⇒ 可续(409 体里的 pendingGate 就是出路);有 expired ⇒ 归
244
+ * [868] `failSuspendedWithExpiredCheckpoint` 那条腿收(它要落 `approval.expired` 这个终局语义,
245
+ * 本函数不许抢);两者皆无 ⇒ 这条 park 谁也续不动,而**没有任何一条排期腿够得着它**
246
+ * (reapStale 只碰 running;boot 回收刻意跳过 park;时间腿 reapSuspended 挂 APPROVAL_TIMEOUT_SEC,
247
+ * 默认 0 = 不排期)⇒ 永久占用,判 stale。probe 缺席 ⇒ 证不出「无可续」⇒ fail-closed 判 live。
248
+ */
249
+ private judgeClaim;
250
+ /**
251
+ * #213 —— 接管一枚已判定陈旧的 claim。返回 false = 现场已经变了 / 抢输了,调用方整轮重来。
252
+ * **整段同步**(无 await)⇒ 进程内不可被打断;跨进程由 rename 与单写者锁兜。
253
+ *
254
+ * ① **复核先行**(复审 codex R1 [critical]):judgeClaim 的 probe await 期间,resume 腿可能已经
255
+ * `markResuming`、终局腿可能已经放锁重铸。所以第一件事是拿判定时的快照逐字比对**盘上现状**
256
+ * (claim 的 taskId+owner nonce、行的 status+updatedAt)。任何一项动了 ⇒ 判决作废,不许动手。
257
+ * ② **先把行写死,再拆 claim**(复审 codex R1 [medium]):次序反过来的话,「claim 已拆、行还 park」
258
+ * 这半拍崩溃会留下一条**永远 park** 的行 —— boot 回收刻意跳过 park、sweepClaims 又没有 claim 可循,
259
+ * 没人收得掉。现在的次序里,同一处崩溃留下的是「行已终局 + claim 还在」,而那正是 `sweepClaims`
260
+ * 每次 boot / 每条 reaper 都会扫掉的形 —— 崩在哪一步都自愈。
261
+ * ③ 拆 claim 用 `renameSync(claim → tmp/隔离名)`:POSIX `rename` 对同一个源路径只可能**成功一次**,
262
+ * 并发的第二个 racer 拿 ENOENT。隔离件落在 `tmp/` 而不是 `runs/active/` —— `hydrate()` 把 activeDir 里的
263
+ * **每个文件**都当 claim 读,隔离件留在那儿就等于把刚拆掉的幽灵在下次 boot 原样复活。
264
+ */
265
+ private takeOverClaim;
182
266
  /** Release a session's single-active claim: unlink the claim file + drop the index entry (idempotent). */
183
267
  private releaseClaim;
184
268
  /** Drop a run entirely (registry + event log + open fd + on-disk dir) — used by deleteBySession. */