@akagilnc/pi-workflow-roles 0.1.4062 → 0.1.4196

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 (72) hide show
  1. package/dist/acp-host/production-host.js +2075 -1088
  2. package/dist/archivist-record-entry.js +204 -20
  3. package/dist/archivist-record-topology.js +36 -16
  4. package/dist/auditor-dossier-tool.js +2 -1
  5. package/dist/diarist-contracts.js +96 -0
  6. package/dist/doctor-evidence.js +3 -2
  7. package/dist/headless-host/production-host.js +2215 -1228
  8. package/dist/migrate-book-topology.js +278917 -0
  9. package/dist/navigator-public-session.js +22 -25
  10. package/dist/notary-source-run.js +7 -4
  11. package/dist/pi/durable-principal.js +9 -6
  12. package/dist/public-cli/auto-resume.js +2 -5
  13. package/dist/public-cli/diarist-run.js +27 -25
  14. package/dist/public-cli/instruction-seat-run.js +8 -11
  15. package/dist/public-cli/invocation.js +127 -63
  16. package/dist/public-cli/main.js +1507 -645
  17. package/dist/public-cli/option-definitions.js +2 -2
  18. package/dist/public-cli/post-admission.js +47 -12
  19. package/dist/public-cli/run-lifecycle.js +206 -58
  20. package/dist/public-cli/seat-ticket-binding.js +3 -6
  21. package/dist/public-cli/settlement.js +5 -3
  22. package/dist/role-run-placement.js +84 -0
  23. package/dist/role-run-relocation.js +358 -0
  24. package/dist/run-terminal-artifacts.js +2 -1
  25. package/dist/run-ticket-number.js +51 -32
  26. package/dist/sitian-appender.js +11 -26
  27. package/dist/submission-ledger.js +78 -19
  28. package/dist/ticket-provenance.js +3 -1
  29. package/package.json +3 -2
  30. package/scripts/build-package.mjs +34 -2
  31. package/souls/countersign.md +2 -2
  32. package/souls/fixer.md +3 -2
  33. package/src/analyst-ledger.ts +14 -15
  34. package/src/archivist-record-entry.ts +285 -38
  35. package/src/archivist-record-topology.ts +43 -18
  36. package/src/auditor-dossier-tool.ts +2 -1
  37. package/src/book-topology-migration-placement.ts +493 -0
  38. package/src/book-topology-migration.ts +220 -0
  39. package/src/book-topology-migrators-t11.ts +856 -0
  40. package/src/book-topology-mixed-volume-migrators.ts +470 -0
  41. package/src/book-topology-partition-migrators.ts +25 -0
  42. package/src/book-topology-record-class-migrators.ts +779 -0
  43. package/src/book-topology-runs-migrator.ts +244 -0
  44. package/src/diarist-contracts.ts +116 -0
  45. package/src/diarist-role.ts +6 -0
  46. package/src/diarist.ts +2 -0
  47. package/src/doctor-evidence.ts +4 -1
  48. package/src/doctor-role.ts +1 -1
  49. package/src/navigator-public-session.ts +33 -30
  50. package/src/notary-source-run.ts +9 -4
  51. package/src/pi/durable-principal.ts +9 -13
  52. package/src/public-cli/auto-resume.ts +2 -6
  53. package/src/public-cli/countersign-run.ts +182 -32
  54. package/src/public-cli/diarist-run.ts +34 -26
  55. package/src/public-cli/instruction-seat-run.ts +7 -11
  56. package/src/public-cli/invocation.ts +192 -58
  57. package/src/public-cli/option-definitions.ts +2 -2
  58. package/src/public-cli/post-admission.ts +73 -13
  59. package/src/public-cli/run-lifecycle.ts +255 -65
  60. package/src/public-cli/seat-ticket-binding.ts +3 -6
  61. package/src/public-cli/settlement.ts +6 -4
  62. package/src/role-run-placement.ts +128 -0
  63. package/src/role-run-relocation.ts +491 -0
  64. package/src/role-runtime.ts +11 -5
  65. package/src/run-terminal-artifacts.ts +3 -1
  66. package/src/run-ticket-number.ts +80 -20
  67. package/src/sitian-appender.ts +25 -28
  68. package/src/submission-ledger.ts +102 -19
  69. package/src/ticket-provenance.ts +4 -1
  70. package/src/ticket-trajectory.ts +22 -35
  71. package/src/worker-role.ts +4 -4
  72. package/src/worker-submission-gates.ts +6 -6
@@ -529,8 +529,8 @@ const DOCTOR_OPTIONS = [
529
529
  repeatable: false,
530
530
  form: "option",
531
531
  description: {
532
- en: "Optional project-relative .ak-roles/books/<book>/issues/<n>/runs override matching --issue.",
533
- zh: "可选项目相对 .ak-roles/books/<book>/issues/<n>/runs 覆盖,且须匹配 --issue。",
532
+ en: "Optional project-relative .ak-roles/books/<book>/<n>/runs override matching --issue.",
533
+ zh: "可选项目相对 .ak-roles/books/<book>/<n>/runs 覆盖,且须匹配 --issue。",
534
534
  },
535
535
  },
