@sema-agent/server 7.12.0 → 7.14.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 (74) hide show
  1. package/USAGE.md +51 -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/coordinators.js +2 -1
  14. package/dist/boot/memory-boundary.d.ts +84 -0
  15. package/dist/boot/memory-boundary.js +110 -0
  16. package/dist/boot/permission-rules-audit.js +29 -1
  17. package/dist/boot/reapers.d.ts +15 -0
  18. package/dist/boot/reapers.js +101 -44
  19. package/dist/boot/resolve-spec.js +31 -0
  20. package/dist/boot/runner-deps.d.ts +16 -2
  21. package/dist/boot/runner-deps.js +24 -4
  22. package/dist/boot/stores.js +93 -3
  23. package/dist/capabilities/memory-notice.d.ts +83 -0
  24. package/dist/capabilities/memory-notice.js +90 -0
  25. package/dist/config-types.d.ts +100 -11
  26. package/dist/config.d.ts +1 -1
  27. package/dist/config.js +111 -1
  28. package/dist/governance-ask-marks.js +2 -1
  29. package/dist/http/active-run-conflict.d.ts +33 -8
  30. package/dist/http/active-run-conflict.js +37 -2
  31. package/dist/http/routes/adoption.js +25 -2
  32. package/dist/http/routes/approvals-assistant.js +33 -3
  33. package/dist/http/routes/capabilities.js +35 -5
  34. package/dist/http/routes/images.js +18 -0
  35. package/dist/http/routes/runs.js +21 -5
  36. package/dist/http/routes/tasks.js +18 -6
  37. package/dist/http/routes/trace-usage.js +43 -14
  38. package/dist/http/server.d.ts +35 -9
  39. package/dist/http/server.js +111 -17
  40. package/dist/http/wire-types.d.ts +6 -1
  41. package/dist/main.js +46 -6
  42. package/dist/observability/fail-open.d.ts +4 -0
  43. package/dist/observability/fail-open.js +4 -0
  44. package/dist/plugins/adoption-log-sql.d.ts +40 -0
  45. package/dist/plugins/adoption-log-sql.js +69 -2
  46. package/dist/plugins/approval-ask-store-sql.d.ts +2 -1
  47. package/dist/plugins/approval-ask-store-sql.js +2 -1
  48. package/dist/plugins/file-run-store.d.ts +85 -1
  49. package/dist/plugins/file-run-store.js +450 -17
  50. package/dist/plugins/memory-embedder.d.ts +44 -0
  51. package/dist/plugins/memory-embedder.js +173 -0
  52. package/dist/plugins/permission-rule-store-sql.d.ts +45 -0
  53. package/dist/plugins/permission-rule-store-sql.js +60 -2
  54. package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
  55. package/dist/plugins/shared-memory-store-sql.js +55 -18
  56. package/dist/plugins/sql-driver.d.ts +19 -0
  57. package/dist/plugins/sql-driver.js +12 -0
  58. package/dist/plugins/store-backend.d.ts +3 -1
  59. package/dist/plugins/store-backend.js +24 -1
  60. package/dist/plugins/tidb-pool.js +11 -4
  61. package/dist/plugins/tool-result-store-sql.d.ts +35 -2
  62. package/dist/plugins/tool-result-store-sql.js +127 -11
  63. package/dist/plugins/web-search.d.ts +3 -1
  64. package/dist/plugins/web-search.js +3 -1
  65. package/dist/rules-consent.d.ts +33 -4
  66. package/dist/rules-consent.js +43 -2
  67. package/dist/run-local.js +6 -2
  68. package/dist/runtime-governance.d.ts +33 -0
  69. package/dist/runtime-governance.js +32 -0
  70. package/dist/security.js +3 -1
  71. package/dist/tool-approval.d.ts +32 -0
  72. package/dist/tool-approval.js +39 -0
  73. package/dist/trace/core-keyset-guard.d.ts +1 -1
  74. package/package.json +3 -3
@@ -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. */
@@ -22,16 +22,69 @@
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
  */
29
32
  import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
30
33
  import { join } from "node:path";
31
- import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
34
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
35
+ import { randomBytes } from "node:crypto";
32
36
  import { AppendLog, atomicWriteFile, ensureDir, readJsonlRecords, sanitizePathComponent, writeThenLink } from "@sema-agent/core";
37
+ import { recordFailOpen } from "../observability/fail-open.js";
33
38
  /** Null-safe owner equality — the JS twin of the SQL `owner <=> ?` guard (a legacy null-owner run matches owner=null). */
