@akagilnc/pi-workflow-roles 0.1.2497 → 0.1.2510

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.
package/README.md CHANGED
@@ -47,7 +47,7 @@ ak-role config set-auto-resume-limit 3
47
47
 
48
48
  `config set` stores the seat model default. For Gate officers (`gatekeeper` / `inspector` / `notary`) resolution is officer pin → province (`gatekeeper`) pin → inherit parent session; an explicit selection that fails is loud and does not fall back. `config unset` clears only those officer overrides. `config set-engine` / `unset-engine` store or clear the persistent labor-engine name on callable roles (same seats as `--engine`; navigator refused — no independent activation). `config set-auto-resume-limit` stores the single-call auto-resume ceiling. Usage and refusal text are owned by `ak-role config` / `ak-role help config`.
49
49
 
50
- Receipts are typed, so callers compose roles without parsing prose; ordering and stopping stay caller-owned. Programmatic consumers derive contracts from the exported schemas in `src/package-contracts/`, not from this guide.
50
+ Receipts are typed, so callers compose roles without parsing prose; ordering and stopping stay caller-owned. Province-grade (省部级) seats may dispatch proper role calls within their own single invocation, but the public CLI semantics are unchanged — an external caller still launches one chosen role per invocation and retains ordering, repetition, and stopping across CLI calls (ADR 0010 two-grade amendment). Programmatic consumers derive contracts from the exported schemas in `src/package-contracts/`, not from this guide.
51
51
 
52
52
  ### Gate submission gate
53
53
 
package/README.zh-CN.md CHANGED
@@ -47,7 +47,7 @@ ak-role config set-auto-resume-limit 3
47
47
 
48
48
  `config set` 存席位模型默认。门下省官席(`gatekeeper`/`inspector`/`notary`)解析顺序:官自钉 → 省钉(`gatekeeper`)→ 继承父 session;显式指定失败响亮、不回退。`config unset` 只清这三官的覆盖。`config set-engine`/`unset-engine` 在可调用角色上写入或清除持久劳务引擎名(与 `--engine` 同轴;拒收 navigator——无独立 activation)。`config set-auto-resume-limit` 写入单次调用自动续跑上限。用法与拒绝文案以 `ak-role config`/`ak-role help config` 为准。
49
49
 
50
- 回执是 typed 的,调用者不必解析散文即可组合角色;顺序与停止归调用者。编程消费者从 `src/package-contracts/` 导出推导契约,不从本文。
50
+ 回执是 typed 的,调用者不必解析散文即可组合角色;顺序与停止归调用者。省部级(province-grade)角色可在自己单次调用的内政之内派发正经角色调用,但公开 CLI 语义零变化——外部调用者仍一次启动其选中的一个角色,跨 CLI 调用的顺序、重复与停止仍全归外部调用者(ADR 0010 两品级窄修)。编程消费者从 `src/package-contracts/` 导出推导契约,不从本文。
51
51
 
52
52
  ### 门下省交卷闸
53
53
 