536
536
  ];
@@ -62,10 +62,10 @@ function withOnceSuccessfulBeforeDispatch(adapters) {
62
62
  let succeeded = false;
63
63
  return {
64
64
  ...adapters,
65
- beforeDispatch: async (admitted) => {
65
+ beforeDispatch: async (admitted, lease) => {
66
66
  if (succeeded)
67
67
  return;
68
- await hook(admitted);
68
+ await hook(admitted, lease);
69
69
  succeeded = true;
70
70
  },
71
71
  };
@@ -259,6 +259,41 @@ export async function dispatchPostAdmissionTurn(input) {
259
259
  const persistRunState = input.persistRunState !== false;
260
260
  const deferredPersist = persistRunState ? {} : { needsPersist: true };
261
261
  const shouldPresent = adapters.shouldPresentSettled ?? ((terminal) => isLawfulTypedTerminalOutcome(terminal.roleOutcome));
262
+ /**
263
+ * Once-only post-host-turn hook (Diarist bind/relocate, etc.) under the still-held
264
+ * writer lease. Every exit after the host turn has started must pass here before
265
+ * lease release — success and failure alike. When a primary failure terminal already
266
+ * exists, keep that cause and leave relocate failure on the shared diagnostic channel.
267
+ */
268
+ let afterDispatchApplied = false;
269
+ const finishAfterTurn = async (result) => {
270
+ if (adapters.afterDispatch === undefined || afterDispatchApplied)
271
+ return result;
272
+ afterDispatchApplied = true;
273
+ try {
274
+ await adapters.afterDispatch(admitted, lease);
275
+ return result;
276
+ }
277
+ catch (error) {
278
+ const primaryFailure = result.terminal !== undefined
279
+ && !isLawfulTypedTerminalOutcome(result.terminal.roleOutcome);
280
+ if (primaryFailure) {
281
+ await recordBestEffortPostDispatchDiagnostic(admitted, env, `afterDispatch failed beside primary terminal (best-effort continue): ${describeErrorIdentity(error)}`, io);
282
+ return result;
283
+ }
284
+ return {
285
+ ...(await presentControlledFailure(admitted, {
286
+ timedOut: false,
287
+ code: null,
288
+ stderr: "",
289
+ thrown: error,
290
+ }, adapters, env.principalAuthority, io, persistRunState)),
291
+ ...(result.turnDispatched === true ? { turnDispatched: true } : {}),
292
+ ...(result.skipAutoResume === true ? { skipAutoResume: true } : {}),
293
+ ...deferredPersist,
294
+ };
295
+ }
296
+ };
262
297
  try {
263
298
  const missingCredential = missingCredentialPreDispatchFailure(env.model, env.credentials);
264
299
  if (missingCredential !== undefined) {
@@ -315,7 +350,7 @@ export async function dispatchPostAdmissionTurn(input) {
315
350
  // (#840 父子不层叠).
316
351
  if (adapters.beforeDispatch !== undefined) {
317
352
  try {
318
- await adapters.beforeDispatch(admitted);
353
+ await adapters.beforeDispatch(admitted, lease);
319
354
  }
320
355
  catch (error) {
321
356
  const settled = (await presentControlledFailure(admitted, {
@@ -384,7 +419,7 @@ export async function dispatchPostAdmissionTurn(input) {
384
419
  stderr: "",
385
420
  thrown: error,
386
421
  }, adapters, env.principalAuthority, io, persistRunState);
387
- return { ...settled, turnDispatched: true, ...deferredPersist };
422
+ return await finishAfterTurn({ ...settled, turnDispatched: true, ...deferredPersist });
388
423
  }
389
424
  // `result.stderr` stays live in memory for the real classification below
390
425
  // regardless of whether this durable mirror write succeeds. A write
@@ -551,7 +586,7 @@ export async function dispatchPostAdmissionTurn(input) {
551
586
  stderr: result.stderr,
552
587
  thrown: error,
553
588
  }, adapters, env.principalAuthority, io, persistRunState);
554
- return { ...settledFailure, turnDispatched: true, ...deferredPersist };
589
+ return await finishAfterTurn({ ...settledFailure, turnDispatched: true, ...deferredPersist });
555
590
  }
556
591
  if (settledOutcome !== undefined) {
557
592
  if (persistRunState) {
@@ -576,12 +611,12 @@ export async function dispatchPostAdmissionTurn(input) {
576
611
  thrown: error,
577
612
  skipRunStateWrite: true,
578
613
  }, adapters, env.principalAuthority, io, persistRunState);
579
- return { ...failed, turnDispatched: true, ...deferredPersist };
614
+ return await finishAfterTurn({ ...failed, turnDispatched: true, ...deferredPersist });
580
615
  }
581
616
  }
582
617
  // Lawful persist + present is the caller's stop seam (auto-resume loop /
583
618
  // manual resume), not this retried host-turn function.
584
- return { ...settledOutcome, ...deferredPersist };
619
+ return await finishAfterTurn({ ...settledOutcome, ...deferredPersist });
585
620
  }
586
621
  // Host/runner true failure coexists with already-recorded payloads — never wash as accepted.
587
622
  if (hostSignalFailed) {
@@ -608,7 +643,7 @@ export async function dispatchPostAdmissionTurn(input) {
608
643
  }
609
644
  : { knownDetails: stderrLogWriteDetails }),
610
645
  }, adapters, env.principalAuthority, io, persistRunState);
611
- return { ...failed, turnDispatched: true, ...deferredPersist };
646
+ return await finishAfterTurn({ ...failed, turnDispatched: true, ...deferredPersist });
612
647
  }
613
648
  // Nothing else was wrong, but the durable stderr mirror itself failed to
614
649
  // write — that is the real (infrastructure) problem in this case, not a
@@ -620,7 +655,7 @@ export async function dispatchPostAdmissionTurn(input) {
620
655
  stderr: result.stderr,
621
656
  thrown: stderrLogWriteFailure,
622
657
  }, adapters, env.principalAuthority, io, persistRunState);
623
- return { ...failed, turnDispatched: true, ...deferredPersist };
658
+ return await finishAfterTurn({ ...failed, turnDispatched: true, ...deferredPersist });
624
659
  }