34
39
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
40
+ /** 进程事实(取证列),模块加载时算一次。 */
41
+ const PROCESS_FACTS = {
42
+ pid: process.pid,
43
+ startedAtMs: Date.now() - Math.round(process.uptime() * 1000),
44
+ };
45
+ /** 接管的有界重试次数:一次判定 + 一次接管后重铸 + 一次输掉竞争后的复判,3 次足够收敛(论证见 takeOverClaim)。 */
46
+ const CLAIM_TAKEOVER_ATTEMPTS = 3;
47
+ /**
48
+ * A1/codex R4 —— claim 记录的**有效形**读法(判定/接管/收口三处共用,与 hydrate 的收编守卫同一把尺)。
49
+ * 「可解析但缺键」(如 `{}` 整行)与零长/被撕是同一种损坏 —— 只认 `claim === undefined` 当撕裂会让
50
+ * 这形走可解析路径、持有者又从陈旧索引里猜(R3 修掉的遮蔽原样复发)。会话对不上号的记录同判无效
51
+ * (错位/拷贝进来的文件):认主一律回落盘上非终局行,那才是权威。
52
+ */
53
+ /** core `sanitizePathComponent`(fs-atomic SAFE_COMPONENT)同一条规则的**布尔形**(codex R5):
54
+ * 它本体对不安全分量是 throw —— 判定腿要的是「无效 ⇒ 走恢复路」,不是「无效 ⇒ 每发 createRun 500」。
55
+ * 规则只可能比 core 更保守地漂(判无效 ⇒ 回落盘上认行,方向安全)。 */
56
+ const SAFE_PATH_COMPONENT = /^[A-Za-z0-9_.-]+$/;
57
+ const isSafePathComponent = (s) => s !== "" && s !== "." && s !== ".." && SAFE_PATH_COMPONENT.test(s);
58
+ function readClaimRecord(recs, sessionId) {
59
+ const c = recs[recs.length - 1];
60
+ if (!c || typeof c.sessionId !== "string" || typeof c.taskId !== "string" || c.sessionId !== sessionId)
61
+ return undefined;
62
+ // 不安全的 taskId(`../x` 之类)当有效读会让 authoritativeRow 在 sanitize 处炸(codex R5)——同判无效。
63
+ if (!isSafePathComponent(c.taskId))
64
+ return undefined;
65
+ return c;
66
+ }
67
+ /**
68
+ * 「这个 pid 现在还在吗」—— 信号 0 探活,与 core 剪 `root/LOCK` 陈旧锁用的是同一条判据。
69
+ * 🔴 方向:只有 **ESRCH**(确凿不在)才回 false。EPERM = 进程存在但不归我管 ⇒ 回 true(在);
70
+ * 任何其它异常同样回 true —— 探测失败不许被读成「它死了」(那才是会错杀活主的方向)。
71
+ */
72
+ function isPidAlive(pid) {
73
+ if (!Number.isInteger(pid) || pid <= 0)
74
+ return true; // 读不出像样的 pid ⇒ 无证据 ⇒ 按「在」处理(fail-closed)
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ }
79
+ catch (e) {
80
+ return e.code !== "ESRCH";
81
+ }
82
+ }
83
+ /**
84
+ * park 孤儿的**静置线**(防竞态余量,不是判据本身 —— 判据是「无 checkpoint 可续」)。
85
+ * 见 judgeClaim 里 park 族那一段的红字注:它盖的是 decide 腿「解 checkpoint」与「markResuming」之间那一拍。
86
+ */
87
+ const PARK_ORPHAN_SETTLE_MS = 30_000;
35
88
  export class FileRunStore {
36
89
  runsDir;
37
90
  activeDir;
@@ -47,6 +100,10 @@ export class FileRunStore {
47
100
  /** [868] the checkpoint-table stand-in (see memory-run-store.ts {@link RunStoreCheckpointProbe} — the full
48
101
  * rationale lives on the interface); absent ⇒ the suspended-run reapers stay honest NO-OPs. */
49
102
  checkpointProbe;
103
+ /** #213 —— 本实例的 claim 身份(见 {@link ClaimOwner} 的选型注)。 */
104
+ claimOwner = { nonce: randomBytes(12).toString("hex"), ...PROCESS_FACTS };
105
+ /** #213 —— sessionId → 该会话 claim 获取的临界区链尾(见 {@link withClaimLock})。 */
106
+ claimLocks = new Map();
50
107
  constructor(root) {
51
108
  this.runsDir = join(root, "runs");
52
109
  this.activeDir = join(root, "runs", "active");
@@ -148,11 +205,67 @@ export class FileRunStore {
148
205
  catch {
149
206
  claims = [];
150
207
  }
208
+ // A1 幸存者索引(codex R1 [high]):撕裂 claim 只剩文件名可认 —— 文件名 = sanitize 后的会话名,
209
+ // 不可逆,所以对已 hydrate 的**非终局**行正向重算 sanitize 比对。撕的是文件,不是会话的占用语义:
210
+ // park 行跨重启刻意保锁、可续的 running 行 boot 回收也刻意保 —— 这些会话必须**收编回索引**而不是
211
+ // 当残骸解锁(解锁 = 新 run 与将来 resume 的旧 run 并飞,单活不变式被 boot 自己撕开)。
212
+ const nonTerminalBySanitizedSession = new Map();
213
+ for (const r of this.runs.values()) {
214
+ if (r.status !== "running" && r.status !== "suspended" && r.status !== "needs_review")
215
+ continue;
216
+ // sanitizePathComponent 对不安全分量是 THROW——坏 run.json(sessionId 非 string/带空格)在此
217
+ // 会把整个构造器炸死(boot 亡),违背文件头「坏行跳过,不毒 boot」承诺(增量重扫 [med] 拦下)。
218
+ // 同尺跳过:这种行进不了幸存者索引,其 claim 文件(若有)走无主隔离路——方向仍 fail-closed。
219
+ if (typeof r.sessionId !== "string" || !isSafePathComponent(r.sessionId))
220
+ continue;
221
+ const key = sanitizePathComponent(r.sessionId);
222
+ const prev = nonTerminalBySanitizedSession.get(key);
223
+ // 同会话多条非终局行不该存在(单活);真撞上取最新的一条,方向仍是 fail-closed(保锁)。
224
+ if (!prev || r.createdAt.getTime() > prev.createdAt.getTime())
225
+ nonTerminalBySanitizedSession.set(key, r);
226
+ }
151
227
  for (const sanitized of claims) {
152
- const recs = readJsonlRecords(join(this.activeDir, sanitized));
228
+ const path = join(this.activeDir, sanitized);
229
+ const recs = readJsonlRecords(path);
153
230
  const claim = recs[recs.length - 1];
154
- if (claim && typeof claim.sessionId === "string" && typeof claim.taskId === "string") {
231
+ // 与判定腿同一把尺(codex R5):不安全 taskId(`../x` 之类)不得进索引 —— 进了,后续任何
232
+ // authoritativeRow(taskId) 都在 sanitize 处炸(runDir throw ⇒ createRun 每发 500)。这种记录
233
+ // 与零长/缺键同判无效,落到下面的幸存者收编/隔离路。
234
+ if (claim && typeof claim.sessionId === "string" && typeof claim.taskId === "string" && isSafePathComponent(claim.taskId)) {
155
235
  this.active.set(claim.sessionId, claim.taskId);
236
+ continue;
237
+ }
238
+ const survivor = nonTerminalBySanitizedSession.get(sanitized);
239
+ if (survivor) {
240
+ // 幸存者在 ⇒ 只收编、不裁死活:死不死轮不到 hydrate 裁 —— checkpoint 面(isResumable/probe)
241
+ // 此刻还没接线,裁了必错杀可续行。收编之后一切既有腿照常工作:EEXIST ⇒ torn 裁定的
242
+ // 「索引里有」臂挡住并报真持有者;孤儿 running ⇒ reclaimOrphanedAtBoot(checkpoint 接好后
243
+ // 由 main.ts 驱动)裁;终局 ⇒ releaseClaim 连撕裂文件一并收走。文件本身不重写 —— 索引才是
244
+ // 运行期的判据面,重写一个正在被 EEXIST 依赖的文件反而引入新的失败形。
245
+ this.active.set(survivor.sessionId, survivor.taskId);
246
+ continue;
247
+ }
248
+ // A1(rescan 2026-08-11):解析不出记录(零长/被撕/缺键)**且无任何非终局行认领**的 claim 文件,
249
+ // 在 boot 这一刻可以无条件判残骸 —— 单写者锁作证没有并存的活写者(与 reclaimOrphanedAtBoot
250
+ // 同一条论证),而 writeThenLink 的原子性保证活写者留不下这种形。不收的话它进不了索引;
251
+ // sweepClaims 只扫索引,这枚文件从此无人能删,EEXIST 把该会话永远拦死(createRun 的 torn
252
+ // 裁定救得了新提交,救不了「残骸文件常驻 activeDir」本身)。隔离到 tmp(同 takeOverClaim 的
253
+ // 姿势):留证据,且绝不留在 activeDir 里等下次 hydrate 又把它当 claim 读一遍。
254
+ const quarantine = join(this.tmpDir, `torn-claim.${sanitized}.${process.pid}.${randomBytes(6).toString("hex")}`);
255
+ try {
256
+ renameSync(path, quarantine);
257
+ }
258
+ catch (e) {
259
+ if (e.code === "ENOENT")
260
+ continue; // 已被并发腿收走 —— 目的已达
261
+ // 搬不动(EPERM/EIO…)再试直删;仍失败 = 残骸文件继续拦人,按 F 类留痕(同 tag:都是
262
+ // 「陈旧 claim 的清理没做成」这一种形),下次 boot 重试。
263
+ try {
264
+ rmSync(path, { force: true });
265
+ }
266
+ catch {
267
+ recordFailOpen("server.run-store.stale-claim-quarantine-unremoved", e instanceof Error ? e.message : String(e));
268
+ }
156
269
  }
157
270
  }
158
271
  }
@@ -176,19 +289,20 @@ export class FileRunStore {
176
289
  async createRun(taskId, sessionId, owner, instanceId, meta = {}) {
177
290
  // Atomic single-active claim: writeThenLink throws EEXIST if the claim already exists (the file twin of the
178
291
  // task_active unique key). The index is the fast-path read; the file is the authority on a concurrent create.
179
- try {
180
- writeThenLink(this.activePath(sessionId), `${JSON.stringify({ sessionId, taskId })}\n`);
181
- }
182
- catch (e) {
183
- if (e.code === "EEXIST") {
184
- // Recover the current holder from the claim file (the index may lag a concurrent winner, but in this
185
- // single-process model the index is authoritative; read disk for robustness anyway).
186
- const recs = readJsonlRecords(this.activePath(sessionId));
187
- const holder = recs[recs.length - 1]?.taskId ?? this.active.get(sessionId) ?? "";
188
- return { ok: false, activeTaskId: holder };
189
- }
190
- throw e;
191
- }
292
+ // #213: an EEXIST no longer ends the story — the holder is ADJUDICATED (live vs dead-owner debris) and a
293
+ // provably-stale claim is TAKEN OVER.
294
+ // 🔴 **claim 与 run 行整段都在临界区里**(不只是取 claim 那一步)——SQL 孪生把两件事放在**一个事务**里,
295
+ // 本地车道的等价物就是这段临界区。只锁取 claim 会留下一个真窗:A 拿到 claim、行还没落盘时 B 就进来了,
296
+ // B 读到「有 claim 无行」⇒ 按残骸把 A 刚拿到的 claim 接管走 —— L7 那一格第二次红就是这条路径。
297
+ return this.withClaimLock(sessionId, async () => {
298
+ const acquired = await this.acquireClaim(sessionId, taskId);
299
+ if (!acquired.ok)
300
+ return acquired;
301
+ return this.commitNewRun(taskId, sessionId, owner, instanceId, meta);
302
+ });
303
+ }
304
+ /** #213 —— createRun 临界区的后半段:落 claim 索引 + run 行(两者一体,行写失败即回滚 claim)。 */
305
+ commitNewRun(taskId, sessionId, owner, instanceId, meta) {
192
306
  this.active.set(sessionId, taskId);
193
307
  const now = new Date();
194
308
  try {
@@ -601,6 +715,325 @@ export class FileRunStore {
601
715
  return reaped;
602
716
  }
603
717
  // ── claim / run file lifecycle helpers ──
718
+ /**
719
+ * #213 —— 裁定/接管路径上唯一许可的取行方式:**盘优先**,盘上没有才回退内存索引。
720
+ *
721
+ * 复审 R2 [high]:反过来(索引优先)会在「同进程两只实例、两边索引都热」时出事 —— A 把 park 行
722
+ * `markResuming` 成 running 并消费掉 checkpoint,B 的索引里还留着**旧的 park 对象**;B 于是读不到
723
+ * 变化、拿自己那份陈旧对象通过复核,把 A 正在跑的行盖成 failed 再放锁。盘是唯一权威,判据只能读盘。
724
+ * 回退内存索引只为「行还没落盘」这一种形(理论上不该出现,留着不让判据凭空缺行)。
725
+ * 代价:只在 EEXIST 争用路径上多一次读文件,常路零开销。
726
+ */
727
+ authoritativeRow(taskId) {
728
+ return this.readRowFromDisk(taskId) ?? this.runs.get(taskId);
729
+ }
730
+ /** #213 —— 越过内存索引直接读盘上的那一行(索引可能比另一实例的写晚一步;判据必须以盘为准)。
731
+ * 读不出/坏行 ⇒ undefined,与 hydrate 的跳过口径一致。 */
732
+ readRowFromDisk(taskId) {
733
+ const path = this.runJsonPath(taskId);
734
+ if (!existsSync(path))
735
+ return undefined;
736
+ const rows = readJsonlRecords(path);
737
+ const disk = rows[rows.length - 1];
738
+ if (!disk || typeof disk.taskId !== "string")
739
+ return undefined;
740
+ if (typeof disk.createdAt !== "string" || Number.isNaN(Date.parse(disk.createdAt)) ||
741
+ typeof disk.updatedAt !== "string" || Number.isNaN(Date.parse(disk.updatedAt)))
742
+ return undefined;
743
+ return this.toRow(disk);
744
+ }
745
+ /**
746
+ * A1/codex R2 —— 盘上扫该会话最新的非终局行:撕裂 claim(解析不出记录)唯一还能认主的通道。
747
+ * 内存索引不可用作判据 —— 同进程冷索引姊妹实例的行可能晚于本实例 hydrate 才落盘(L9/[1684] 同族),
748
+ * 判据必须以盘为准。O(盘上 run 数),只在「claim 解析不出记录」这条罕见路径上走,常路零开销。
749
+ * 行的读法与 hydrate/readRowFromDisk 同一套宽容口径(坏行跳过,不毒判定)。
750
+ */
751
+ newestNonTerminalRowOnDisk(sessionId) {
752
+ // readdir 失败**不吞**(静默降级棘轮三问):吞成「无行」会把「证不出有行」读成「可接管」——
753
+ // 在单活轴上方向反了。真 fs 故障就让 createRun 响亮地抛(takeOverClaim 对非 ENOENT 同姿势)。
754
+ const names = readdirSync(this.runsDir);
755
+ let best;
756
+ for (const name of names) {
757
+ if (name === "active")
758
+ continue;
759
+ // 与 hydrate(:283)/readRowFromDisk 同一把尺:条目不是目录 / run.json 不在 ⇒ 跳过。
760
+ // 少了这行守卫时,runs/ 里任何非目录杂物(.DS_Store/._* 备份残件)会让 readJsonlRecords 抛
761
+ // ENOTDIR(core 只吞 ENOENT)⇒ torn 裁定路 500 且 torn claim 永不清理——「会话永钉」以更响的
762
+ // 形回归(7.13.0 发车前增量重扫 [high] 拦下)。上面「readdir 失败不吞」只管 readdirSync 本体。
763
+ const path = join(this.runsDir, name, "run.json");
764
+ if (!existsSync(path))
765
+ continue;
766
+ const rows = readJsonlRecords(path);
767
+ const disk = rows[rows.length - 1];
768
+ if (!disk || typeof disk.taskId !== "string" || disk.sessionId !== sessionId)
769
+ continue;
770
+ if (typeof disk.createdAt !== "string" || Number.isNaN(Date.parse(disk.createdAt)) ||
771
+ typeof disk.updatedAt !== "string" || Number.isNaN(Date.parse(disk.updatedAt)))
772
+ continue;
773
+ if (disk.status !== "running" && disk.status !== "suspended" && disk.status !== "needs_review")
774
+ continue;
775
+ const row = this.toRow(disk);
776
+ if (!best || row.createdAt.getTime() > best.createdAt.getTime())
777
+ best = row;
778
+ }
779
+ return best;
780
+ }
781
+ /** 本进程要落盘的 claim 形(带持有者身份 —— #213 的活性证据)。 */
782
+ claimRecord(sessionId, taskId) {
783
+ return { sessionId, taskId, owner: { ...this.claimOwner } };
784
+ }
785
+ /**
786
+ * #213 —— **每会话**的 claim 获取临界区。
787
+ *
788
+ * 为什么必须有:接管的判定链里有 `await`(checkpoint probe 的两条 EXISTS 谓词),两条并发 `createRun`
789
+ * 会在同一枚陈旧 claim 上**各判各的**、然后**各接管各的** —— 第二条的 `rename` 搬走的是第一条刚写下的
790
+ * **新** claim,于是两条都拿到 ok:true,单活不变式被并发撕开。(这不是纸面推演:L7 那一格先红,就是
791
+ * 这条路径。`rename` 只保证「同一个源路径只成功一次」,它**不是**对被判定那份内容的 CAS。)
792
+ *
793
+ * 为什么进程内互斥就够:这条车道是「一个数据根一个写者」——`FileStorageBackend` 的 `root/LOCK` 让第二个
794
+ * 活进程 fail fast。跨进程并发在本车道**不存在**;而这正是接管判据本身所依赖的同一条不变式
795
+ * (`reclaimOrphanedAtBoot` 也全靠它),所以这里没有引入新的假设。`rename` 仍然留着当廉价二道闸:
796
+ * 判定与接管之间 claim 若已被别腿正常释放,它给 ENOENT,整轮重来。
797
+ *
798
+ * 形状:每 session 一条 promise 链,后来者 await 前一位;链尾归零时删表项(不无界增长)。
799
+ */
800
+ async withClaimLock(sessionId, fn) {
801
+ const prev = this.claimLocks.get(sessionId);
802
+ let release = () => undefined;
803
+ const mine = new Promise((res) => {
804
+ release = res;
805
+ });
806
+ this.claimLocks.set(sessionId, mine);
807
+ if (prev)
808
+ await prev; // 前一位失败也不会卡住后一位:mine 在 finally 里无条件 release
809
+ try {
810
+ return await fn();
811
+ }
812
+ finally {
813
+ release();
814
+ if (this.claimLocks.get(sessionId) === mine)
815
+ this.claimLocks.delete(sessionId);
816
+ }
817
+ }
818
+ /** #213 —— 临界区内的 claim 获取本体:铸不下就裁定持有者,判陈旧则接管后重铸。有界重试(论证见 takeOverClaim)。 */
819
+ async acquireClaim(sessionId, taskId) {
820
+ for (let attempt = 0; attempt < CLAIM_TAKEOVER_ATTEMPTS; attempt += 1) {
821
+ try {
822
+ writeThenLink(this.activePath(sessionId), `${JSON.stringify(this.claimRecord(sessionId, taskId))}\n`);
823
+ return { ok: true };
824
+ }
825
+ catch (e) {
826
+ if (e.code !== "EEXIST")
827
+ throw e;
828
+ // Recover the current holder from the claim file (the index may lag a concurrent winner, but in this
829
+ // single-process model the index is authoritative; read disk for robustness anyway).
830
+ // 有效形读法(readClaimRecord):缺键/错会话的记录读成 undefined ⇒ 走撕裂裁定的扫盘认主。
831
+ const claim = readClaimRecord(readJsonlRecords(this.activePath(sessionId)), sessionId);
832
+ const holder = claim?.taskId ?? this.active.get(sessionId) ?? "";
833
+ const verdict = await this.judgeClaim(sessionId, claim, holder);
834
+ if (verdict.kind === "live")
835
+ return { ok: false, activeTaskId: verdict.holderTaskId ?? holder };
836
+ this.takeOverClaim(sessionId, verdict.reason, verdict.snapshot); // 复核不过/搬不动也重来一轮
837
+ }
838
+ }
839
+ // 预算用尽的收口(复审 R2 [medium]):最后一轮里**接管刚刚成功**时,循环已经没有回合去铸新 claim 了。
840
+ // 此时直接回 409 是**撒谎** —— 会话其实是空的,且 activeTaskId 只能给出空串,调用方连 attach 到谁都不知道。
841
+ // 所以先诚实地再铸一次;只有真的还被人占着(EEXIST)才报冲突,而且报的是**盘上真持有者**。
842
+ try {
843
+ writeThenLink(this.activePath(sessionId), `${JSON.stringify(this.claimRecord(sessionId, taskId))}\n`);
844
+ return { ok: true };
845
+ }
846
+ catch (e) {
847
+ if (e.code !== "EEXIST")
848
+ throw e;
849
+ }
850
+ const finalClaim = readClaimRecord(readJsonlRecords(this.activePath(sessionId)), sessionId);
851
+ return { ok: false, activeTaskId: finalClaim?.taskId ?? this.active.get(sessionId) ?? "" };
852
+ }
853
+ /**
854
+ * #213 —— 撞上 EEXIST 之后,对**现持有者**的裁定。默认方向是 `live`(挡住):只有能**证明**
855
+ * 持有者已死的形才判 `stale`。证不出来一律 fail-closed —— 单活不变式比自愈更重要。
856
+ *
857
+ * 三族:
858
+ * ① 行不在 / 行已终局 —— claim 是残骸(R9 回滚失手、跨进程窗)。`sweepClaims` 本该收掉,
859
+ * 它只在 boot 与几条 reaper 上跑;这里就地补齐。
860
+ * ② 行 `running` —— 有没有在飞的驱动腿,取决于**谁**写的这枚 claim:本进程写的 ⇒ 驱动腿就在
861
+ * 本进程内存里 ⇒ 真活主;别的进程写的 ⇒ 那个进程已死(boot 单写者锁作证)⇒ 驱动腿随它一起没了,
862
+ * 行永远不会自己走到终局。这与 `reclaimOrphanedAtBoot` 的判决**逐字同源**,只是时点不同。
863
+ * ③ 行 `suspended`/`needs_review`(park)—— park 按契约**没有在飞的腿**(腿返回 suspended 后就退出了,
864
+ * resume 腿会先 `markResuming` 翻回 running),所以这一族与「谁写的」无关,只问一件事:
865
+ * **还有东西可续吗**。有 pending ⇒ 可续(409 体里的 pendingGate 就是出路);有 expired ⇒ 归
866
+ * [868] `failSuspendedWithExpiredCheckpoint` 那条腿收(它要落 `approval.expired` 这个终局语义,
867
+ * 本函数不许抢);两者皆无 ⇒ 这条 park 谁也续不动,而**没有任何一条排期腿够得着它**
868
+ * (reapStale 只碰 running;boot 回收刻意跳过 park;时间腿 reapSuspended 挂 APPROVAL_TIMEOUT_SEC,
869
+ * 默认 0 = 不排期)⇒ 永久占用,判 stale。probe 缺席 ⇒ 证不出「无可续」⇒ fail-closed 判 live。
870
+ */
871
+ async judgeClaim(sessionId, claim, holderTaskId) {
872
+ const owner = claim?.owner;
873
+ const who = owner
874
+ ? `pid ${owner.pid} started ${new Date(owner.startedAtMs).toISOString()}`
875
+ : "an owner-less claim (written before the liveness stamp existed)";
876
+ const torn = claim === undefined;
877
+ // 🔴 索引里没有 ≠ 行不存在:另一个实例在本实例 hydrate **之后**建的 run,只活在盘上。拿索引当判据
878
+ // 会把它读成「幽灵 claim」并抢走一个**真活主**的锁([1684] 的两实例并发格就是这条路)。以盘为准。
879
+ // 撕裂形跳过这次急切取行(holder 是索引猜的,可能陈旧甚至不安全)——撕裂臂里无条件扫盘认主。
880
+ let row = !torn && holderTaskId ? this.authoritativeRow(holderTaskId) : undefined;
881
+ let holder = holderTaskId;
882
+ // ── A1(rescan 2026-08-11 + codex R2 两条 [high]):claim 文件在(EEXIST 撞上了它)却解析不出任何
883
+ // 记录 = 「被撕的写」(readJsonlRecords 契约:零长=[],无尾换行的末行被丢)。撕的是**文件**,不是
884
+ // 会话的占用语义 —— 修前这形恒 fail-closed live 且 activeTaskId 为空、任何收割腿都够不着,会话被一枚
885
+ // 谁都删不掉的空文件永久钉死。裁定:唯一还能认主的通道是**行**,而行必须**以盘为准**(同进程冷索引
886
+ // 姊妹实例的行可能不在本实例内存里 —— L9/[1684] 冷索引形 × 外力截断的组合,codex R2 第二条)。
887
+ // · 认出非终局行 ⇒ 把持有者当它,走下面与可解析 claim **同一套**裁定(park 族问可续性、running 族要
888
+ // 死亡证据;文件不可读 ⇒ 无 owner 证据 ⇒ running 族自然 fail-closed)。blanket-live 是反例:它会把
889
+ // 「无可续的老 park」这一 L2 形永久锁死,而它的可解析孪生早就该被接管(codex R2 第一条)。
890
+ // · 认不出行:本实例索引在飞(claim 已铸、行未落的半拍)⇒ live;索引也没有 ⇒ 单写者不变式作证
891
+ // (活写者只能是本进程,而本进程持有中的 claim 必在索引里)⇒ 死进程残骸,判 stale。
892
+ if (torn) {
893
+ // codex R3 [high]:认主**无条件**以盘上最新非终局行为准 —— 索引猜出来的 holder 可能是姊妹实例
894
+ // 缓存的**旧**持有者(行已终局):拿它当行会遮住接班的 park(409 报一个终局名字,替班行照 live
895
+ // 挡住,永锁)。索引只在「盘上无非终局行」时兜「claim 已铸、行未落」的本进程半拍。
896
+ row = this.newestNonTerminalRowOnDisk(sessionId);
897
+ if (row) {
898
+ holder = row.taskId; // 409 也要报得出真持有者(修前这形只能报空串)
899
+ }
900
+ else if (this.active.has(sessionId)) {
901
+ return { kind: "live", holderTaskId: this.active.get(sessionId) };
902
+ }
903
+ else {
904
+ return {
905
+ kind: "stale",
906
+ reason: "torn claim file (no parseable record — dead-writer debris) reclaimed",
907
+ snapshot: { taskId: "", ownerNonce: undefined, status: undefined, updatedAtMs: undefined, torn: true },
908
+ };
909
+ }
910
+ }
911
+ // 判定链里的每个 await 都会让出事件循环 —— 复审(codex R1 [critical])的靶心:probe 期间 resume 腿
912
+ // 可能已经 `markResuming`(park→running)、终局腿可能已经放锁重铸。所以这里先取一份快照,接管前
913
+ // 必须**逐字复核**(见 takeOverClaim);任何一项动了就放手重来。同一条纪律在 reapSuspended 里
914
+ // 已经有先例(那处的 `snap` 复核),本函数照抄并把 **claim 身份**也一并纳入。
915
+ const snapshot = {
916
+ taskId: holder,
917
+ ownerNonce: owner?.nonce,
918
+ status: row?.status,
919
+ updatedAtMs: row?.updatedAt.getTime(),
920
+ ...(torn ? { torn: true } : {}),
921
+ };
922
+ /** live 判决统一带上真持有者(撕裂形的 holder 是从行认出来的,调用方的 claim 读数里没有它)。 */
923
+ const live = () => ({ kind: "live", ...(holder ? { holderTaskId: holder } : {}) });
924
+ // ── ③ park 族:与「谁写的」无关,只问「还有东西可续吗」 ──
925
+ if (row && (row.status === "suspended" || row.status === "needs_review")) {
926
+ const probe = this.checkpointProbe;
927
+ if (!probe)
928
+ return live(); // 无 checkpoint 面 ⇒ 证不出不可续 ⇒ 挡住
929
+ if (await probe.hasPending(row.sessionId))
930
+ return live(); // 可续的 park —— 用户的待决审批,绝不许抢
931
+ if (await probe.hasExpired(row.sessionId))
932
+ return live(); // expired 有专腿收(approval.expired),不越权
933
+ // 🔴 复审 R1 [critical] 的残余窗:decide 腿「解掉 checkpoint」与「markResuming」之间,行还是 park、
934
+ // checkpoint 已不 pending —— 快照复核看不出区别。用一道**静置线**盖住它:只收「已经躺了
935
+ // PARK_ORPHAN_SETTLE_MS 没人动过」的 park。这不是把 TTL 当修法(修法是「无 checkpoint 可续」
936
+ // 这条新谓词 —— 今天没有任何一条腿有它);静置线只是防竞态的余量,而它要清的占用本来是**无界**的,
937
+ // 多等 30 秒零代价。真要跨过它,得有一条 resume 腿在解 checkpoint 与 markResuming 之间卡满 30 秒。
938
+ if (Date.now() - row.updatedAt.getTime() < PARK_ORPHAN_SETTLE_MS)
939
+ return live();
940
+ return { kind: "stale", reason: `parked run (${row.status}) has no resumable checkpoint — claim reclaimed`, snapshot };
941
+ }
942
+ // ── ①② 非 park 族:判死之前必须有**正面死亡证据** ──
943
+ // 证据只有两种:(a) 这枚 claim 是**本实例**写的 —— 那我对它的驱动腿有完整的内存知识;
944
+ // (b) 它是外来的,且 owner 的 pid **确凿不在了**(ESRCH)。
945
+ // 为什么不能只看 nonce:`root/LOCK` 保证的是「一个数据根一个活**进程**」,同一进程里再造第二只
946
+ // FileRunStore(测试/误配)并不违反它,而那第二只的 claim 背后是一个**活着的**驱动腿 —— 只看 nonce
947
+ // 就会把真活主抢走([1684] 两实例并发格 + 「claim 已落、行还没落」那半拍窗,两次都真红过)。
948
+ // 🔴 pid 复用在这里**不会造成误判**:方向是 fail-closed —— 只有「确凿不在」才判陈旧;pid 被复用 ⇒
949
+ // 探到「在」⇒ 判 live ⇒ 维持今天的 409(少愈一次,绝不错杀),boot 回收仍是兜底。EPERM(属于别的
950
+ // 用户)同样表示进程存在,按 live 处理。缺 owner 戳的老 claim 无证可查,同样 live —— 升级本身就是
951
+ // 一次重启,`reclaimOrphanedAtBoot`(它靠单写者锁,不需要逐 claim 的死亡证据)才是它的正经收口。
952
+ const mine = owner?.nonce === this.claimOwner.nonce;
953
+ const ownerGone = owner !== undefined && !isPidAlive(owner.pid);
954
+ if (!mine && !ownerGone)
955
+ return live(); // 撕裂形(owner 无从读起)也天然落在这臂:running 无死亡证据 ⇒ 挡住
956
+ if (!row)
957
+ return { kind: "stale", reason: `claim reclaimed: no run row for ${holder || "(unknown task)"} — ${who}`, snapshot };
958
+ if (row.status !== "running")
959
+ return { kind: "stale", reason: `claim reclaimed: holder already terminal (${row.status})`, snapshot };
960
+ // running:本实例写的 ⇒ 驱动腿就在本进程内存里 ⇒ 真活主(单活不变式在此守住)。
961
+ if (mine)
962
+ return live();
963
+ // 🔴 复审 R1 [high]:外来且 owner 已死 **仍不够** —— `reclaimOrphanedAtBoot` 刻意用
964
+ // `isResumable(sessionId)`(= 该会话有 pending checkpoint)把一类 running 行留下来:
965
+ // `markResuming` 会把 park 翻成 running,而它的 checkpoint 还没被消费,resume 挑选器会重新驱动它。
966
+ // 本函数是同一条判决的「晚一点的时点」,判据必须**逐字同源**,否则新提交会把一条 boot 特意保住的
967
+ // 行打成 failed、把用户还没决议的审批晾在那儿。
968
+ const probe = this.checkpointProbe;
969
+ if (probe && (await probe.hasPending(row.sessionId)))
970
+ return live();
971
+ // 外来 + owner 已死 + 无可续 ⇒ 驱动腿随那个进程没了,这行永远走不到终局 ⇒ 孤儿。
972
+ return { kind: "stale", reason: `run orphaned by engine restart (claim held by ${who}, which is gone)`, snapshot };
973
+ }
974
+ /**
975
+ * #213 —— 接管一枚已判定陈旧的 claim。返回 false = 现场已经变了 / 抢输了,调用方整轮重来。
976
+ * **整段同步**(无 await)⇒ 进程内不可被打断;跨进程由 rename 与单写者锁兜。
977
+ *
978
+ * ① **复核先行**(复审 codex R1 [critical]):judgeClaim 的 probe await 期间,resume 腿可能已经
979
+ * `markResuming`、终局腿可能已经放锁重铸。所以第一件事是拿判定时的快照逐字比对**盘上现状**
980
+ * (claim 的 taskId+owner nonce、行的 status+updatedAt)。任何一项动了 ⇒ 判决作废,不许动手。
981
+ * ② **先把行写死,再拆 claim**(复审 codex R1 [medium]):次序反过来的话,「claim 已拆、行还 park」
982
+ * 这半拍崩溃会留下一条**永远 park** 的行 —— boot 回收刻意跳过 park、sweepClaims 又没有 claim 可循,
983
+ * 没人收得掉。现在的次序里,同一处崩溃留下的是「行已终局 + claim 还在」,而那正是 `sweepClaims`
984
+ * 每次 boot / 每条 reaper 都会扫掉的形 —— 崩在哪一步都自愈。
985
+ * ③ 拆 claim 用 `renameSync(claim → tmp/隔离名)`:POSIX `rename` 对同一个源路径只可能**成功一次**,
986
+ * 并发的第二个 racer 拿 ENOENT。隔离件落在 `tmp/` 而不是 `runs/active/` —— `hydrate()` 把 activeDir 里的
987
+ * **每个文件**都当 claim 读,隔离件留在那儿就等于把刚拆掉的幽灵在下次 boot 原样复活。
988
+ */
989
+ takeOverClaim(sessionId, reason, snapshot) {
990
+ // ① 复核:盘上的 claim 还是我判过的那一枚吗?(同一把有效形尺:缺键/错会话读成 undefined)
991
+ const nowClaim = readClaimRecord(readJsonlRecords(this.activePath(sessionId)), sessionId);
992
+ if (snapshot.torn) {
993
+ // A1:被撕的形没有可比的字段 —— 复核=「现在仍然解析不出记录」。解析得出 ⇒ 判定的 await
994
+ // 间隙里这枚 claim 被正常释放又被活主重铸了,判决作废(下一轮对新 claim 重新裁定)。
995
+ if (nowClaim !== undefined)
996
+ return false;
997
+ }
998
+ else if (!nowClaim || nowClaim.taskId !== snapshot.taskId || nowClaim.owner?.nonce !== snapshot.ownerNonce)
999
+ return false;
1000
+ // 行的键统一取自快照(裁定认出的持有者)——撕裂 claim 的调用方读数里没有 taskId,快照才有(codex R2)。
1001
+ const row = snapshot.taskId ? this.authoritativeRow(snapshot.taskId) : undefined;
1002
+ if (row?.status !== snapshot.status || row?.updatedAt.getTime() !== snapshot.updatedAtMs)
1003
+ return false;
1004
+ // ② 先落终局行:留一条永远 running/park 的行会让 listRuns/fleet 显示一个永不结束的假活 run。
1005
+ // errorCode 刻意留空 —— 与 reclaimOrphanedAtBoot 同族(它也只写 error),不新铸 wire 错误码。
1006
+ // 🔴 **副本写,不原地改**:本文件其它写路径是「改 row 再 persist」,那在 persist 抛出时会留下
1007
+ // 「内存已 failed、盘上还 park」的分叉。接管这条路径必须是全有或全无(下一步就要拆 claim),
1008
+ // 所以交给 persist 去 `runs.set` —— 写盘失败就什么都没发生,claim 原封不动,下一轮重来。
1009
+ if (row && (row.status === "running" || row.status === "suspended" || row.status === "needs_review")) {
1010
+ this.persist({ ...row, status: "failed", error: reason, updatedAt: new Date() });
1011
+ }
1012
+ // ③ 再拆 claim。
1013
+ const quarantine = join(this.tmpDir, `stale-claim.${sanitizePathComponent(sessionId)}.${process.pid}.${randomBytes(6).toString("hex")}`);
1014
+ try {
1015
+ renameSync(this.activePath(sessionId), quarantine);
1016
+ }
1017
+ catch (e) {
1018
+ // ENOENT = 输掉了这一枚 claim 的接管权(或它已被正常释放);行已终局,sweepClaims 会收尾。
1019
+ // 其它错误(EPERM/EIO…)不是「输掉竞争」而是真实 fs 故障——吞成 false 会把故障演成永远的
1020
+ // 「接管失败→重试→再失败」静默循环,rethrow 让它像其它写路径一样响亮。
1021
+ if (e.code === "ENOENT")
1022
+ return false;
1023
+ throw e;
1024
+ }
1025
+ try {
1026
+ rmSync(quarantine, { force: true });
1027
+ }
1028
+ catch (e) {
1029
+ // F 类兜底:隔离件是死物,且不在 activeDir 下,留着不会被 hydrate 当成 claim 复活;
1030
+ // 但删不掉仍要留痕(计数+一次性 warn),否则 tmp 静默膨胀没人知道。
1031
+ recordFailOpen("server.run-store.stale-claim-quarantine-unremoved", e instanceof Error ? e.message : String(e));
1032
+ }
1033
+ if (this.active.get(sessionId) === snapshot.taskId)
1034
+ this.active.delete(sessionId);
1035
+ return true;
1036
+ }
604
1037
  /** Release a session's single-active claim: unlink the claim file + drop the index entry (idempotent). */
605
1038
  releaseClaim(sessionId) {
606
1039
  this.active.delete(sessionId);