@akagilnc/pi-workflow-roles 0.1.2177 → 0.1.2193

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.
@@ -22627,6 +22627,10 @@ var init_settlement = __esm({
22627
22627
  });
22628
22628
 
22629
22629
  // src/public-cli/auto-resume.ts
22630
+ import { constants as fsConstants2 } from "node:fs";
22631
+ import { randomUUID as randomUUID2 } from "node:crypto";
22632
+ import { appendFile as appendFile2, lstat as lstat4, mkdir as mkdir4, open as open3, readFile as readFile10 } from "node:fs/promises";
22633
+ import { join as join12 } from "node:path";
22630
22634
  function presentTerminal(terminal, io) {
22631
22635
  if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
22632
22636
  presentFailureTerminal(terminal, io);
@@ -22634,12 +22638,204 @@ function presentTerminal(terminal, io) {
22634
22638
  io.stdout(formatTerminalResult(terminal));
22635
22639
  }
22636
22640
  }
22641
+ async function finalizeExceptionRunBestEffort(runDirectory, io) {
22642
+ try {
22643
+ await markRunTerminal(runDirectory);
22644
+ } catch (error) {
22645
+ io.stderr(
22646
+ `run terminal-state finalization failed (best-effort continue): ${describeErrorIdentity(error)}
22647
+ `
22648
+ );
22649
+ }
22650
+ }
22651
+ function runArtifactsDirectory(runDirectory) {
22652
+ return join12(runDirectory, "artifacts");
22653
+ }
22654
+ async function ensureRealArtifactsDirectory(runDirectory) {
22655
+ const runStat = await lstat4(runDirectory);
22656
+ if (runStat.isSymbolicLink() || !runStat.isDirectory()) {
22657
+ throw new Error("dispatch error retention: run directory is not a real directory");
22658
+ }
22659
+ const artifactsDir = runArtifactsDirectory(runDirectory);
22660
+ try {
22661
+ const existing = await lstat4(artifactsDir);
22662
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
22663
+ throw new Error("dispatch error retention: artifacts path is not a real directory");
22664
+ }
22665
+ } catch (error) {
22666
+ if (!isMissingPathError3(error)) throw error;
22667
+ await mkdir4(artifactsDir, { recursive: true });
22668
+ const created = await lstat4(artifactsDir);
22669
+ if (created.isSymbolicLink() || !created.isDirectory()) {
22670
+ throw new Error("dispatch error retention: artifacts directory is not a real directory");
22671
+ }
22672
+ }
22673
+ return artifactsDir;
22674
+ }
22675
+ function serializeThrownValue(value, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
22676
+ if (value instanceof Error) {
22677
+ if (seen.has(value)) return "[circular]";
22678
+ seen.add(value);
22679
+ const transferred = {};
22680
+ for (const key of Object.getOwnPropertyNames(value)) {
22681
+ transferred[key] = transferNestedValue(
22682
+ value[key],
22683
+ depth + 1,
22684
+ seen
22685
+ );
22686
+ }
22687
+ return {
22688
+ errorKind: "Error",
22689
+ constructorName: value.constructor?.name,
22690
+ ...transferred,
22691
+ ...value.cause === void 0 ? {} : {
22692
+ causeChain: depth >= 10 ? "[cause-chain-depth-limit]" : serializeThrownValue(value.cause, depth + 1, seen)
22693
+ }
22694
+ };
22695
+ }
22696
+ return value;
22697
+ }
22698
+ function isMissingPathError3(error) {
22699
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
22700
+ }
22701
+ function transferNestedValue(value, depth, seen) {
22702
+ if (value instanceof Error) return serializeThrownValue(value, depth, seen);
22703
+ if (depth >= 10) return "[nested-depth-limit]";
22704
+ if (Array.isArray(value)) {
22705
+ return value.map((item) => transferNestedValue(item, depth + 1, seen));
22706
+ }
22707
+ if (value !== null && typeof value === "object") {
22708
+ if (seen.has(value)) return "[circular]";
22709
+ seen.add(value);
22710
+ const transferred = {};
22711
+ for (const key of Object.getOwnPropertyNames(value)) {
22712
+ transferred[key] = transferNestedValue(
22713
+ value[key],
22714
+ depth + 1,
22715
+ seen
22716
+ );
22717
+ }
22718
+ return transferred;
22719
+ }
22720
+ return value;
22721
+ }
22722
+ function jsonSafeReplacer() {
22723
+ const seen = /* @__PURE__ */ new WeakSet();
22724
+ return (_key, value) => {
22725
+ if (typeof value === "bigint") return `${value}n`;
22726
+ if (typeof value === "object" && value !== null) {
22727
+ if (seen.has(value)) return "[circular]";
22728
+ seen.add(value);
22729
+ }
22730
+ return value;
22731
+ };
22732
+ }
22733
+ async function retainDispatchError(admitted, attempt, error) {
22734
+ const artifactsDir = await ensureRealArtifactsDirectory(admitted.runDirectory);
22735
+ const filePath = join12(
22736
+ artifactsDir,
22737
+ `dispatch-error-attempt-${attempt}-${randomUUID2()}.json`
22738
+ );
22739
+ const payload = `${JSON.stringify(
22740
+ {
22741
+ version: 1,
22742
+ attempt,
22743
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
22744
+ error: serializeThrownValue(error)
22745
+ },
22746
+ jsonSafeReplacer(),
22747
+ 2
22748
+ )}
22749
+ `;
22750
+ const noFollowFlag = typeof fsConstants2.O_NOFOLLOW === "number" ? fsConstants2.O_NOFOLLOW : 0;
22751
+ const handle = await open3(
22752
+ filePath,
22753
+ fsConstants2.O_WRONLY | fsConstants2.O_CREAT | fsConstants2.O_EXCL | noFollowFlag,
22754
+ 384
22755
+ );
22756
+ try {
22757
+ await handle.writeFile(payload, "utf8");
22758
+ } finally {
22759
+ await handle.close();
22760
+ }
22761
+ let pointerLease;
22762
+ try {
22763
+ pointerLease = await acquireRunWriterLease(admitted.runDirectory);
22764
+ } catch (error2) {
22765
+ if (error2 instanceof RunWriterLeaseHeldError) return { file: filePath };
22766
+ throw error2;
22767
+ }
22768
+ let pointerError;
22769
+ try {
22770
+ const text = await readFile10(admitted.sessionFile, "utf8");
22771
+ let parentId = null;
22772
+ for (const line2 of text.trim().split("\n").filter(Boolean)) {
22773
+ const entry = JSON.parse(line2);
22774
+ if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
22775
+ }
22776
+ const timestamp2 = (/* @__PURE__ */ new Date()).toISOString();
22777
+ const pointerLine = `${JSON.stringify({
22778
+ type: "custom",
22779
+ customType: DISPATCH_ERROR_RETENTION_ENTRY_TYPE,
22780
+ data: { version: 1, attempt, file: filePath, recordedAt: timestamp2 },
22781
+ id: randomUUID2(),
22782
+ parentId,
22783
+ timestamp: timestamp2
22784
+ })}
22785
+ `;
22786
+ await appendFile2(admitted.sessionFile, pointerLine, "utf8");
22787
+ } catch (error2) {
22788
+ pointerError = error2;
22789
+ } finally {
22790
+ await pointerLease.release();
22791
+ }
22792
+ return pointerError === void 0 ? { file: filePath } : { file: filePath, pointerError };
22793
+ }
22794
+ function dispatchExceptionFailureTerminal(input) {
22795
+ const history = input.everyAttemptThrew ? "dispatch threw an exception on every attempt" : "the final dispatch threw an exception";
22796
+ const diagnostic = `${history} (${input.endReason}; resumes used ${input.autoResumeAttempts}); last cause: ${describeErrorIdentity(input.causeError)}`;
22797
+ const decisiveFacts = {
22798
+ cause: "unrecognized",
22799
+ diagnostic,
22800
+ resumesUsed: input.autoResumeAttempts,
22801
+ dispatchErrorFiles: [...input.errorFiles]
22802
+ };
22803
+ if (input.errorFiles.length > 0) {
22804
+ decisiveFacts.lastDispatchErrorFile = input.errorFiles[input.errorFiles.length - 1];
22805
+ }
22806
+ const candidate = input.causeError;
22807
+ if (typeof candidate?.name === "string") decisiveFacts.errorName = candidate.name;
22808
+ if (typeof candidate?.code === "string" || typeof candidate?.code === "number") {
22809
+ decisiveFacts.errorCode = candidate.code;
22810
+ }
22811
+ const artifacts = input.errorFiles.map((path) => ({
22812
+ kind: "error",
22813
+ path
22814
+ }));
22815
+ return {
22816
+ roleOutcome: {
22817
+ kind: "failure",
22818
+ role: input.role,
22819
+ cause: "unrecognized",
22820
+ diagnostic,
22821
+ decisiveFacts
22822
+ },
22823
+ navigator: { disposition: "no-advice" },
22824
+ artifacts,
22825
+ runId: input.runId,
22826
+ autoResumeCount: input.autoResumeAttempts
22827
+ };
22828
+ }
22637
22829
  async function runWithAutoResumeLoop(options) {
22638
22830
  const limit = options.autoResumeLimit ?? AUTO_RESUME_LIMIT;
22639
22831
  parseAutoResumeLimit(limit);
22640
22832
  let autoResumeAttempts = 0;
22641
22833
  let isFirst = true;
22642
22834
  let currentExtraArgs = options.buildInitialArgs();
22835
+ let dispatchOrdinal = 0;
22836
+ let lastThrownError;
22837
+ let everyAttemptThrew = true;
22838
+ const retainedErrorFiles = [];
22643
22839
  while (true) {
22644
22840
  let lease;
22645
22841
  try {
@@ -22654,32 +22850,96 @@ async function runWithAutoResumeLoop(options) {
22654
22850
  }
22655
22851
  throw error;
22656
22852
  }
22657
- const result2 = await options.dispatch(currentExtraArgs, lease, isFirst, dummyIo);
22658
- const terminal = result2.terminal;
22659
- if (terminal !== void 0) {
22660
- terminal.autoResumeCount = autoResumeAttempts;
22853
+ let result2;
22854
+ try {
22855
+ result2 = await options.dispatch(currentExtraArgs, lease, isFirst, dummyIo);
22856
+ } catch (error) {
22857
+ lastThrownError = error;
22858
+ const attempt = dispatchOrdinal;
22859
+ try {
22860
+ const { file, pointerError } = await retainDispatchError(options.admitted, attempt, error);
22861
+ retainedErrorFiles.push(file);
22862
+ options.io.stderr(
22863
+ `dispatch attempt ${attempt} threw (${describeErrorIdentity(error)}); full error retained at ${file}
22864
+ `
22865
+ );
22866
+ if (pointerError !== void 0) {
22867
+ options.io.stderr(
22868
+ `dispatch error retention failed (best-effort continue): ${describeErrorIdentity(pointerError)}
22869
+ `
22870
+ );
22871
+ }
22872
+ } catch (retentionError) {
22873
+ options.io.stderr(
22874
+ `dispatch error retention failed (best-effort continue): ${describeErrorIdentity(retentionError)}
22875
+ `
22876
+ );
22877
+ }
22661
22878
  }
22662
- const lawful = terminal !== void 0 && isLawfulTypedTerminalOutcome(terminal.roleOutcome);
22663
- if (lawful) {
22879
+ dispatchOrdinal += 1;
22880
+ if (result2 !== void 0) {
22881
+ everyAttemptThrew = false;
22882
+ const terminal = result2.terminal;
22664
22883
  if (terminal !== void 0) {
22665
- options.io.stdout(formatTerminalResult(terminal));
22884
+ terminal.autoResumeCount = autoResumeAttempts;
22885
+ }
22886
+ const lawful = terminal !== void 0 && isLawfulTypedTerminalOutcome(terminal.roleOutcome);
22887
+ if (lawful) {
22888
+ if (terminal !== void 0) {
22889
+ options.io.stdout(formatTerminalResult(terminal));
22890
+ }
22891
+ return result2;
22892
+ }
22893
+ if (autoResumeAttempts >= limit) {
22894
+ if (terminal !== void 0) presentTerminal(terminal, options.io);
22895
+ return result2;
22896
+ }
22897
+ if (!await isSessionPrincipalAvailable(options.admitted.sessionFile)) {
22898
+ if (terminal !== void 0) presentTerminal(terminal, options.io);
22899
+ return result2;
22900
+ }
22901
+ } else {
22902
+ if (autoResumeAttempts >= limit) {
22903
+ const terminal = dispatchExceptionFailureTerminal({
22904
+ role: options.admitted.role,
22905
+ runId: options.admitted.runId,
22906
+ causeError: lastThrownError,
22907
+ errorFiles: retainedErrorFiles,
22908
+ autoResumeAttempts,
22909
+ endReason: "auto-resume budget exhausted",
22910
+ everyAttemptThrew
22911
+ });
22912
+ await finalizeExceptionRunBestEffort(options.admitted.runDirectory, options.io);
22913
+ presentTerminal(terminal, options.io);
22914
+ return {
22915
+ exitCode: 1,
22916
+ terminal
22917
+ };
22918
+ }
22919
+ if (!await isSessionPrincipalAvailable(options.admitted.sessionFile)) {
22920
+ const terminal = dispatchExceptionFailureTerminal({
22921
+ role: options.admitted.role,
22922
+ runId: options.admitted.runId,
22923
+ causeError: lastThrownError,
22924
+ errorFiles: retainedErrorFiles,
22925
+ autoResumeAttempts,
22926
+ endReason: "session principal unavailable before further resume",
22927
+ everyAttemptThrew
22928
+ });
22929
+ await finalizeExceptionRunBestEffort(options.admitted.runDirectory, options.io);
22930
+ presentTerminal(terminal, options.io);
22931
+ return {
22932
+ exitCode: 1,
22933
+ terminal
22934
+ };
22666
22935
  }
22667
- return result2;
22668
- }
22669
- if (autoResumeAttempts >= limit) {
22670
- if (terminal !== void 0) presentTerminal(terminal, options.io);
22671
- return result2;
22672
- }
22673
- if (!await isSessionPrincipalAvailable(options.admitted.sessionFile)) {
22674
- if (terminal !== void 0) presentTerminal(terminal, options.io);
22675
- return result2;
22676
22936
  }
22677
22937
  autoResumeAttempts++;
22678
22938
  currentExtraArgs = options.buildResumeArgs();
22679
22939
  isFirst = false;
22680
22940
  }
22681
22941
  }
22682
- var dummyIo;
22942
+ var dummyIo, DISPATCH_ERROR_RETENTION_ENTRY_TYPE;
22683
22943
  var init_auto_resume = __esm({
22684
22944
  "src/public-cli/auto-resume.ts"() {
22685
22945
  "use strict";
@@ -22690,12 +22950,13 @@ var init_auto_resume = __esm({
22690
22950
  dummyIo = { stdout: () => {
22691
22951
  }, stderr: () => {
22692
22952
  } };
22953
+ DISPATCH_ERROR_RETENTION_ENTRY_TYPE = "ak_run_dispatch_error_retention";
22693
22954
  }
22694
22955
  });
22695
22956
 
22696
22957
  // src/public-cli/coder-run.ts
22697
22958
  import { writeFile as writeFile6 } from "node:fs/promises";
22698
- import { join as join12 } from "node:path";
22959
+ import { join as join13 } from "node:path";
22699
22960
  function buildCoderActivationExtraArgs(admitted, options) {
22700
22961
  const prompt = buildCoderTransportPrompt(
22701
22962
  admitted,
@@ -22857,7 +23118,7 @@ async function dispatchAdmittedCoder(input) {
22857
23118
  }
22858
23119
  try {
22859
23120
  await writeFile6(
22860
- join12(admitted.runDirectory, "stderr.log"),
23121
+ join13(admitted.runDirectory, "stderr.log"),
22861
23122
  result2.stderr,
22862
23123
  "utf8"
22863
23124
  );
@@ -23088,7 +23349,7 @@ var init_coder_run = __esm({
23088
23349
 
23089
23350
  // src/public-cli/collector-run.ts
23090
23351
  import { writeFile as writeFile7 } from "node:fs/promises";
23091
- import { join as join13 } from "node:path";
23352
+ import { join as join14 } from "node:path";
23092
23353
  function buildCollectorActivationExtraArgs(admitted, options = {}) {
23093
23354
  const prompt = buildCollectorTransportPrompt(
23094
23355
  admitted,
@@ -23193,7 +23454,7 @@ async function dispatchAdmittedCollector(input) {
23193
23454
  }
23194
23455
  try {
23195
23456
  await writeFile7(
23196
- join13(admitted.runDirectory, "stderr.log"),
23457
+ join14(admitted.runDirectory, "stderr.log"),
23197
23458
  result2.stderr,
23198
23459
  "utf8"
23199
23460
  );
@@ -23324,7 +23585,7 @@ var init_collector_run = __esm({
23324
23585
 
23325
23586
  // src/public-cli/doctor-run.ts
23326
23587
  import { writeFile as writeFile8 } from "node:fs/promises";
23327
- import { join as join14 } from "node:path";
23588
+ import { join as join15 } from "node:path";
23328
23589
  function buildDoctorActivationExtraArgs(admitted, options = {}) {
23329
23590
  const prompt = buildDoctorTransportPrompt(
23330
23591
  admitted,
@@ -23422,7 +23683,7 @@ async function dispatchAdmittedDoctor(input) {
23422
23683
  }
23423
23684
  try {
23424
23685
  await writeFile8(
23425
- join14(admitted.runDirectory, "stderr.log"),
23686
+ join15(admitted.runDirectory, "stderr.log"),
23426
23687
  result2.stderr,
23427
23688
  "utf8"
23428
23689
  );
@@ -23556,7 +23817,7 @@ var init_doctor_run = __esm({
23556
23817
 
23557
23818
  // src/public-cli/fixer-run.ts
23558
23819
  import { writeFile as writeFile9 } from "node:fs/promises";
23559
- import { join as join15 } from "node:path";
23820
+ import { join as join16 } from "node:path";
23560
23821
  function buildFixerActivationExtraArgs(admitted, options) {
23561
23822
  const prompt = buildFixerTransportPrompt(
23562
23823
  admitted,
@@ -23727,7 +23988,7 @@ async function dispatchAdmittedFixer(input) {
23727
23988
  }
23728
23989
  try {
23729
23990
  await writeFile9(
23730
- join15(admitted.runDirectory, "stderr.log"),
23991
+ join16(admitted.runDirectory, "stderr.log"),
23731
23992
  result2.stderr,
23732
23993
  "utf8"
23733
23994
  );
@@ -23965,7 +24226,7 @@ var init_fixer_run = __esm({
23965
24226
 
23966
24227
  // src/public-cli/judge-run.ts
23967
24228
  import { writeFile as writeFile10 } from "node:fs/promises";
23968
- import { join as join16 } from "node:path";
24229
+ import { join as join17 } from "node:path";
23969
24230
  function buildJudgeActivationExtraArgs(admitted, options = {}) {
23970
24231
  const prompt = buildJudgeTransportPrompt(
23971
24232
  admitted,
@@ -24113,7 +24374,7 @@ async function dispatchAdmittedJudge(input) {
24113
24374
  }
24114
24375
  try {
24115
24376
  await writeFile10(
24116
- join16(admitted.runDirectory, "stderr.log"),
24377
+ join17(admitted.runDirectory, "stderr.log"),
24117
24378
  result2.stderr,
24118
24379
  "utf8"
24119
24380
  );
@@ -24311,8 +24572,8 @@ var init_judge_run = __esm({
24311
24572
  });
24312
24573
 
24313
24574
  // src/public-cli/merger-run.ts
24314
- import { mkdir as mkdir4, writeFile as writeFile11 } from "node:fs/promises";
24315
- import { join as join17, resolve as resolve6 } from "node:path";
24575
+ import { mkdir as mkdir5, writeFile as writeFile11 } from "node:fs/promises";
24576
+ import { join as join18, resolve as resolve6 } from "node:path";
24316
24577
  function buildMergerActivationExtraArgs(admitted, options) {
24317
24578
  const prompt = buildMergerTransportPrompt(
24318
24579
  admitted,
@@ -24472,7 +24733,7 @@ async function dispatchAdmittedMerger(input) {
24472
24733
  }
24473
24734
  try {
24474
24735
  await writeFile11(
24475
- join17(admitted.runDirectory, "stderr.log"),
24736
+ join18(admitted.runDirectory, "stderr.log"),
24476
24737
  result2.stderr,
24477
24738
  "utf8"
24478
24739
  );
@@ -24545,7 +24806,7 @@ async function admitMergerShellForActivationFailure(options) {
24545
24806
  const runId = (options.createRunId ?? uuidv7)();
24546
24807
  const { ledgerHome, bookKey, runDirectory, sessionDirectory, sessionFile } = roleRunSessionCoordinates({ cwd: projectRoot, runId, role: "merger", home: options.home });
24547
24808
  ensureRealDirectoryTree(ledgerHome, sessionDirectory);
24548
- await mkdir4(runDirectory, { recursive: true });
24809
+ await mkdir5(runDirectory, { recursive: true });
24549
24810
  const emptyDerived = {
24550
24811
  targetObjectId: "",
24551
24812
  sourceObjectId: "",
@@ -24553,8 +24814,8 @@ async function admitMergerShellForActivationFailure(options) {
24553
24814
  expectedConflictPaths: [],
24554
24815
  resolutionScope: []
24555
24816
  };
24556
- const admittedRequestPath = join17(runDirectory, "admitted-request.json");
24557
- const mergerInputPath = join17(runDirectory, "merger-input.json");
24817
+ const admittedRequestPath = join18(runDirectory, "admitted-request.json");
24818
+ const mergerInputPath = join18(runDirectory, "merger-input.json");
24558
24819
  await writeFile11(
24559
24820
  admittedRequestPath,
24560
24821
  `${JSON.stringify(
@@ -24784,7 +25045,7 @@ var init_merger_run = __esm({
24784
25045
 
24785
25046
  // src/public-cli/reviewer-run.ts
24786
25047
  import { writeFile as writeFile12 } from "node:fs/promises";
24787
- import { join as join18 } from "node:path";
25048
+ import { join as join19 } from "node:path";
24788
25049
  function buildReviewerTicketNumberArgs(ticketNumber) {
24789
25050
  return ticketNumber === void 0 ? [] : ["--ak-review-ticket-number", String(ticketNumber)];
24790
25051
  }
@@ -24953,7 +25214,7 @@ async function dispatchAdmittedReviewer(input) {
24953
25214
  }
24954
25215
  try {
24955
25216
  await writeFile12(
24956
- join18(admitted.runDirectory, "stderr.log"),
25217
+ join19(admitted.runDirectory, "stderr.log"),
24957
25218
  result2.stderr,
24958
25219
  "utf8"
24959
25220
  );
@@ -25197,12 +25458,12 @@ var init_reviewer_run = __esm({
25197
25458
  });
25198
25459
 
25199
25460
  // src/atomic-write.ts
25200
- import { randomUUID as randomUUID2 } from "node:crypto";
25461
+ import { randomUUID as randomUUID3 } from "node:crypto";
25201
25462
  import { rename, rm, writeFile as writeFile13 } from "node:fs/promises";
25202
- import { dirname as dirname7, join as join19 } from "node:path";
25463
+ import { dirname as dirname7, join as join20 } from "node:path";
25203
25464
  async function writeFileAtomically(destination, contents) {
25204
25465
  const parent = dirname7(destination);
25205
- const temporary = join19(parent, `.atomic-write-${randomUUID2()}.tmp`);
25466
+ const temporary = join20(parent, `.atomic-write-${randomUUID3()}.tmp`);
25206
25467
  try {
25207
25468
  await writeFile13(temporary, contents);
25208
25469
  await rename(temporary, destination);
@@ -25218,8 +25479,8 @@ var init_atomic_write = __esm({
25218
25479
  });
25219
25480
 
25220
25481
  // src/taishi-index.ts
25221
- import { open as open3, readFile as readFile10, unlink as unlink4 } from "node:fs/promises";
25222
- import { dirname as dirname8, join as join20 } from "node:path";
25482
+ import { open as open4, readFile as readFile11, unlink as unlink4 } from "node:fs/promises";
25483
+ import { dirname as dirname8, join as join21 } from "node:path";
25223
25484
  function sleep(ms) {
25224
25485
  return new Promise((resolve8) => {
25225
25486
  setTimeout(resolve8, ms);
@@ -25228,12 +25489,12 @@ function sleep(ms) {
25228
25489
  async function withTaishiLibraryIndexLock(ledgerHome, fn) {
25229
25490
  const indexPath = taishiLibraryIndexPath(ledgerHome);
25230
25491
  ensureRealDirectoryTree(ledgerHome, dirname8(indexPath));
25231
- const lockPath = join20(dirname8(indexPath), LIBRARY_INDEX_LOCK_NAME);
25492
+ const lockPath = join21(dirname8(indexPath), LIBRARY_INDEX_LOCK_NAME);
25232
25493
  assertLedgerFileInsideHome(lockPath, ledgerHome);
25233
25494
  const startedAt = Date.now();
25234
25495
  while (true) {
25235
25496
  try {
25236
- const handle = await open3(lockPath, "wx");
25497
+ const handle = await open4(lockPath, "wx");
25237
25498
  try {
25238
25499
  await handle.writeFile(`${process.pid}
25239
25500
  `, "utf8");
@@ -25255,7 +25516,7 @@ async function withTaishiLibraryIndexLock(ledgerHome, fn) {
25255
25516
  }
25256
25517
  }
25257
25518
  function taishiLibraryIndexPath(ledgerHome) {
25258
- return join20(ledgerHome, "taishi", "library-index.json");
25519
+ return join21(ledgerHome, "taishi", "library-index.json");
25259
25520
  }
25260
25521
  function rowFromIssueMetricsPage(page) {
25261
25522
  return {
@@ -25334,7 +25595,7 @@ async function readTaishiLibraryIndexPage(ledgerHome) {
25334
25595
  const path = taishiLibraryIndexPath(ledgerHome);
25335
25596
  let raw;
25336
25597
  try {
25337
- raw = await readFile10(path, "utf8");
25598
+ raw = await readFile11(path, "utf8");
25338
25599
  } catch (error) {
25339
25600
  if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
25340
25601
  return void 0;
@@ -25495,12 +25756,12 @@ var init_taishi_cohort = __esm({
25495
25756
  });
25496
25757
 
25497
25758
  // src/ledger-session-read.ts
25498
- import { readFile as readFile11 } from "node:fs/promises";
25759
+ import { readFile as readFile12 } from "node:fs/promises";
25499
25760
  function isRecord6(value) {
25500
25761
  return typeof value === "object" && value !== null && !Array.isArray(value);
25501
25762
  }
25502
25763
  async function readLedgerSessionJsonl(path) {
25503
- const text = await readFile11(path, "utf8");
25764
+ const text = await readFile12(path, "utf8");
25504
25765
  const lines = text.split("\n");
25505
25766
  const rows = [];
25506
25767
  for (let index = 0; index < lines.length; index += 1) {
@@ -25610,8 +25871,8 @@ function extractSessionToolIntervals(rows) {
25610
25871
  if (resultTimestamp === void 0 || resultTimestamp.length === 0) {
25611
25872
  throw new Error(`toolResult ${message.toolCallId} missing timestamp`);
25612
25873
  }
25613
- const open4 = openById.get(message.toolCallId);
25614
- if (open4 === void 0) {
25874
+ const open5 = openById.get(message.toolCallId);
25875
+ if (open5 === void 0) {
25615
25876
  const toolName = typeof message.toolName === "string" && message.toolName.length > 0 ? message.toolName : "unknown";
25616
25877
  order.push({
25617
25878
  toolCallId: message.toolCallId,
@@ -25621,10 +25882,10 @@ function extractSessionToolIntervals(rows) {
25621
25882
  });
25622
25883
  continue;
25623
25884
  }
25624
- if (open4.endedAt !== void 0) {
25885
+ if (open5.endedAt !== void 0) {
25625
25886
  throw new Error(`duplicate toolResult for toolCallId ${message.toolCallId}`);
25626
25887
  }
25627
- open4.endedAt = resultTimestamp;
25888
+ open5.endedAt = resultTimestamp;
25628
25889
  }
25629
25890
  }
25630
25891
  return order.map((interval) => {
@@ -25657,9 +25918,9 @@ var init_ledger_session_read = __esm({
25657
25918
  });
25658
25919
 
25659
25920
  // src/run-terminal-artifacts.ts
25660
- import { readdir as readdir4, readFile as readFile12 } from "node:fs/promises";
25661
- import { basename as basename4, dirname as dirname9, join as join21 } from "node:path";
25662
- function isMissingPathError3(error) {
25921
+ import { readdir as readdir4, readFile as readFile13 } from "node:fs/promises";
25922
+ import { basename as basename4, dirname as dirname9, join as join22 } from "node:path";
25923
+ function isMissingPathError4(error) {
25663
25924
  return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
25664
25925
  }
25665
25926
  function errorText2(error) {
@@ -25689,9 +25950,9 @@ function readUsableTerminalArtifactBody(body) {
25689
25950
  async function readTerminalArtifactAtPath(path, file) {
25690
25951
  let raw;
25691
25952
  try {
25692
- raw = await readFile12(path, "utf8");
25953
+ raw = await readFile13(path, "utf8");
25693
25954
  } catch (error) {
25694
- if (isMissingPathError3(error)) return void 0;
25955
+ if (isMissingPathError4(error)) return void 0;
25695
25956
  return {
25696
25957
  status: "unreadable",
25697
25958
  file,
@@ -25728,12 +25989,12 @@ async function listUniqueErrorFallbackPaths(directories) {
25728
25989
  try {
25729
25990
  names = await readdir4(dir);
25730
25991
  } catch (error) {
25731
- if (isMissingPathError3(error)) continue;
25992
+ if (isMissingPathError4(error)) continue;
25732
25993
  throw error;
25733
25994
  }
25734
25995
  for (const name of names.sort((a, b) => a.localeCompare(b))) {
25735
25996
  if (!UNIQUE_ERROR_FALLBACK_NAME.test(name)) continue;
25736
- found.push(join21(dir, name));
25997
+ found.push(join22(dir, name));
25737
25998
  }
25738
25999
  }
25739
26000
  return found;
@@ -25749,14 +26010,14 @@ function presentUniqueFallbackBoundToRun(body, expectedRunId) {
25749
26010
  return typeof body.runId === "string" && body.runId === expectedRunId;
25750
26011
  }
25751
26012
  async function readRunTerminalArtifact(runDirectory) {
25752
- const artifactsDir = join21(runDirectory, "artifacts");
26013
+ const artifactsDir = join22(runDirectory, "artifacts");
25753
26014
  for (const file of RUN_TERMINAL_ARTIFACT_FILES) {
25754
- const path = join21(artifactsDir, file);
26015
+ const path = join22(artifactsDir, file);
25755
26016
  const read3 = await readTerminalArtifactAtPath(path, file);
25756
26017
  if (read3 !== void 0) return read3;
25757
26018
  }
25758
26019
  for (const relative3 of RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS) {
25759
- const path = join21(runDirectory, relative3);
26020
+ const path = join22(runDirectory, relative3);
25760
26021
  const read3 = await readTerminalArtifactAtPath(path, "error.json");
25761
26022
  if (read3 !== void 0) return read3;
25762
26023
  }
@@ -25793,9 +26054,9 @@ var init_run_terminal_artifacts = __esm({
25793
26054
  });
25794
26055
 
25795
26056
  // src/taishi-ledger.ts
25796
- import { readdir as readdir5, readFile as readFile13 } from "node:fs/promises";
25797
- import { join as join22 } from "node:path";
25798
- function isMissingPathError4(error) {
26057
+ import { readdir as readdir5, readFile as readFile14 } from "node:fs/promises";
26058
+ import { join as join23 } from "node:path";
26059
+ function isMissingPathError5(error) {
25799
26060
  return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
25800
26061
  }
25801
26062
  function errorText3(error) {
@@ -25807,7 +26068,7 @@ function isRecord8(value) {
25807
26068
  async function readExistingRunLifecycleState(runDirectory) {
25808
26069
  try {
25809
26070
  const raw = JSON.parse(
25810
- await readFile13(join22(runDirectory, "run-state.json"), "utf8")
26071
+ await readFile14(join23(runDirectory, "run-state.json"), "utf8")
25811
26072
  );
25812
26073
  if (!isRecord8(raw) || typeof raw.state !== "string") return void 0;
25813
26074
  return raw.state;
@@ -25832,16 +26093,16 @@ async function listLedgerBookNames(booksRoot) {
25832
26093
  const entries = await readdir5(booksRoot, { withFileTypes: true });
25833
26094
  return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
25834
26095
  } catch (error) {
25835
- if (isMissingPathError4(error)) return [];
26096
+ if (isMissingPathError5(error)) return [];
25836
26097
  throw error;
25837
26098
  }
25838
26099
  }
25839
26100
  async function readInvocationScopeFields(runDirectory) {
25840
26101
  let raw;
25841
26102
  try {
25842
- raw = await readFile13(join22(runDirectory, "invocation.json"), "utf8");
26103
+ raw = await readFile14(join23(runDirectory, "invocation.json"), "utf8");
25843
26104
  } catch (error) {
25844
- if (isMissingPathError4(error)) return void 0;
26105
+ if (isMissingPathError5(error)) return void 0;
25845
26106
  throw error;
25846
26107
  }
25847
26108
  const parsed = JSON.parse(raw);
@@ -25871,15 +26132,15 @@ function decideIssueScope(input) {
25871
26132
  }
25872
26133
  async function resolveSessionFile(runDirectory) {
25873
26134
  try {
25874
- const raw = await readFile13(join22(runDirectory, "invocation.json"), "utf8");
26135
+ const raw = await readFile14(join23(runDirectory, "invocation.json"), "utf8");
25875
26136
  const parsed = JSON.parse(raw);
25876
26137
  if (isRecord8(parsed) && typeof parsed.sessionFile === "string" && parsed.sessionFile.trim() !== "") {
25877
26138
  return parsed.sessionFile;
25878
26139
  }
25879
26140
  } catch (error) {
25880
- if (!isMissingPathError4(error)) throw error;
26141
+ if (!isMissingPathError5(error)) throw error;
25881
26142
  }
25882
- return join22(runDirectory, "session", "session.jsonl");
26143
+ return join23(runDirectory, "session", "session.jsonl");
25883
26144
  }
25884
26145
  async function classifyScopedRun(input) {
25885
26146
  const missingSources = [];
@@ -26003,7 +26264,7 @@ async function classifyScopedRun(input) {
26003
26264
  async function scanTaishiIssueRuns(input) {
26004
26265
  const ledgerHome = resolveActivationLedgerHome();
26005
26266
  const scopeTicketNumber = input.ticketNumber;
26006
- const booksRoot = join22(ledgerHome, "books");
26267
+ const booksRoot = join23(ledgerHome, "books");
26007
26268
  let wholeBook = false;
26008
26269
  let scopeRootIdentity;
26009
26270
  let bookNames;
@@ -26030,19 +26291,19 @@ async function scanTaishiIssueRuns(input) {
26030
26291
  const unreadable = [];
26031
26292
  const scopeConflicts = [];
26032
26293
  for (const book of bookNames) {
26033
- const runsDir = join22(booksRoot, book, "runs");
26294
+ const runsDir = join23(booksRoot, book, "runs");
26034
26295
  let runNames;
26035
26296
  try {
26036
26297
  const entries = await readdir5(runsDir, { withFileTypes: true });
26037
26298
  runNames = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
26038
26299
  } catch (error) {
26039
- if (isMissingPathError4(error)) continue;
26300
+ if (isMissingPathError5(error)) continue;
26040
26301
  throw error;
26041
26302
  }
26042
26303
  for (const runName of runNames) {
26043
26304
  const parsed = parseRunDirectoryName(runName);
26044
26305
  if (parsed === void 0) continue;
26045
- const runDirectory = join22(runsDir, runName);
26306
+ const runDirectory = join23(runsDir, runName);
26046
26307
  let scopeFields;
26047
26308
  try {
26048
26309
  scopeFields = await readInvocationScopeFields(runDirectory);
@@ -26817,7 +27078,7 @@ var init_taishi_metric_family = __esm({
26817
27078
 
26818
27079
  // src/taishi-page.ts
26819
27080
  import { createHash as createHash4 } from "node:crypto";
26820
- import { dirname as dirname10, join as join23 } from "node:path";
27081
+ import { dirname as dirname10, join as join24 } from "node:path";
26821
27082
  function taishiIssuePageKey(address) {
26822
27083
  const parts = ["book", address.bookKey];
26823
27084
  if (address.issueNumber !== void 0) {
@@ -26828,7 +27089,7 @@ function taishiIssuePageKey(address) {
26828
27089
  return createHash4("sha256").update(parts.join("\0")).digest("hex").slice(0, 32);
26829
27090
  }
26830
27091
  function taishiIssuePagePath(ledgerHome, address) {
26831
- return join23(ledgerHome, "taishi", "issues", `${taishiIssuePageKey(address)}.json`);
27092
+ return join24(ledgerHome, "taishi", "issues", `${taishiIssuePageKey(address)}.json`);
26832
27093
  }
26833
27094
  function taishiIssuePageAddressFromPage(page) {
26834
27095
  return {
@@ -26964,8 +27225,8 @@ var init_taishi_page = __esm({
26964
27225
  });
26965
27226
 
26966
27227
  // src/taishi-entry.ts
26967
- import { readFile as readFile14 } from "node:fs/promises";
26968
- function isMissingPathError5(error) {
27228
+ import { readFile as readFile15 } from "node:fs/promises";
27229
+ function isMissingPathError6(error) {
26969
27230
  return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
26970
27231
  }
26971
27232
  function cachedPageMatchesRequestedScope(page, input) {
@@ -27003,13 +27264,13 @@ async function readOrComputeTaishiIssuePage(input) {
27003
27264
  ...issueNumber === void 0 && input.bookKey === void 0 ? { scopeRootIdentity: projectRoot } : {}
27004
27265
  });
27005
27266
  try {
27006
- const raw = await readFile14(pagePath, "utf8");
27267
+ const raw = await readFile15(pagePath, "utf8");
27007
27268
  const page = JSON.parse(raw);
27008
27269
  if (cachedPageMatchesRequestedScope(page, { bookKey, ...input })) {
27009
27270
  return { mode: "issue", page, pagePath };
27010
27271
  }
27011
27272
  } catch (error) {
27012
- if (!isMissingPathError5(error)) {
27273
+ if (!isMissingPathError6(error)) {
27013
27274
  throw new TaishiIssueComputeError({
27014
27275
  bookKey,
27015
27276
  projectRoot,
@@ -27096,10 +27357,10 @@ async function runTaishiModelGroupsMode(input) {
27096
27357
  scopeRootIdentity: projectRoot
27097
27358
  });
27098
27359
  try {
27099
- const raw = await readFile14(pagePath, "utf8");
27360
+ const raw = await readFile15(pagePath, "utf8");
27100
27361
  JSON.parse(raw);
27101
27362
  } catch (error) {
27102
- if (!isMissingPathError5(error)) {
27363
+ if (!isMissingPathError6(error)) {
27103
27364
  throw new TaishiIssueComputeError({ bookKey, projectRoot, cause: error });
27104
27365
  }
27105
27366
  try {
@@ -27194,7 +27455,7 @@ var init_taishi_entry = __esm({
27194
27455
  });
27195
27456
 
27196
27457
  // src/public-cli/taishi-run.ts
27197
- import { readFile as readFile15 } from "node:fs/promises";
27458
+ import { readFile as readFile16 } from "node:fs/promises";
27198
27459
  import { isAbsolute as isAbsolute5, resolve as resolve7 } from "node:path";
27199
27460
  function resolveTaishiIssueBookKeyFromCwd(cwd = process.cwd()) {
27200
27461
  try {
@@ -27247,7 +27508,7 @@ async function buildTaishiSweepModeInputFromAttachmentPaths(attachmentPaths) {
27247
27508
  const absolute = isAbsolute5(sourcePath) ? sourcePath : resolve7(sourcePath);
27248
27509
  let bytes;
27249
27510
  try {
27250
- bytes = await readFile15(absolute);
27511
+ bytes = await readFile16(absolute);
27251
27512
  } catch (error) {
27252
27513
  throw new CliUsageError(
27253
27514
  `taishi sweep attachment is not a readable regular file: ${sourcePath}`,
@@ -27349,7 +27610,7 @@ __export(cli_exports, {
27349
27610
  });
27350
27611
  import { realpath as realpath5 } from "node:fs/promises";
27351
27612
  import { homedir as homedir3 } from "node:os";
27352
- import { join as join24 } from "node:path";
27613
+ import { join as join25 } from "node:path";
27353
27614
  function takePublicGlobalFlag(argv, index, options) {
27354
27615
  const tokens = argv.slice(index);
27355
27616
  const taken = options.takeDashed(tokens);
@@ -27395,7 +27656,7 @@ function resolveHome(env) {
27395
27656
  return env.home ?? process.env.HOME ?? homedir3();
27396
27657
  }
27397
27658
  function resolveAgentDir(env, home) {
27398
- return env.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? join24(home, ".pi", "agent");
27659
+ return env.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? join25(home, ".pi", "agent");
27399
27660
  }
27400
27661
  function parseThinking(value) {
27401
27662
  if (!THINKING_LEVELS2.has(value)) {
@@ -28265,7 +28526,7 @@ var init_cli = __esm({
28265
28526
 
28266
28527
  // src/public-cli/main.ts
28267
28528
  import { existsSync as existsSync3 } from "node:fs";
28268
- import { dirname as dirname11, join as join25 } from "node:path";
28529
+ import { dirname as dirname11, join as join26 } from "node:path";
28269
28530
  import { fileURLToPath as fileURLToPath2 } from "node:url";
28270
28531
 
28271
28532
  // src/public-cli/host-pi-runtime.ts
@@ -28354,8 +28615,8 @@ function linkPackage(packageRoot2, name, targetDir) {
28354
28615
  // src/public-cli/main.ts
28355
28616
  var here = dirname11(fileURLToPath2(import.meta.url));
28356
28617
  function resolvePackageRoot(binDir) {
28357
- const canonical = join25(binDir, "..", "..");
28358
- if (existsSync3(join25(canonical, "package.json"))) {
28618
+ const canonical = join26(binDir, "..", "..");
28619
+ if (existsSync3(join26(canonical, "package.json"))) {
28359
28620
  return canonical;
28360
28621
  }
28361
28622
  return binDir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.2177",
3
+ "version": "0.1.2193",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -41,7 +41,7 @@
41
41
  "scripts": {
42
42
  "build": "tsx scripts/generate-tool-execution-observation-schema.ts && tsc -p tsconfig.build.json && node scripts/build-package.mjs",
43
43
  "prepack": "npm run build",
44
- "test": "pnpm run test:all",
44
+ "test": "node --import tsx --test test/unit/**/*.test.ts test/contract/**/*.test.ts",
45
45
  "test:fast": "node --import tsx --test test/unit/**/*.test.ts test/contract/**/*.test.ts",
46
46
  "test:integration": "node --import tsx --test test/unit/**/*.test.ts test/contract/**/*.test.ts test/integration/**/*.test.ts",
47
47
  "test:all": "node scripts/run-test-all.mjs",
@@ -4,10 +4,29 @@
4
4
  * per call by the caller, #422 — never re-read from disk inside the loop),
5
5
  * in-place (same runId/session).
6
6
  * Unifies presentation: intermediate attempts use dummyIo, only final Terminal is presented.
7
+ *
8
+ * Owner 2026-08-23: a dispatch that exits by throwing used to bypass the entire
9
+ * retry mechanism (the throw escaped the while-loop before the count check ever
10
+ * ran). Every exception is now retained whole, in place, and the ordinary retry
11
+ * path continues: same budget, same call-local count semantics. No failure-type
12
+ * classification — every thrown value is treated identically.
7
13
  */
8
- import { AUTO_RESUME_LIMIT, isSessionPrincipalAvailable, acquireRunWriterLease, RunWriterLeaseHeldError, type RunWriterLease } from "./run-lifecycle.ts";
14
+ import { constants as fsConstants } from "node:fs";
15
+ import { randomUUID } from "node:crypto";
16
+ import { appendFile, lstat, mkdir, open, readFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+
19
+ import {
20
+ AUTO_RESUME_LIMIT,
21
+ describeErrorIdentity,
22
+ isSessionPrincipalAvailable,
23
+ acquireRunWriterLease,
24
+ markRunTerminal,
25
+ RunWriterLeaseHeldError,
26
+ type RunWriterLease,
27
+ } from "./run-lifecycle.ts";
9
28
  import { parseAutoResumeLimit } from "./config.ts";
10
- import { isLawfulTypedTerminalOutcome, formatTerminalResult, type TerminalResult } from "./terminal.ts";
29
+ import { isLawfulTypedTerminalOutcome, formatTerminalResult, type TerminalArtifactRef, type TerminalResult, type TerminalRoleName } from "./terminal.ts";
11
30
  import { presentFailureTerminal, presentStructuralRejection } from "./settlement.ts";
12
31
  import type { CliIo } from "./cli-io.ts";
13
32
 
@@ -21,13 +40,305 @@ function presentTerminal(terminal: TerminalResult, io: CliIo): void {
21
40
  }
22
41
  }
23
42
 
43
+ /**
44
+ * Best-effort finalization of the durable run state before an exception-path
45
+ * synthetic failure terminal is returned (#426 review: taishi-ledger classifies
46
+ * running runs as live — an exhausted invocation must not remain live
47
+ * indefinitely). Finalization failure must not mask the real cause.
48
+ */
49
+ async function finalizeExceptionRunBestEffort(runDirectory: string, io: CliIo): Promise<void> {
50
+ try {
51
+ await markRunTerminal(runDirectory);
52
+ } catch (error) {
53
+ io.stderr(
54
+ `run terminal-state finalization failed (best-effort continue): ${describeErrorIdentity(error)}\n`,
55
+ );
56
+ }
57
+ }
58
+
24
59
  export type AutoResumeDispatchResult = {
25
60
  exitCode: number;
26
61
  terminal?: TerminalResult;
27
62
  };
28
63
 
64
+ /** Session custom-entry type carrying the pointer to one dispatch error file. */
65
+ export const DISPATCH_ERROR_RETENTION_ENTRY_TYPE = "ak_run_dispatch_error_retention" as const;
66
+
67
+ /** Artifacts subdirectory of a run directory (established run-artifacts location). */
68
+ function runArtifactsDirectory(runDirectory: string): string {
69
+ return join(runDirectory, "artifacts");
70
+ }
71
+
72
+ /**
73
+ * #182-A hardened path identity, mirrored from settlement.ts's
74
+ * ensureAuditEvidenceDirectory: a planted symlink at the run directory or the
75
+ * artifacts path must not receive the dispatch error dump (O_NOFOLLOW only
76
+ * protects the final file name; recursive mkdir would accept a symlinked
77
+ * parent). Fails loudly with the true cause instead.
78
+ */
79
+ async function ensureRealArtifactsDirectory(runDirectory: string): Promise<string> {
80
+ const runStat = await lstat(runDirectory);
81
+ if (runStat.isSymbolicLink() || !runStat.isDirectory()) {
82
+ throw new Error("dispatch error retention: run directory is not a real directory");
83
+ }
84
+ const artifactsDir = runArtifactsDirectory(runDirectory);
85
+ try {
86
+ const existing = await lstat(artifactsDir);
87
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
88
+ throw new Error("dispatch error retention: artifacts path is not a real directory");
89
+ }
90
+ } catch (error) {
91
+ if (!isMissingPathError(error)) throw error;
92
+ await mkdir(artifactsDir, { recursive: true });
93
+ const created = await lstat(artifactsDir);
94
+ if (created.isSymbolicLink() || !created.isDirectory()) {
95
+ throw new Error("dispatch error retention: artifacts directory is not a real directory");
96
+ }
97
+ }
98
+ return artifactsDir;
99
+ }
100
+
101
+ /**
102
+ * Whole-object transfer of a thrown value (owner 2026-08-23: 「记录所有错误信息。
103
+ * 不能丢详细情况」). Every own property of the Error object — enumerable or not,
104
+ * which is how message/stack and any attached identity land verbatim — plus the
105
+ * constructor name and the full cause chain. No field list is prescribed or
106
+ * filtered: whatever the exception object carries goes into the file as-is.
107
+ */
108
+ function serializeThrownValue(value: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
109
+ if (value instanceof Error) {
110
+ if (seen.has(value)) return "[circular]";
111
+ seen.add(value);
112
+ const transferred: Record<string, unknown> = {};
113
+ for (const key of Object.getOwnPropertyNames(value)) {
114
+ transferred[key] = transferNestedValue(
115
+ (value as unknown as Record<string, unknown>)[key],
116
+ depth + 1,
117
+ seen,
118
+ );
119
+ }
120
+ return {
121
+ errorKind: "Error",
122
+ constructorName: value.constructor?.name,
123
+ ...transferred,
124
+ ...(value.cause === undefined
125
+ ? {}
126
+ : {
127
+ causeChain:
128
+ depth >= 10
129
+ ? "[cause-chain-depth-limit]"
130
+ : serializeThrownValue(value.cause, depth + 1, seen),
131
+ }),
132
+ };
133
+ }
134
+ return value;
135
+ }
136
+
137
+ /** ENOENT identity shared with settlement.ts's hardened audit-artifact path. */
138
+ function isMissingPathError(error: unknown): boolean {
139
+ return (
140
+ error instanceof Error &&
141
+ "code" in error &&
142
+ (error as { code?: unknown }).code === "ENOENT"
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Recursive Error-property transfer (#426 review: nested Errors must not be
148
+ * passed raw to JSON.stringify — their non-enumerable message/stack would
149
+ * serialize as {}). Depth-limited; cycle-safe via the seen set so the recursive
150
+ * construction itself cannot diverge before stringify runs.
151
+ */
152
+ function transferNestedValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {
153
+ if (value instanceof Error) return serializeThrownValue(value, depth, seen);
154
+ if (depth >= 10) return "[nested-depth-limit]";
155
+ if (Array.isArray(value)) {
156
+ return value.map((item) => transferNestedValue(item, depth + 1, seen));
157
+ }
158
+ if (value !== null && typeof value === "object") {
159
+ if (seen.has(value)) return "[circular]";
160
+ seen.add(value);
161
+ const transferred: Record<string, unknown> = {};
162
+ for (const key of Object.getOwnPropertyNames(value)) {
163
+ transferred[key] = transferNestedValue(
164
+ (value as unknown as Record<string, unknown>)[key],
165
+ depth + 1,
166
+ seen,
167
+ );
168
+ }
169
+ return transferred;
170
+ }
171
+ return value;
172
+ }
173
+
174
+ /** Cycle- and bigint-safe JSON replacer so serialization itself cannot drop data. */
175
+ function jsonSafeReplacer(): (key: string, value: unknown) => unknown {
176
+ const seen = new WeakSet<object>();
177
+ return (_key: string, value: unknown): unknown => {
178
+ if (typeof value === "bigint") return `${value}n`;
179
+ if (typeof value === "object" && value !== null) {
180
+ if (seen.has(value)) return "[circular]";
181
+ seen.add(value);
182
+ }
183
+ return value;
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Retain one throwing dispatch attempt's complete exception as an independent
189
+ * per-attempt file under the run's artifacts directory, then leave an
190
+ * addressable pointer in the session principal (custom entry). Exclusive-create
191
+ * open (O_EXCL) with a per-attempt unique name enforces 史必追加 (#419): a later
192
+ * attempt can never overwrite an earlier attempt's file.
193
+ */
194
+ async function retainDispatchError(
195
+ admitted: { sessionFile: string; runDirectory: string },
196
+ attempt: number,
197
+ error: unknown,
198
+ ): Promise<{ file: string; pointerError?: unknown }> {
199
+ const artifactsDir = await ensureRealArtifactsDirectory(admitted.runDirectory);
200
+ const filePath = join(
201
+ artifactsDir,
202
+ `dispatch-error-attempt-${attempt}-${randomUUID()}.json`,
203
+ );
204
+ // Whole-object dump: everything the thrown value carries, nothing picked.
205
+ const payload = `${JSON.stringify(
206
+ {
207
+ version: 1,
208
+ attempt,
209
+ recordedAt: new Date().toISOString(),
210
+ error: serializeThrownValue(error),
211
+ },
212
+ jsonSafeReplacer(),
213
+ 2,
214
+ )}\n`;
215
+ // O_EXCL: exclusive create — the retention history is append-only by
216
+ // construction; a colliding name fails loudly instead of overwriting.
217
+ // O_NOFOLLOW when the platform provides it keeps a planted symlink from
218
+ // being followed; on platforms without it, exclusivity still holds.
219
+ const noFollowFlag =
220
+ typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
221
+ const handle = await open(
222
+ filePath,
223
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollowFlag,
224
+ 0o600,
225
+ );
226
+ try {
227
+ await handle.writeFile(payload, "utf8");
228
+ } finally {
229
+ await handle.close();
230
+ }
231
+ // Addressable pointer in the dossier (卷宗): reuses the session principal's
232
+ // appended custom-entry shape (mirrors pi SessionManager.appendCustomEntry,
233
+ // same mechanism as #419 attempt history — no second ledger introduced).
234
+ // The one-writer lease is held for the append (#426 review: production
235
+ // dispatchers release the lease in their finally before the rejection reaches
236
+ // this point); if a concurrent writer already holds it, the append is skipped
237
+ // gracefully — the error file itself is already durably retained.
238
+ let pointerLease: RunWriterLease;
239
+ try {
240
+ pointerLease = await acquireRunWriterLease(admitted.runDirectory);
241
+ } catch (error) {
242
+ if (error instanceof RunWriterLeaseHeldError) return { file: filePath };
243
+ throw error;
244
+ }
245
+ // Pointer-stage failure (#426 fix_now #5) is separated from the file write:
246
+ // once the error file is durably on disk, a failed readFile/JSON.parse/
247
+ // appendFile must not reject through here and orphan it — the file path is
248
+ // still handed back to the caller (retainedErrorFiles → terminal
249
+ // dispatchErrorFiles/artifacts), and the pointer failure is reported
250
+ // separately by the caller.
251
+ let pointerError: unknown;
252
+ try {
253
+ const text = await readFile(admitted.sessionFile, "utf8");
254
+ // Session headers are not branch entries (#419 precedent in
255
+ // appendRunAttemptHistory excludes type === "session"); leave parentId null
256
+ // until a non-header entry exists so the pointer stays addressable.
257
+ let parentId: string | null = null;
258
+ for (const line of text.trim().split("\n").filter(Boolean)) {
259
+ const entry = JSON.parse(line) as { id?: unknown; type?: unknown };
260
+ if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
261
+ }
262
+ const timestamp = new Date().toISOString();
263
+ const pointerLine = `${JSON.stringify({
264
+ type: "custom",
265
+ customType: DISPATCH_ERROR_RETENTION_ENTRY_TYPE,
266
+ data: { version: 1, attempt, file: filePath, recordedAt: timestamp },
267
+ id: randomUUID(),
268
+ parentId,
269
+ timestamp,
270
+ })}\n`;
271
+ await appendFile(admitted.sessionFile, pointerLine, "utf8");
272
+ } catch (error) {
273
+ pointerError = error;
274
+ } finally {
275
+ await pointerLease.release();
276
+ }
277
+ return pointerError === undefined ? { file: filePath } : { file: filePath, pointerError };
278
+ }
279
+
280
+ /**
281
+ * Typed failure terminal for a retry path that ended with only exceptions:
282
+ * loud, non-lawful, carrying the last true cause and the pointers to the
283
+ * full per-attempt error files. Never rethrows the raw exception at callers.
284
+ */
285
+ function dispatchExceptionFailureTerminal(input: {
286
+ role: TerminalRoleName;
287
+ runId: string;
288
+ causeError: unknown;
289
+ errorFiles: readonly string[];
290
+ autoResumeAttempts: number;
291
+ endReason: string;
292
+ /** True only when every attempt threw; otherwise describe just the final attempt. */
293
+ everyAttemptThrew: boolean;
294
+ }): TerminalResult {
295
+ // #426 review: this terminal fires whenever the FINAL dispatch throws, not
296
+ // only when every attempt threw — do not misrepresent a mixed retry history.
297
+ const history = input.everyAttemptThrew
298
+ ? "dispatch threw an exception on every attempt"
299
+ : "the final dispatch threw an exception";
300
+ const diagnostic = `${history} (${input.endReason}; resumes used ${input.autoResumeAttempts}); last cause: ${describeErrorIdentity(input.causeError)}`;
301
+ const decisiveFacts: Record<string, unknown> = {
302
+ cause: "unrecognized",
303
+ diagnostic,
304
+ resumesUsed: input.autoResumeAttempts,
305
+ dispatchErrorFiles: [...input.errorFiles],
306
+ };
307
+ if (input.errorFiles.length > 0) {
308
+ decisiveFacts.lastDispatchErrorFile = input.errorFiles[input.errorFiles.length - 1];
309
+ }
310
+ const candidate = input.causeError as { name?: unknown; code?: unknown };
311
+ if (typeof candidate?.name === "string") decisiveFacts.errorName = candidate.name;
312
+ if (typeof candidate?.code === "string" || typeof candidate?.code === "number") {
313
+ decisiveFacts.errorCode = candidate.code;
314
+ }
315
+ const artifacts: TerminalArtifactRef[] = input.errorFiles.map((path) => ({
316
+ kind: "error",
317
+ path,
318
+ }));
319
+ return {
320
+ roleOutcome: {
321
+ kind: "failure",
322
+ role: input.role,
323
+ cause: "unrecognized",
324
+ diagnostic,
325
+ decisiveFacts,
326
+ },
327
+ navigator: { disposition: "no-advice" },
328
+ artifacts,
329
+ runId: input.runId,
330
+ autoResumeCount: input.autoResumeAttempts,
331
+ };
332
+ }
333
+
29
334
  export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(options: {
30
- admitted: { sessionFile: string; runDirectory: string };
335
+ admitted: {
336
+ sessionFile: string;
337
+ runDirectory: string;
338
+ /** Identity for the loop-owned typed failure terminal (dispatch-exception exhaustion). */
339
+ role: TerminalRoleName;
340
+ runId: string;
341
+ };
31
342
  io: CliIo;
32
343
  /**
33
344
  * Effective ceiling (#422), resolved by the caller before the loop; never re-read
@@ -47,6 +358,10 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
47
358
  let autoResumeAttempts = 0;
48
359
  let isFirst = true;
49
360
  let currentExtraArgs = options.buildInitialArgs();
361
+ let dispatchOrdinal = 0;
362
+ let lastThrownError: unknown;
363
+ let everyAttemptThrew = true;
364
+ const retainedErrorFiles: string[] = [];
50
365
 
51
366
  while (true) {
52
367
  let lease: RunWriterLease;
@@ -62,29 +377,99 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
62
377
  throw error;
63
378
  }
64
379
 
65
- const result = await options.dispatch(currentExtraArgs, lease, isFirst, dummyIo);
66
-
67
- const terminal = (result as { terminal?: TerminalResult }).terminal;
68
- if (terminal !== undefined) {
69
- (terminal as { autoResumeCount?: number }).autoResumeCount = autoResumeAttempts;
380
+ let result: T | undefined;
381
+ try {
382
+ result = await options.dispatch(currentExtraArgs, lease, isFirst, dummyIo);
383
+ } catch (error) {
384
+ // Owner 2026-08-23: 「出了异常,就原地记录错误信息,然后重试。」
385
+ // Retain the whole exception in place (per-attempt full file + dossier
386
+ // pointer); recording failure must not break the retry path (PR #418
387
+ // diagnostic-sink-isolation precedent). The dispatcher owns lease release
388
+ // in its own finally, so the retry round starts with the lock free.
389
+ lastThrownError = error;
390
+ const attempt = dispatchOrdinal;
391
+ try {
392
+ // Track the file as soon as it is durably written (#426 review):
393
+ // a pointer-stage failure comes back separately (pointerError) and must
394
+ // never orphan the retained file (#426 fix_now #5).
395
+ const { file, pointerError } = await retainDispatchError(options.admitted, attempt, error);
396
+ retainedErrorFiles.push(file);
397
+ options.io.stderr(
398
+ `dispatch attempt ${attempt} threw (${describeErrorIdentity(error)}); full error retained at ${file}\n`,
399
+ );
400
+ if (pointerError !== undefined) {
401
+ options.io.stderr(
402
+ `dispatch error retention failed (best-effort continue): ${describeErrorIdentity(pointerError)}\n`,
403
+ );
404
+ }
405
+ } catch (retentionError) {
406
+ options.io.stderr(
407
+ `dispatch error retention failed (best-effort continue): ${describeErrorIdentity(retentionError)}\n`,
408
+ );
409
+ }
70
410
  }
411
+ dispatchOrdinal += 1;
71
412
 
72
- const lawful = terminal !== undefined && isLawfulTypedTerminalOutcome(terminal.roleOutcome);
73
- if (lawful) {
413
+ if (result !== undefined) {
414
+ everyAttemptThrew = false;
415
+ const terminal = (result as { terminal?: TerminalResult }).terminal;
74
416
  if (terminal !== undefined) {
75
- // Present lawful terminal once to real io (dummy was used inside dispatch)
76
- options.io.stdout(formatTerminalResult(terminal));
417
+ (terminal as { autoResumeCount?: number }).autoResumeCount = autoResumeAttempts;
77
418
  }
78
- return result;
79
- }
80
419
 
81
- if (autoResumeAttempts >= limit) {
82
- if (terminal !== undefined) presentTerminal(terminal, options.io);
83
- return result;
84
- }
85
- if (!(await isSessionPrincipalAvailable(options.admitted.sessionFile))) {
86
- if (terminal !== undefined) presentTerminal(terminal, options.io);
87
- return result;
420
+ const lawful = terminal !== undefined && isLawfulTypedTerminalOutcome(terminal.roleOutcome);
421
+ if (lawful) {
422
+ if (terminal !== undefined) {
423
+ // Present lawful terminal once to real io (dummy was used inside dispatch)
424
+ options.io.stdout(formatTerminalResult(terminal));
425
+ }
426
+ return result;
427
+ }
428
+
429
+ if (autoResumeAttempts >= limit) {
430
+ if (terminal !== undefined) presentTerminal(terminal, options.io);
431
+ return result;
432
+ }
433
+ if (!(await isSessionPrincipalAvailable(options.admitted.sessionFile))) {
434
+ if (terminal !== undefined) presentTerminal(terminal, options.io);
435
+ return result;
436
+ }
437
+ } else {
438
+ // Exception path: continue through the identical budget/session gates.
439
+ if (autoResumeAttempts >= limit) {
440
+ const terminal = dispatchExceptionFailureTerminal({
441
+ role: options.admitted.role,
442
+ runId: options.admitted.runId,
443
+ causeError: lastThrownError,
444
+ errorFiles: retainedErrorFiles,
445
+ autoResumeAttempts,
446
+ endReason: "auto-resume budget exhausted",
447
+ everyAttemptThrew,
448
+ });
449
+ await finalizeExceptionRunBestEffort(options.admitted.runDirectory, options.io);
450
+ presentTerminal(terminal, options.io);
451
+ return {
452
+ exitCode: 1,
453
+ terminal,
454
+ } as T;
455
+ }
456
+ if (!(await isSessionPrincipalAvailable(options.admitted.sessionFile))) {
457
+ const terminal = dispatchExceptionFailureTerminal({
458
+ role: options.admitted.role,
459
+ runId: options.admitted.runId,
460
+ causeError: lastThrownError,
461
+ errorFiles: retainedErrorFiles,
462
+ autoResumeAttempts,
463
+ endReason: "session principal unavailable before further resume",
464
+ everyAttemptThrew,
465
+ });
466
+ await finalizeExceptionRunBestEffort(options.admitted.runDirectory, options.io);
467
+ presentTerminal(terminal, options.io);
468
+ return {
469
+ exitCode: 1,
470
+ terminal,
471
+ } as T;
472
+ }
88
473
  }
89
474
 
90
475
  autoResumeAttempts++;
@@ -347,7 +347,7 @@ export type RunWriterLease = {
347
347
  * True error identity for diagnostics — name/code/message as-is, never a
348
348
  * guessed label (failure-honesty constitution).
349
349
  */
350
- function describeErrorIdentity(error: unknown): string {
350
+ export function describeErrorIdentity(error: unknown): string {
351
351
  const candidate = error as { name?: unknown; code?: unknown; message?: unknown };
352
352
  const name =
353
353
  typeof candidate?.name === "string" && candidate.name !== ""