@akagilnc/pi-workflow-roles 0.1.2503 → 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.
@@ -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.2503",
3
+ "version": "0.1.2510",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
  /**