@@ -133,13 +133,14 @@ ak-role resume <runId> "<裁定>"
133
133
  | analyst | **太史** | 司天台分析席:只读司天记录、出高阶指标;确定性机制,非 LLM,可单独调用 | 已建([ADR 0068](docs/adr/0068-taishi-analysis-seat-reads-records-writes-sibling-home.md);机器面键 `analyst`,[#445](https://github.com/Akagilnc/ak-pi-workflow-roles/issues/445) 拼音清零) |
134
134
  | — | **司天台** | 记候簿——只打点、只指针,不分析不执法 | **一期不是角色**([ADR 0047](docs/adr/0047-sitian-phase-one-mechanism-not-role.md):零 LLM 双面对账);分析席已由太史承担;机器面键 `archivist`([#445](https://github.com/Akagilnc/ak-pi-workflow-roles/issues/445)) |
135
135
  | gleaner-left | **左拾遗** | 合并前以无锚定冷眼审全幅合并候选,只上弹章、不封驳不裁决(风闻) | soul 已落+[ADR 0067](docs/adr/0067-menxia-province-founding-jishizhong-fubaolang.md) 修正案;机器席位待建 |
136
+ | marshal | **尚书省** | 审→判→修 质量收敛环的省部级驱动角色:调用方递票号与 baseline,尚书省驱动御史台/大理寺/修内司滚到收敛(converged 唯庭可判)或 escalate 上呈,交回 typed 报告;不弹、不判、不修,只让链条转到收敛 | 已定名(#145);席位待落地(#146) |
136
137
  | — | **兰台** | 读档议制——耗时/缺口/冗余三条,上奏不执法 | 未建 |
137
138
  | — | **考功司** | 考具体效率——角色与档位的升档率、一次通过率、每票成本 | 留档,需要时另立票 |
138
139
  | — | **主簿** | 合并后勾稽销案:核实确已合上、清理残留、报到达 | 未建 |
139
140
 
140
141
  **merge 按钮归调用者**,没有任何角色握不可逆权限:通进司把收证这件苦活做完并报收集终态,人(或 AI)自己判断、自己点,点完想调主簿就调、不调也可以。
141
142
 
142
- 上表**不规定调用顺序**——组合、顺序、重复次数归调用者([ADR 0010](docs/adr/0010-callers-own-role-composition-and-repetition.md))。御史台/大理寺/审刑院是**职责分立的类比,不是必经链**;审刑院也并非只跟在大理寺之后,大理寺、御史台、太医署各自都有一次。门下省交卷闸是完成侧挂钩,不是调用者必经编排链。
143
+ 上表**不规定调用顺序**——组合、顺序、重复次数归调用者([ADR 0010](docs/adr/0010-callers-own-role-composition-and-repetition.md))。御史台/大理寺/审刑院是**职责分立的类比,不是必经链**;审刑院也并非只跟在大理寺之后,大理寺、御史台、太医署各自都有一次。门下省交卷闸是完成侧挂钩,不是调用者必经编排链。省部级角色的内部组合属于其单次调用的内政,公开 CLI 语义零变化——外部调用者仍一次启动其选中的一个角色,跨 CLI 调用的顺序、重复与停止仍全归外部调用者。
143
144
 
144
145
  `拾遗补阙` 成对留档,待将来出现第二个进言席再启用。
145
146
 
@@ -16031,55 +16031,128 @@ function describeErrorIdentity(error) {
16031
16031
  const message = typeof candidate?.message === "string" && candidate.message !== "" ? `: ${candidate.message}` : "";
16032
16032
  return `${name}${code}${message}`;
16033
16033
  }
16034
+ function errorCodeOf(error) {
16035
+ return error.code;
16036
+ }
16037
+ function isProcessAlive(pid) {
16038
+ try {
16039
+ process.kill(pid, 0);
16040
+ return true;
16041
+ } catch (error) {
16042
+ return errorCodeOf(error) !== "ESRCH";
16043
+ }
16044
+ }
16045
+ async function autopsyWriterLock(lockPath) {
16046
+ let content;
16047
+ try {
16048
+ content = await readFile5(lockPath, "utf8");
16049
+ } catch (error) {
16050
+ if (errorCodeOf(error) === "ENOENT") return { verdict: "absent" };
16051
+ return { verdict: "absent", readFailure: error };
16052
+ }
16053
+ const pid = Number.parseInt(content.trim(), 10);
16054
+ if (!Number.isInteger(pid) || pid <= 0) return { verdict: "absent" };
16055
+ return isProcessAlive(pid) ? { verdict: "alive", pid } : { verdict: "dead", pid };
16056
+ }
16057
+ function describeAutopsy(autopsy) {
16058
+ switch (autopsy.verdict) {
16059
+ case "alive":
16060
+ return `live pid ${autopsy.pid}`;
16061
+ case "dead":
16062
+ return `dead pid ${autopsy.pid}`;
16063
+ case "absent":
16064
+ return autopsy.readFailure !== void 0 ? "unreadable lock" : "absent or unparseable holder";
16065
+ }
16066
+ }
16067
+ async function reclaimStaleWriterLock(lockPath, runDirectory) {
16068
+ const current = await autopsyWriterLock(lockPath);
16069
+ if (current.verdict !== "dead") return;
16070
+ try {
16071
+ await unlink2(lockPath);
16072
+ } catch (error) {
16073
+ if (errorCodeOf(error) === "ENOENT") return;
16074
+ if (errorCodeOf(error) !== "EACCES") throw error;
16075
+ await chmod(runDirectory, 493);
16076
+ await unlink2(lockPath);
16077
+ }
16078
+ }
16079
+ async function createWriterLease(lockPath, runDirectory, reportCleanupFailure) {
16080
+ const handle = await open(lockPath, "wx");
16081
+ try {
16082
+ await handle.writeFile(`${process.pid}
16083
+ `, "utf8");
16084
+ } catch (error) {
16085
+ await handle.close().catch(() => void 0);
16086
+ await unlink2(lockPath).catch(() => void 0);
16087
+ throw error;
16088
+ }
16089
+ let released = false;
16090
+ return {
16091
+ lockPath,
16092
+ async release() {
16093
+ if (released) return;
16094
+ released = true;
16095
+ await handle.close().catch(() => void 0);
16096
+ try {
16097
+ await unlink2(lockPath);
16098
+ } catch (error) {
16099
+ if (errorCodeOf(error) === "EACCES") {
16100
+ try {
16101
+ await chmod(runDirectory, 493);
16102
+ await unlink2(lockPath);
16103
+ } catch (retryError) {
16104
+ reportCleanupFailure(retryError);
16105
+ }
16106
+ } else {
16107
+ reportCleanupFailure(error);
16108
+ }
16109
+ }
16110
+ }
16111
+ };
16112
+ }
16034
16113
  async function acquireRunWriterLease(runDirectory, onCleanupFailure) {
16035
16114
  const reportCleanupFailure = (error) => {
16036
16115
  try {
16037
16116
  onCleanupFailure?.(
16038
- `writer lease lock cleanup failed (best-effort continue; stale lock resurfaces as lease-held on next acquire) at ${join7(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`
16117
+ `writer lease lock cleanup failed (best-effort continue; stale lock is reclaimed by the next acquire's holder autopsy) at ${join7(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`
16039
16118
  );
16040
16119
  } catch {
16041
16120
  }
16042
16121
  };
16043
16122
  const lockPath = join7(runDirectory, WRITER_LOCK_FILE);
16044
- try {
16045
- const handle = await open(lockPath, "wx");
16123
+ let lastAutopsy = { verdict: "absent" };
16124
+ for (let reclaimsLeft = WRITER_LEASE_RECLAIM_ROUNDS; ; reclaimsLeft -= 1) {
16046
16125
  try {
16047
- await handle.writeFile(`${process.pid}
16048
- `, "utf8");
16126
+ return await createWriterLease(lockPath, runDirectory, reportCleanupFailure);
16049
16127
  } catch (error) {
16050
- await handle.close().catch(() => void 0);
16051
- await unlink2(lockPath).catch(() => void 0);
16052
- throw error;
16128
+ if (errorCodeOf(error) !== "EEXIST") throw error;
16053
16129
  }
16054
- let released = false;
16055
- return {
16056
- lockPath,
16057
- async release() {
16058
- if (released) return;
16059
- released = true;
16060
- await handle.close().catch(() => void 0);
16061
- try {
16062
- await unlink2(lockPath);
16063
- } catch (error) {
16064
- if (error.code === "EACCES") {
16065
- try {
16066
- await chmod(runDirectory, 493);
16067
- await unlink2(lockPath);
16068
- } catch (retryError) {
16069
- reportCleanupFailure(retryError);
16070
- }
16071
- } else {
16072
- reportCleanupFailure(error);
16073
- }
16074
- }
16075
- }
16076
- };
16077
- } catch (error) {
16078
- if (error instanceof Error && "code" in error && error.code === "EEXIST") {
16079
- throw new RunWriterLeaseHeldError();
16130
+ lastAutopsy = await autopsyWriterLock(lockPath);
16131
+ if (lastAutopsy.verdict === "absent" && lastAutopsy.readFailure !== void 0) {
16132
+ reportCleanupFailure(lastAutopsy.readFailure);
16133
+ }
16134
+ if (lastAutopsy.verdict === "alive") {
16135
+ throw new RunWriterLeaseHeldError(
16136
+ `role run writer lease is already held by live pid ${lastAutopsy.pid} at ${lockPath}`
16137
+ );
16138
+ }
16139
+ if (lastAutopsy.verdict === "absent") {
16140
+ throw new RunWriterLeaseHeldError(
16141
+ lastAutopsy.readFailure !== void 0 ? `role run writer lease lock is unreadable at ${lockPath}: ${describeErrorIdentity(lastAutopsy.readFailure)}; holder liveness unverifiable, lock left in place` : `role run writer lease lock at ${lockPath} has no verifiable holder pid (empty or unparseable); holder liveness unverifiable, lock left in place`
16142
+ );
16143
+ }
16144
+ if (reclaimsLeft <= 0) break;
16145
+ try {
16146
+ await reclaimStaleWriterLock(lockPath, runDirectory);
16147
+ } catch (reclaimError) {
16148
+ throw new RunWriterLeaseHeldError(
16149
+ `stale writer lease reclaim failed at ${lockPath} (autopsy: ${describeAutopsy(lastAutopsy)}): ${describeErrorIdentity(reclaimError)}`
16150
+ );
16080
16151
  }
16081
- throw error;
16082
16152
  }
16153
+ throw new RunWriterLeaseHeldError(
16154
+ `role run writer lease stayed contested at ${lockPath} after ${WRITER_LEASE_RECLAIM_ROUNDS} reclaims (last autopsy: ${describeAutopsy(lastAutopsy)})`
16155
+ );
16083
16156
  }
16084
16157
  async function findRunDirectoryById(home, runId) {
16085
16158
  if (runId.trim() === "") return void 0;
@@ -16471,7 +16544,7 @@ async function peekRoleRunRole(home, runId) {
16471
16544
  const run = await readRoleRunState(runDirectory);
16472
16545
  return run?.role;
16473
16546
  }
16474
- var V1_RESUMABLE_PROVIDERS, AUTO_RESUME_LIMIT, RESUME_TRANSPORT_ENVELOPE, RUN_STATE_FILE, WRITER_LOCK_FILE, RunWriterLeaseHeldError;
16547
+ var V1_RESUMABLE_PROVIDERS, AUTO_RESUME_LIMIT, RESUME_TRANSPORT_ENVELOPE, RUN_STATE_FILE, WRITER_LOCK_FILE, RunWriterLeaseHeldError, WRITER_LEASE_RECLAIM_ROUNDS;
16475
16548
  var init_run_lifecycle = __esm({
16476
16549
  "src/public-cli/run-lifecycle.ts"() {
16477
16550
  "use strict";
@@ -16492,6 +16565,7 @@ var init_run_lifecycle = __esm({
16492
16565
  this.name = "RunWriterLeaseHeldError";
16493
16566
  }
16494
16567
  };
16568
+ WRITER_LEASE_RECLAIM_ROUNDS = 3;
16495
16569
  }
16496
16570
  });
16497
16571
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.2497",
3
+ "version": "0.1.2510",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -194,7 +194,7 @@ function placeTicket(prepared: PreparedTicket): BoardPlacement {
194
194
  }
195
195
  if (latest.station === "coder") return "coder";
196
196
  if (latest.station === "fixer" || latest.station === "reviewer") return "marshal";
197
- // ADR 0053: marshal-driven runs land in 刑部 once the seat ships (station maps now).
197
+ // ADR 0053: marshal-driven runs land in 尚书省 once the seat ships (station maps now).
198
198
  if (latest.station === "marshal") return "marshal";
199
199
  if (latest.station === "collector") return "collector";
200
200
  return `other:${latest.station}`;
@@ -399,7 +399,7 @@ const YAMEN_LABELS: Readonly<Record<string, string>> = {
399
399
  collector: "通进司",
400
400
  doctor: "太医署",
401
401
  merger: "校书郎",
402
- marshal: "刑部",
402
+ marshal: "尚书省",
403
403
  };
404
404
 
405
405
  function latestKnownStation(runs: readonly TicketTrajectoryRun[]): string | undefined {
@@ -805,7 +805,7 @@ const COLUMN_LABELS: Readonly<Record<string, string>> = {
805
805
  pending: "待发",
806
806
  court: "大理寺 · 审票",
807
807
  coder: "将作监",
808
- marshal: "刑部",
808
+ marshal: "尚书省",
809
809
  collector: "通进司",
810
810
  done: "已完成",
811
811
  };
@@ -382,14 +382,151 @@ export function describeErrorIdentity(error: unknown): string {
382
382
  return `${name}${code}${message}`;
383
383
  }
384
384
 
385
+ function errorCodeOf(error: unknown): unknown {
386
+ return (error as { code?: unknown }).code;
387
+ }
388
+
389
+ /**
390
+ * Signal-0 liveness probe. Only ESRCH proves absence; any other refusal
391
+ * (e.g. EPERM) means the holder process exists.
392
+ */
393
+ function isProcessAlive(pid: number): boolean {
394
+ try {
395
+ process.kill(pid, 0);
396
+ return true;
397
+ } catch (error) {
398
+ return errorCodeOf(error) !== "ESRCH";
399
+ }
400
+ }
401
+
402
+ type WriterLockAutopsy =
403
+ | { verdict: "absent"; readFailure?: unknown }
404
+ | { verdict: "dead"; pid: number }
405
+ | { verdict: "alive"; pid: number };
406
+
407
+ /**
408
+ * Holder autopsy for an existing writer.lock (#552). "absent" covers no file,
409
+ * no parseable pid (a live creator mid-acquisition reads as empty, and so does
410
+ * the crash-window leftover), and unreadable files — absent alone never
411
+ * authorizes reclaim; only a "dead" verdict does. A non-ENOENT read failure
412
+ * still decides "absent" but rides along as readFailure so the true cause can
413
+ * land in the cleanup sink instead of being laundered away.
414
+ */
415
+ async function autopsyWriterLock(lockPath: string): Promise<WriterLockAutopsy> {
416
+ let content: string;
417
+ try {
418
+ content = await readFile(lockPath, "utf8");
419
+ } catch (error) {
420
+ if (errorCodeOf(error) === "ENOENT") return { verdict: "absent" };
421
+ return { verdict: "absent", readFailure: error };
422
+ }
423
+ const pid = Number.parseInt(content.trim(), 10);
424
+ if (!Number.isInteger(pid) || pid <= 0) return { verdict: "absent" };
425
+ return isProcessAlive(pid) ? { verdict: "alive", pid } : { verdict: "dead", pid };
426
+ }
427
+
428
+ function describeAutopsy(autopsy: WriterLockAutopsy): string {
429
+ switch (autopsy.verdict) {
430
+ case "alive":
431
+ return `live pid ${autopsy.pid}`;
432
+ case "dead":
433
+ return `dead pid ${autopsy.pid}`;
434
+ case "absent":
435
+ return autopsy.readFailure !== undefined
436
+ ? "unreadable lock"
437
+ : "absent or unparseable holder";
438
+ }
439
+ }
440
+
385
441
  /**
386
- * Acquire the one-writer lease for a Role run. Concurrent acquire rejects
387
- * without dispatch. Exclusive create — no second writer.
442
+ * Remove one lock whose re-read autopsy is still a verified-dead holder, or
443
+ * leave it for the next round otherwise. The pre-unlink re-read guard means a
444
+ * concurrent writer that re-locked between the autopsy and the unlink cannot
445
+ * have its live lock stolen. Residual race: a writer can still re-lock between
446
+ * the re-read and the unlink itself; POSIX offers no compare-and-delete, and
447
+ * this narrows the window to a single syscall pair.
448
+ */
449
+ async function reclaimStaleWriterLock(lockPath: string, runDirectory: string): Promise<void> {
450
+ const current = await autopsyWriterLock(lockPath);
451
+ if (current.verdict !== "dead") return;
452
+ try {
453
+ await unlink(lockPath);
454
+ } catch (error) {
455
+ if (errorCodeOf(error) === "ENOENT") return;
456
+ if (errorCodeOf(error) !== "EACCES") throw error;
457
+ await chmod(runDirectory, 0o755);
458
+ await unlink(lockPath);
459
+ }
460
+ }
461
+
462
+ async function createWriterLease(
463
+ lockPath: string,
464
+ runDirectory: string,
465
+ reportCleanupFailure: (error: unknown) => void,
466
+ ): Promise<RunWriterLease> {
467
+ const handle = await open(lockPath, "wx");
468
+ try {
469
+ await handle.writeFile(`${process.pid}\n`, "utf8");
470
+ } catch (error) {
471
+ await handle.close().catch(() => undefined);
472
+ await unlink(lockPath).catch(() => undefined);
473
+ throw error;
474
+ }
475
+ let released = false;
476
+ return {
477
+ lockPath,
478
+ async release() {
479
+ if (released) return;
480
+ released = true;
481
+ await handle.close().catch(() => undefined);
482
+ try {
483
+ await unlink(lockPath);
484
+ } catch (error) {
485
+ if (errorCodeOf(error) === "EACCES") {
486
+ try {
487
+ await chmod(runDirectory, 0o755);
488
+ await unlink(lockPath);
489
+ } catch (retryError) {
490
+ // best-effort cleanup: a stale lock left here is reclaimed by the
491
+ // next acquire's holder autopsy, but the true chmod/unlink cause
492
+ // must be recorded, not swallowed.
493
+ reportCleanupFailure(retryError);
494
+ }
495
+ } else {
496
+ // non-EACCES unlink failure is best-effort settlement cleanup; record true cause.
497
+ reportCleanupFailure(error);
498
+ }
499
+ }
500
+ },
501
+ };
502
+ }
503
+
504
+ const WRITER_LEASE_RECLAIM_ROUNDS = 3;
505
+
506
+ /**
507
+ * Acquire the one-writer lease for a Role run. Exclusive create — no second
508
+ * writer; a concurrent acquire rejects without dispatch.
509
+ *
510
+ * A contested lock gets a holder autopsy before rejection (#552): only a
511
+ * verified-dead holder pid — parseable pid, signal-0 ESRCH, and still dead on
512
+ * the pre-unlink re-read — authorizes reclaim, because no writer is left to
513
+ * release the lock; acquire then retries the create. An empty, unparseable, or
514
+ * unreadable lock proves no dead holder (a live creator is mid-acquisition
515
+ * between the exclusive create and its pid write), so it rejects as
516
+ * RunWriterLeaseHeldError naming the path and the lock stays on disk — a
517
+ * crash-window empty lock blocking a resume is that refusal's known residue,
518
+ * not safely fixable by unlink here. A live holder rejects the same typed
519
+ * error naming the pid and path. A pid recycled by an unrelated process reads
520
+ * as alive — that degrades to the same typed rejection, never worse than the
521
+ * pre-#552 behavior. Reclaim rounds are bounded by
522
+ * WRITER_LEASE_RECLAIM_ROUNDS; a lock that stays contested (e.g. a reclaim
523
+ * race repeatedly lost) surfaces the same typed error instead of spinning.
388
524
  *
389
525
  * `onCleanupFailure` receives a non-terminal diagnostic line when release-time
390
- * lock cleanup fails. Release stays best-effort (a stale lock resurfaces as
391
- * RunWriterLeaseHeldError on next acquire), but the true error identity must
392
- * still land somewhere observable — silent swallowing is forbidden.
526
+ * lock cleanup fails or a contested lock cannot be read. Release stays
527
+ * best-effort (a stale lock is reclaimed by the next acquire's autopsy), but
528
+ * the true error identity must still land somewhere observable — silent
529
+ * swallowing is forbidden.
393
530
  */
394
531
  export async function acquireRunWriterLease(
395
532
  runDirectory: string,
@@ -401,58 +538,57 @@ export async function acquireRunWriterLease(
401
538
  // cause has already been handed to the sink as its argument.
402
539
  try {
403
540
  onCleanupFailure?.(
404
- `writer lease lock cleanup failed (best-effort continue; stale lock resurfaces as lease-held on next acquire) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
541
+ `writer lease lock cleanup failed (best-effort continue; stale lock is reclaimed by the next acquire's holder autopsy) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
405
542
  );
406
543
  } catch {
407
544
  // diagnostic-sink failure is itself best-effort; never break release().
408
545
  }
409
546
  };
410
547
  const lockPath = join(runDirectory, WRITER_LOCK_FILE);
411
- try {
412
- const handle = await open(lockPath, "wx");
548
+ let lastAutopsy: WriterLockAutopsy = { verdict: "absent" };
549
+ // The reclaim budget: every successful reclaim is immediately followed by a
550
+ // fresh create attempt, including the budget's last one (the previous shape
551
+ // exited after a final-round reclaim with the path already clear).
552
+ for (let reclaimsLeft = WRITER_LEASE_RECLAIM_ROUNDS; ; reclaimsLeft -= 1) {
413
553
  try {
414
- await handle.writeFile(`${process.pid}\n`, "utf8");
554
+ return await createWriterLease(lockPath, runDirectory, reportCleanupFailure);
415
555
  } catch (error) {
416
- await handle.close().catch(() => undefined);
417
- await unlink(lockPath).catch(() => undefined);
418
- throw error;
556
+ if (errorCodeOf(error) !== "EEXIST") throw error;
419
557
  }
420
- let released = false;
421
- return {
422
- lockPath,
423
- async release() {
424
- if (released) return;
425
- released = true;
426
- await handle.close().catch(() => undefined);
427
- try {
428
- await unlink(lockPath);
429
- } catch (error) {
430
- if ((error as { code?: unknown }).code === "EACCES") {
431
- try {
432
- await chmod(runDirectory, 0o755);
433
- await unlink(lockPath);
434
- } catch (retryError) {
435
- // best-effort cleanup: stale lock will surface as lease-held on next acquire (exit 2),
436
- // but the true chmod/unlink cause must be recorded, not swallowed.
437
- reportCleanupFailure(retryError);
438
- }
439
- } else {
440
- // non-EACCES unlink failure is best-effort settlement cleanup; record true cause.
441
- reportCleanupFailure(error);
442
- }
443
- }
444
- },
445
- };
446
- } catch (error) {
447
- if (
448
- error instanceof Error &&
449
- "code" in error &&
450
- (error as { code?: unknown }).code === "EEXIST"
451
- ) {
452
- throw new RunWriterLeaseHeldError();
558
+ lastAutopsy = await autopsyWriterLock(lockPath);
559
+ if (lastAutopsy.verdict === "absent" && lastAutopsy.readFailure !== undefined) {
560
+ // 失败诚实: a lock we could not read must land its true read cause
561
+ // somewhere observable — recorded, but never treated as proof of death.
562
+ reportCleanupFailure(lastAutopsy.readFailure);
563
+ }
564
+ if (lastAutopsy.verdict === "alive") {
565
+ throw new RunWriterLeaseHeldError(
566
+ `role run writer lease is already held by live pid ${lastAutopsy.pid} at ${lockPath}`,
567
+ );
568
+ }
569
+ if (lastAutopsy.verdict === "absent") {
570
+ // #552 mechanical criterion: stale = holder pid verified dead. An
571
+ // empty lock (a live creator is mid-acquisition before its pid write)
572
+ // or an unreadable one proves no dead holder — unlinking here could
573
+ // steal a live writer's lock, so reject typed and leave the lock.
574
+ throw new RunWriterLeaseHeldError(
575
+ lastAutopsy.readFailure !== undefined
576
+ ? `role run writer lease lock is unreadable at ${lockPath}: ${describeErrorIdentity(lastAutopsy.readFailure)}; holder liveness unverifiable, lock left in place`
577
+ : `role run writer lease lock at ${lockPath} has no verifiable holder pid (empty or unparseable); holder liveness unverifiable, lock left in place`,
578
+ );
579
+ }
580
+ if (reclaimsLeft <= 0) break;
581
+ try {
582
+ await reclaimStaleWriterLock(lockPath, runDirectory);
583
+ } catch (reclaimError) {
584
+ throw new RunWriterLeaseHeldError(
585
+ `stale writer lease reclaim failed at ${lockPath} (autopsy: ${describeAutopsy(lastAutopsy)}): ${describeErrorIdentity(reclaimError)}`,
586
+ );
453
587
  }
454
- throw error;
455
588
  }
589
+ throw new RunWriterLeaseHeldError(
590
+ `role run writer lease stayed contested at ${lockPath} after ${WRITER_LEASE_RECLAIM_ROUNDS} reclaims (last autopsy: ${describeAutopsy(lastAutopsy)})`,
591
+ );
456
592
  }
457
593
 
458
594
  /**