625
660
  const noReceipt = await attachRecordedSubmissions(admitted, await settleHostEndedNoReceipt(admitted, env.principalAuthority), courtScope);
626
661
  if (persistRunState) {
@@ -640,16 +675,16 @@ export async function dispatchPostAdmissionTurn(input) {
640
675
  thrown: error,
641
676
  skipRunStateWrite: true,
642
677
  }, adapters, env.principalAuthority, io, persistRunState);
643
- return { ...failed, turnDispatched: true, ...deferredPersist };
678
+ return await finishAfterTurn({ ...failed, turnDispatched: true, ...deferredPersist });
644
679
  }
645
680
  }
646
- return {
681
+ return await finishAfterTurn({
647
682
  exitCode: exitCodeForTerminalOutcome(noReceipt.roleOutcome),
648
683
  admitted,
649
684
  terminal: noReceipt,
650
685
  turnDispatched: true,
651
686
  ...deferredPersist,
652
- };
687
+ });
653
688
  }
654
689
  finally {
655
690
  try {
@@ -5,14 +5,16 @@
5
5
  * any existing run with an available Pi session principal may be resumed; caller decides.
6
6
  * Prose is never regex-classified as quota evidence.
7
7
  */
8
- import { chmod, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
9
- import { isAbsolute, join } from "node:path";
8
+ import { chmod, lstat, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
9
+ import { basename, isAbsolute, join } from "node:path";
10
10
  import { activationBookDirectory, resolveActivationLedgerHome, } from "../activation-ledger-topology.js";
11
- import { readRunTicketNumber } from "../run-ticket-number.js";
11
+ import { listBookRunDirectories } from "../role-run-placement.js";
12
+ import { isSafePositiveTicketNumber, readBoardTicketNumber, } from "../run-ticket-number.js";
12
13
  import { CliUsageError } from "./cli-errors.js";
13
14
  import { readLatestTypedProviderHttpObservation, } from "../typed-provider-http.js";
14
15
  export { clearTypedProviderHttpObservation, recordTypedProviderHttpStatus, readLatestTypedProviderHttpObservation, } from "../typed-provider-http.js";
15
16
  import { parseCollectorRepository } from "../collector-config.js";
17
+ import { interpretDurableCourtTicketNumbers, sameCourtTicketNumbers, } from "../diarist-contracts.js";
16
18
  import { appendEngineSessionMaterial, engineSessionMaterialFromOptions, pickEngineAxis, } from "../package-resources/engine-material.js";
17
19
  import { recordEffectiveInvocationModel, requireAuthorityRef, } from "./invocation.js";
18
20
  /** Providers eligible for v1 typed-429 resume (Codex / xAI only).
@@ -477,14 +479,11 @@ function isProcessAlive(pid) {
477
479
  }
478
480
  }
479
481
  /**
480
- * Holder autopsy for an existing writer.lock (#552). "absent" covers no file,
481
- * no parseable pid (a live creator mid-acquisition reads as empty, and so does
482
- * the crash-window leftover), and unreadable files — absent alone never
483
- * authorizes reclaim; only a "dead" verdict does. A non-ENOENT read failure
484
- * still decides "absent" but rides along as readFailure so the true cause can
485
- * land in the cleanup sink instead of being laundered away.
482
+ * The writer lease holder is the shared authority for current run activity.
483
+ * Only ENOENT proves no holder; malformed or unreadable locks remain unknown
484
+ * because they can be observed while a live creator is writing its pid.
486
485
  */
487
- async function autopsyWriterLock(lockPath) {
486
+ export async function autopsyWriterLock(lockPath) {
488
487
  let content;
489
488
  try {
490
489
  content = await readFile(lockPath, "utf8");
@@ -492,14 +491,16 @@ async function autopsyWriterLock(lockPath) {
492
491
  catch (error) {
493
492
  if (errorCodeOf(error) === "ENOENT")
494
493
  return { verdict: "absent" };
495
- return { verdict: "absent", readFailure: error };
494
+ return { verdict: "unknown", reason: "unreadable", readFailure: error };
496
495
  }
497
496
  const normalized = content.trim();
498
- if (!/^[1-9]\d*$/.test(normalized))
499
- return { verdict: "absent" };
497
+ if (!/^[1-9]\d*$/.test(normalized)) {
498
+ return { verdict: "unknown", reason: "unparseable", content };
499
+ }
500
500
  const pid = Number.parseInt(normalized, 10);
501
- if (!Number.isSafeInteger(pid) || pid <= 0)
502
- return { verdict: "absent" };
501
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
502
+ return { verdict: "unknown", reason: "unparseable", content };
503
+ }
503
504
  return isProcessAlive(pid) ? { verdict: "alive", pid } : { verdict: "dead", pid };
504
505
  }
505
506
  function describeAutopsy(autopsy) {
@@ -509,9 +510,9 @@ function describeAutopsy(autopsy) {
509
510
  case "dead":
510
511
  return `dead pid ${autopsy.pid}`;
511
512
  case "absent":
512
- return autopsy.readFailure !== undefined
513
- ? "unreadable lock"
514
- : "absent or unparseable holder";
513
+ return "absent holder";
514
+ case "unknown":
515
+ return autopsy.reason === "unreadable" ? "unreadable lock" : "unparseable holder";
515
516
  }
516
517
  }
517
518
  /**
@@ -565,28 +566,36 @@ async function createWriterLease(lockPath, runDirectory, reportCleanupFailure) {
565
566
  throw error;
566
567
  }
567
568
  let released = false;
569
+ let currentRunDirectory = runDirectory;
570
+ let currentLockPath = lockPath;
568
571
  return {
569
- lockPath,
572
+ get lockPath() {
573
+ return currentLockPath;
574
+ },
575
+ relocate(nextRunDirectory) {
576
+ currentRunDirectory = nextRunDirectory;
577
+ currentLockPath = join(nextRunDirectory, WRITER_LOCK_FILE);
578
+ },
570
579
  async release() {
571
580
  if (released)
572
581
  return;
573
582
  released = true;
574
583
  await handle.close().catch(() => undefined);
575
584
  try {
576
- await unlink(lockPath);
585
+ await unlink(currentLockPath);
577
586
  }
578
587
  catch (error) {
579
588
  if (errorCodeOf(error) === "EACCES") {
580
589
  try {
581
- await chmod(runDirectory, 0o755);
582
- await unlink(lockPath);
590
+ await chmod(currentRunDirectory, 0o755);
591
+ await unlink(currentLockPath);
583
592
  }
584
593
  catch (retryError) {
585
- reportCleanupFailure(retryError);
594
+ reportCleanupFailure(retryError, currentLockPath);
586
595
  }
587
596
  }
588
597
  else {
589
- reportCleanupFailure(error);
598
+ reportCleanupFailure(error, currentLockPath);
590
599
  }
591
600
  }
592
601
  },
@@ -640,8 +649,8 @@ export async function acquireRunWriterLease(runDirectory, onCleanupFailure) {
640
649
  * acquire reads it as live and rejects; nothing here may promise that the
641
650
  * next acquire reclaims it.
642
651
  */
643
- const reportCleanupFailure = (error) => {
644
- reportDiagnostic(`writer lease lock cleanup failed (release is best-effort; residual lock left in place) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`);
652
+ const reportCleanupFailure = (error, lockPath) => {
653
+ reportDiagnostic(`writer lease lock cleanup failed (release is best-effort; residual lock left in place) at ${lockPath}: ${describeErrorIdentity(error)}`);
645
654
  };
646
655
  /** Contested-lock read failure: nothing was cleaned up; the lock stays exactly where it is. */
647
656
  const reportReadFailure = (error) => {
@@ -659,17 +668,20 @@ export async function acquireRunWriterLease(runDirectory, onCleanupFailure) {
659
668
  throw error;
660
669
  }
661
670
  lastAutopsy = await autopsyWriterLock(lockPath);
662
- if (lastAutopsy.verdict === "absent" && lastAutopsy.readFailure !== undefined) {
671
+ if (lastAutopsy.verdict === "unknown" && lastAutopsy.reason === "unreadable") {
663
672
  reportReadFailure(lastAutopsy.readFailure);
664
673
  }
665
674
  if (lastAutopsy.verdict === "alive") {
666
675
  throw new RunWriterLeaseHeldError(`role run writer lease is already held by live pid ${lastAutopsy.pid} at ${lockPath}`);
667
676
  }
668
- if (lastAutopsy.verdict === "absent") {
669
- throw new RunWriterLeaseHeldError(lastAutopsy.readFailure !== undefined
677
+ if (lastAutopsy.verdict === "unknown") {
678
+ throw new RunWriterLeaseHeldError(lastAutopsy.reason === "unreadable"
670
679
  ? `role run writer lease lock is unreadable at ${lockPath}: ${describeErrorIdentity(lastAutopsy.readFailure)}; holder liveness unverifiable, lock left in place`
671
680
  : `role run writer lease lock at ${lockPath} has no verifiable holder pid (empty or unparseable); holder liveness unverifiable, lock left in place`);
672
681
  }
682
+ if (lastAutopsy.verdict === "absent") {
683
+ throw new RunWriterLeaseHeldError(`role run writer lease disappeared before holder autopsy at ${lockPath}`);
684
+ }
673
685
  if (reclaimsLeft <= 0)
674
686
  break;
675
687
  let reclaimed = false;
@@ -694,8 +706,11 @@ export async function acquireRunWriterLease(runDirectory, onCleanupFailure) {
694
706
  /**
695
707
  * Locate a Role run directory by run ID under the ledger books home.
696
708
  * Returns undefined when the ID is unknown.
709
+ * Walk surface = listBookRunDirectories (flat legacy + subject-tree).
710
+ * Collects every match under the supplied book/role filters: zero → undefined,
711
+ * one → path, many → loud ambiguity (never readdir-first).
697
712
  */
698
- export async function findRunDirectoryById(home, runId) {
713
+ export async function findRunDirectoryById(home, runId, onlyBookKey, onlyRole) {
699
714
  if (runId.trim() === "")
700
715
  return undefined;
701
716
  const ledgerHome = resolveActivationLedgerHome(home);
@@ -704,25 +719,39 @@ export async function findRunDirectoryById(home, runId) {
704
719
  try {
705
720
  bookKeys = await readdir(booksRoot);
706
721
  }
707
- catch {
708
- return undefined;
722
+ catch (error) {
723
+ // Only a missing books root is "unknown id"; permission/IO damage propagates.
724
+ if (errorCodeOf(error) === "ENOENT")
725
+ return undefined;
726
+ throw error;
709
727
  }
728
+ const matches = [];
710
729
  for (const bookKey of bookKeys) {
711
- const runsDir = join(activationBookDirectory(ledgerHome, bookKey), "runs");
712
- let entries;
730
+ if (onlyBookKey !== undefined && bookKey !== onlyBookKey)
731
+ continue;
732
+ const bookDir = activationBookDirectory(ledgerHome, bookKey);
733
+ let runDirectories;
713
734
  try {
714
- entries = await readdir(runsDir);
735
+ runDirectories = await listBookRunDirectories(bookDir);
715
736
  }
716
- catch {
717
- continue;
737
+ catch (error) {
738
+ if (errorCodeOf(error) === "ENOENT")
739
+ continue;
740
+ throw error;
718
741
  }
719
- for (const entry of entries) {
720
- if (entry === `${runId}@judge` || entry.startsWith(`${runId}@`)) {
721
- return join(runsDir, entry);
742
+ for (const runDirectory of runDirectories) {
743
+ const entry = basename(runDirectory);
744
+ if ((onlyRole === undefined && (entry === `${runId}@judge` || entry.startsWith(`${runId}@`))) ||
745
+ entry === `${runId}@${onlyRole}`) {
746
+ matches.push(runDirectory);
722
747
  }
723
748
  }
724
749
  }
725
- return undefined;
750
+ if (matches.length === 0)
751
+ return undefined;
752
+ if (matches.length === 1)
753
+ return matches[0];
754
+ throw new Error(`ambiguous role run id ${runId}: ${matches.join(", ")}`);
726
755
  }
727
756
  /** Code-owned gate inspector summons prefix (public-role-summons / #747). */
728
757
  export const GATE_DOSSIER_POINTER_PREFIX = "卷宗指针:";
@@ -760,19 +789,49 @@ export async function readRunParentPath(runDirectory) {
760
789
  }
761
790
  return undefined;
762
791
  }
792
+ /**
793
+ * True when durable run-state names a session principal that has actually formed
794
+ * (session file present as a real file). Provisional runs relocated+terminalized
795
+ * before dispatch never form one — same-ticket lookup must skip them so a newer
796
+ * abandoned mint cannot eclipse an older resumable principal (#859).
797
+ * Not a terminal-state gate: ADR/#416 allows terminal resume when principal exists.
798
+ */
799
+ async function runHasFormedSessionPrincipal(runDirectory) {
800
+ const disk = await readRoleRunStateDisk(runDirectory);
801
+ if (disk === undefined)
802
+ return false;
803
+ const sessionFile = typeof disk.principalWire.sessionFile === "string" &&
804
+ disk.principalWire.sessionFile.trim() !== ""
805
+ ? disk.principalWire.sessionFile
806
+ : join(disk.principalWire.sessionDirectory, "session.jsonl");
807
+ try {
808
+ const stat = await lstat(sessionFile);
809
+ return stat.isFile() && !stat.isSymbolicLink();
810
+ }
811
+ catch (error) {
812
+ // Absent session file → not formed; permission/IO/damage keeps identity.
813
+ if (errorCodeOf(error) === "ENOENT")
814
+ return false;
815
+ throw error;
816
+ }
817
+ }
763
818
  /**
764
819
  * Locate the latest retained run for one seat under a book (#637 / #747).
765
- * Same walk surface as findRunDirectoryById. Match by parent run path (officer
766
- * seats, #747) or by ticket number (countersign / diarist principal).
767
- * runId is UUIDv7 — lexicographic max is latest. No parallel index.
768
- * Only a truly missing runs directory means no history; damage/permission errors propagate.
820
+ * Same walk surface as findRunDirectoryById (listBookRunDirectories). Match by
821
+ * parent run path (officer seats, #747) or by ticket number (countersign /
822
+ * diarist principal). runId is UUIDv7 — lexicographic max is latest among runs
823
+ * that formed a session principal. No parallel index.
824
+ * Only a truly missing book directory means no history; damage/permission errors propagate.
769
825
  */
770
826
  export async function findLatestRunIdForSeatTicket(input) {
827
+ if (input.ticketNumber === undefined && input.parentRunPath === undefined) {
828
+ return undefined;
829
+ }
771
830
  const ledgerHome = resolveActivationLedgerHome(input.home);
772
- const runsDir = join(activationBookDirectory(ledgerHome, input.bookKey), "runs");
773
- let entries;
831
+ const bookDir = activationBookDirectory(ledgerHome, input.bookKey);
832
+ let runDirectories;
774
833
  try {
775
- entries = await readdir(runsDir);
834
+ runDirectories = await listBookRunDirectories(bookDir);
776
835
  }
777
836
  catch (error) {
778
837
  if (errorCodeOf(error) === "ENOENT")
@@ -781,39 +840,38 @@ export async function findLatestRunIdForSeatTicket(input) {
781
840
  }
782
841
  const suffix = `@${input.role}`;
783
842
  let best;
784
- for (const entry of entries) {
843
+ for (const runDirectory of runDirectories) {
844
+ const entry = basename(runDirectory);
785
845
  if (!entry.endsWith(suffix))
786
846
  continue;
787
847
  const runId = entry.slice(0, entry.length - suffix.length);
788
848
  if (runId.length === 0)
789
849
  continue;
790
- const runDirectory = join(runsDir, entry);
791
850
  if (input.parentRunPath !== undefined) {
792
851
  const parentPath = await readRunParentPath(runDirectory);
793
852
  if (parentPath !== input.parentRunPath)
794
853
  continue;
795
854
  }
796
855
  else if (input.ticketNumber !== undefined) {
797
- const ticketNumber = await readRunTicketNumber(runDirectory);
856
+ // Same-ticket resume is board identity only — never migration-derived.
857
+ const ticketNumber = await readBoardTicketNumber(runDirectory);
798
858
  if (ticketNumber !== input.ticketNumber)
799
859
  continue;
800
860
  }
801
- else {
861
+ // Durable fact: never resume-select a provisional that never formed principal.
862
+ if (!(await runHasFormedSessionPrincipal(runDirectory)))
802
863
  continue;
803
- }
804
864
  if (best === undefined || runId > best)
805
865
  best = runId;
806
866
  }
807
867
  return best;
808
868
  }
809
- /** Restore optional correlation + typed ticket identity from a durable admitted page. */
869
+ /** Restore optional correlation + typed main-ticket identity from a durable page. */
810
870
  function parsePersistedTicketIdentity(record) {
811
871
  const correlationId = typeof record.correlationId === "string" && record.correlationId.trim() !== ""
812
872
  ? record.correlationId
813
873
  : undefined;
814
- const ticketNumber = typeof record.ticketNumber === "number" &&
815
- Number.isSafeInteger(record.ticketNumber) &&
816
- record.ticketNumber >= 1
874
+ const ticketNumber = isSafePositiveTicketNumber(record.ticketNumber)
817
875
  ? record.ticketNumber
818
876
  : undefined;
819
877
  return {
@@ -821,10 +879,64 @@ function parsePersistedTicketIdentity(record) {
821
879
  ...(ticketNumber === undefined ? {} : { ticketNumber }),
822
880
  };
823
881
  }
882
+ /**
883
+ * #871 durable set from one run page.
884
+ * Absent → absent. Present damage keeps the original reason (no throw here).
885
+ */
886
+ function readDurableCourtTicketNumbersPage(record, principalTicket, pageLabel) {
887
+ const interpreted = interpretDurableCourtTicketNumbers(record, {
888
+ ...(principalTicket === undefined ? {} : { principalTicket }),
889
+ });
890
+ if (interpreted.kind === "damage") {
891
+ return {
892
+ kind: "damage",
893
+ reason: `role run ${pageLabel} courtTicketNumbers is damaged: ${interpreted.reason}`,
894
+ };
895
+ }
896
+ if (interpreted.kind === "ok") {
897
+ return { kind: "ok", tickets: interpreted.tickets };
898
+ }
899
+ return { kind: "absent" };
900
+ }
901
+ /**
902
+ * Merge admitted + invocation #871 sets.
903
+ * Both present must match; one present wins; both absent stays legacy.
904
+ * Damage / cross-page mismatch return a diagnostic — countersign resume settles
905
+ * controlled failure; load itself does not structural-reject.
906
+ */
907
+ function mergeDurableCourtTicketNumbers(input) {
908
+ const fromAdmitted = input.admittedRecord === undefined
909
+ ? { kind: "absent" }
910
+ : readDurableCourtTicketNumbersPage(input.admittedRecord, input.principalTicket, "admitted-request");
911
+ const fromInvocation = input.invocationRecord === undefined
912
+ ? { kind: "absent" }
913
+ : readDurableCourtTicketNumbersPage(input.invocationRecord, input.principalTicket, "invocation");
914
+ if (fromAdmitted.kind === "damage")
915
+ return fromAdmitted;
916
+ if (fromInvocation.kind === "damage")
917
+ return fromInvocation;
918
+ if (fromAdmitted.kind === "ok" && fromInvocation.kind === "ok") {
919
+ if (!sameCourtTicketNumbers(fromAdmitted.tickets, fromInvocation.tickets)) {
920
+ return {
921
+ kind: "damage",
922
+ reason: "role run courtTicketNumbers differs between admitted-request and invocation",
923
+ };
924
+ }
925
+ return { kind: "ok", tickets: fromAdmitted.tickets };
926
+ }
927
+ if (fromAdmitted.kind === "ok")
928
+ return { kind: "ok", tickets: fromAdmitted.tickets };
929
+ if (fromInvocation.kind === "ok")
930
+ return { kind: "ok", tickets: fromInvocation.tickets };
931
+ return { kind: "ok" };
932
+ }
824
933
  function restoredTicketFields(fields) {
825
934
  return {
826
935
  ...(fields.correlationId === undefined ? {} : { correlationId: fields.correlationId }),
827
936
  ...(fields.ticketNumber === undefined ? {} : { ticketNumber: fields.ticketNumber }),
937
+ ...(fields.courtTicketNumbers === undefined
938
+ ? {}
939
+ : { courtTicketNumbers: fields.courtTicketNumbers }),
828
940
  };
829
941
  }
830
942
  async function loadResumableRunRecord(home, runId, authority) {
@@ -862,6 +974,10 @@ async function loadResumableRunRecord(home, runId, authority) {
862
974
  let derived;
863
975
  let correlationId;
864
976
  let ticketNumber;
977
+ let courtTicketNumbers;
978
+ let courtTicketNumbersDamage;
979
+ let admittedIdentityRecord;
980
+ let invocationIdentityRecord;
865
981
  let prNumber;
866
982
  let repository;
867
983
  let repositoryDisplay;
@@ -996,6 +1112,7 @@ async function loadResumableRunRecord(home, runId, authority) {
996
1112
  };
997
1113
  }
998
1114
  }
1115
+ admittedIdentityRecord = record;
999
1116
  const fromAdmitted = parsePersistedTicketIdentity(record);
1000
1117
  correlationId = fromAdmitted.correlationId;
1001
1118
  ticketNumber = fromAdmitted.ticketNumber;
@@ -1014,6 +1131,7 @@ async function loadResumableRunRecord(home, runId, authority) {
1014
1131
  typeof invocationRaw === "object" &&
1015
1132
  !Array.isArray(invocationRaw)) {
1016
1133
  const rec = invocationRaw;
1134
+ invocationIdentityRecord = rec;
1017
1135
  if (typeof rec.provider === "string" && typeof rec.model === "string") {
1018
1136
  model = {
1019
1137
  provider: rec.provider,
@@ -1033,12 +1151,29 @@ async function loadResumableRunRecord(home, runId, authority) {
1033
1151
  }
1034
1152
  }
1035
1153
  catch (error) {
1154
+ if (error instanceof CliUsageError) {
1155
+ throw error;
1156
+ }
1036
1157
  if (!(error instanceof Error &&
1037
1158
  "code" in error &&
1038
1159
  error.code === "ENOENT")) {
1039
1160
  throw new CliUsageError(`role run invocation identity is unreadable: ${runId}`, { cause: error });
1040
1161
  }
1041
1162
  }
1163
+ // #871: resolve set after main ticket so principal membership can be checked.
1164
+ // Present-but-damaged / cross-page mismatch keep diagnostic for countersign
1165
+ // controlled-failure; both-absent stays legacy; single lawful page restores.
1166
+ const mergedSet = mergeDurableCourtTicketNumbers({
1167
+ admittedRecord: admittedIdentityRecord,
1168
+ invocationRecord: invocationIdentityRecord,
1169
+ principalTicket: ticketNumber,
1170
+ });
1171
+ if (mergedSet.kind === "damage") {
1172
+ courtTicketNumbersDamage = mergedSet.reason;
1173
+ }
1174
+ else if (mergedSet.tickets !== undefined) {
1175
+ courtTicketNumbers = mergedSet.tickets;
1176
+ }
1042
1177
  return {
1043
1178
  run,
1044
1179
  principal,
@@ -1058,6 +1193,10 @@ async function loadResumableRunRecord(home, runId, authority) {
1058
1193
  ...(derived === undefined ? {} : { derived }),
1059
1194
  ...(correlationId === undefined ? {} : { correlationId }),
1060
1195
  ...(ticketNumber === undefined ? {} : { ticketNumber }),
1196
+ ...(courtTicketNumbers === undefined ? {} : { courtTicketNumbers }),
1197
+ ...(courtTicketNumbersDamage === undefined
1198
+ ? {}
1199
+ : { courtTicketNumbersDamage }),
1061
1200
  ...(prNumber === undefined ? {} : { prNumber }),
1062
1201
  ...(repository === undefined ? {} : { repository }),
1063
1202
  ...(repositoryDisplay === undefined ? {} : { repositoryDisplay }),
@@ -1213,9 +1352,18 @@ export async function loadResumableCountersignRun(home, runId, authority) {
1213
1352
  if (loaded.run.role !== "countersign") {
1214
1353
  throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not countersign`);
1215
1354
  }
1355
+ const base = resumedBaseAdmitted(loaded);
1216
1356
  const admitted = {
1217
1357
  role: "countersign",
1218
- ...resumedBaseAdmitted(loaded),
1358
+ ...base,
1359
+ ...(base.courtTicketNumbers === undefined
1360
+ ? {}
1361
+ : { courtTicketNumbers: base.courtTicketNumbers }),
1362
+ ...(loaded.admittedFields.courtTicketNumbersDamage === undefined
1363
+ ? {}
1364
+ : {
1365
+ courtTicketNumbersDamage: loaded.admittedFields.courtTicketNumbersDamage,
1366
+ }),
1219
1367
  };
1220
1368
  return seatLoadedResult(loaded, admitted);
1221
1369
  }
@@ -1,17 +1,14 @@
1
1
  /**
2
2
  * Shared ticket identity seam for public court seats (#635 / #637 / #709 / #747 / #771).
3
3
  *
4
- * ADR 0075 `diarist-resolves-ticket-llm-layer`: the seat's LLM names the court
5
- * target as a typed assertion; the mechanical layer only verifies that assertion
6
- * (complete decimal of N appears in the summons; ticket exists). Code never
7
- * judges which ticket the summons is about — no prose harvest, no first-#N
8
- * position pick, no matching book-known numbers against instruction text
4
+ * ADR 0075 `diarist-resolves-ticket-llm-layer`: the diarist LLM recognizes the
5
+ * court target; recognized ticket → provenance, truly unbound → no provenance,
6
+ * or escalate when it cannot recognize one. Code does not re-judge the ticket
9
7
  * (锚定宪法; owner 2026-09-08: 代码不准做判断). Other seats reuse a typed
10
8
  * identity already handed over (起居郎 assertion / source-run / already-bound
11
9
  * resume) — they do not re-recognize from instruction. No CLI --ticket, no
12
10
  * attachment frontmatter. #747: officer same-parent resume also lives here.
13
11
  *
14
- * Mechanical verify helpers live on diarist.ts / invocation.ts (sole owners).
15
12
  * This module owns same-ticket / same-parent resume only.
16
13
  */
17
14
  import { resolveBookKeyFromGit } from "../activation-ledger-git.js";