@akagilnc/pi-workflow-roles 0.1.2152 → 0.1.2169

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.
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Durable Role run lifecycle for public CLI (ADR 0052 / #11 / #108).
2
+ * Durable Role run lifecycle for public CLI (ADR 0052 / #11 / #108 / #416).
3
3
  * States: admitted → running → resumable | terminal.
4
- * v1 resume is limited to an observed typed HTTP 429 on Codex/xAI with no
5
- * lawful role terminal result. Prose is never regex-classified as quota evidence.
4
+ * #416 (owner 2026-08-22): resume no longer gates on terminal/resumable or typed 429 —
5
+ * any existing run with an available Pi session principal may be resumed; caller decides.
6
+ * Prose is never regex-classified as quota evidence.
6
7
  */
7
- import { lstat, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
8
+ import { chmod, lstat, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
8
9
  import { join } from "node:path";
9
10
 
10
11
  import {
@@ -38,7 +39,9 @@ import {
38
39
  type InvocationEffectiveModel,
39
40
  } from "./invocation.ts";
40
41
 
41
- /** Providers eligible for v1 typed-429 resume (Codex / xAI only). */
42
+ /** Providers eligible for v1 typed-429 resume (Codex / xAI only).
43
+ * @deprecated v1 429-only gate removed by #416 (owner 2026-08-22: "根本不要有限制"). Kept for compatibility; do not use for new branching.
44
+ */
42
45
  export const V1_RESUMABLE_PROVIDERS = ["openai-codex", "xai"] as const;
43
46
  export type V1ResumableProvider = (typeof V1_RESUMABLE_PROVIDERS)[number];
44
47
 
@@ -49,6 +52,9 @@ export type TypedHttp429Observation = {
49
52
  readonly provider: V1ResumableProvider;
50
53
  };
51
54
 
55
+ /** Per single LLM call auto-resume retries (call-local, no persistence). See #416 scope correction 2026-08-22. */
56
+ export const AUTO_RESUME_LIMIT = 2 as const;
57
+
52
58
  export type RoleRunRecord = {
53
59
  readonly runId: string;
54
60
  readonly role:
@@ -69,7 +75,9 @@ export type RoleRunRecord = {
69
75
  readonly admittedRequestPath: string;
70
76
  /** Coder/Fixer — preserved for resume continuation. */
71
77
  readonly phase?: CoderPhase | FixerPhase;
72
- /** Present only while state === "resumable". */
78
+ /** Present only while state === "resumable".
79
+ * @deprecated retained only for historical 429 runs; #416 no longer gates resume on this field.
80
+ */
73
81
  readonly resumable?: TypedHttp429Observation;
74
82
  };
75
83
 
@@ -79,12 +87,14 @@ export const RESUME_TRANSPORT_ENVELOPE = "[ak-role:resume-continue]" as const;
79
87
  const RUN_STATE_FILE = "run-state.json";
80
88
  const WRITER_LOCK_FILE = "writer.lock";
81
89
 
90
+ /** @deprecated #416: 429-only classification removed; kept for compatibility. */
82
91
  export function isV1ResumableProvider(
83
92
  provider: string,
84
93
  ): provider is V1ResumableProvider {
85
94
  return (V1_RESUMABLE_PROVIDERS as readonly string[]).includes(provider);
86
95
  }
87
96
 
97
+ /** @deprecated #416: 429-only observation no longer gates resume; kept for historical runs. */
88
98
  export async function readTypedHttp429Observation(
89
99
  runDirectory: string,
90
100
  ): Promise<TypedHttp429Observation | undefined> {
@@ -96,6 +106,7 @@ export async function readTypedHttp429Observation(
96
106
  }
97
107
 
98
108
  /**
109
+ * @deprecated #416: 429-only resumability predicate removed from gating. Kept for compatibility.
99
110
  * True only when a typed HTTP 429 was observed and no lawful terminal exists.
100
111
  * Callers must pass the lawful-terminal fact from settlement, not re-infer it.
101
112
  */
@@ -266,6 +277,7 @@ export async function markRunRunning(
266
277
  });
267
278
  }
268
279
 
280
+ /** @deprecated #416: 429-only resumable marker; kept for historical runs. */
269
281
  export async function markRunResumable(
270
282
  runDirectory: string,
271
283
  observation: TypedHttp429Observation,
@@ -328,13 +340,52 @@ export type RunWriterLease = {
328
340
  release(): Promise<void>;
329
341
  };
330
342
 
343
+ /**
344
+ * True error identity for diagnostics — name/code/message as-is, never a
345
+ * guessed label (failure-honesty constitution).
346
+ */
347
+ function describeErrorIdentity(error: unknown): string {
348
+ const candidate = error as { name?: unknown; code?: unknown; message?: unknown };
349
+ const name =
350
+ typeof candidate?.name === "string" && candidate.name !== ""
351
+ ? candidate.name
352
+ : typeof error;
353
+ const code =
354
+ typeof candidate?.code === "string" || typeof candidate?.code === "number"
355
+ ? ` code=${String(candidate.code)}`
356
+ : "";
357
+ const message =
358
+ typeof candidate?.message === "string" && candidate.message !== ""
359
+ ? `: ${candidate.message}`
360
+ : "";
361
+ return `${name}${code}${message}`;
362
+ }
363
+
331
364
  /**
332
365
  * Acquire the one-writer lease for a Role run. Concurrent acquire rejects
333
366
  * without dispatch. Exclusive create — no second writer.
367
+ *
368
+ * `onCleanupFailure` receives a non-terminal diagnostic line when release-time
369
+ * lock cleanup fails. Release stays best-effort (a stale lock resurfaces as
370
+ * RunWriterLeaseHeldError on next acquire), but the true error identity must
371
+ * still land somewhere observable — silent swallowing is forbidden.
334
372
  */
335
373
  export async function acquireRunWriterLease(
336
374
  runDirectory: string,
375
+ onCleanupFailure?: (diagnostic: string) => void,
337
376
  ): Promise<RunWriterLease> {
377
+ const reportCleanupFailure = (error: unknown): void => {
378
+ // Sink isolation: a throwing onCleanupFailure must not propagate through
379
+ // release() — release stays best-effort by contract. The true cleanup
380
+ // cause has already been handed to the sink as its argument.
381
+ try {
382
+ onCleanupFailure?.(
383
+ `writer lease lock cleanup failed (best-effort continue; stale lock resurfaces as lease-held on next acquire) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
384
+ );
385
+ } catch {
386
+ // diagnostic-sink failure is itself best-effort; never break release().
387
+ }
388
+ };
338
389
  const lockPath = join(runDirectory, WRITER_LOCK_FILE);
339
390
  try {
340
391
  const handle = await open(lockPath, "wx");
@@ -352,7 +403,23 @@ export async function acquireRunWriterLease(
352
403
  if (released) return;
353
404
  released = true;
354
405
  await handle.close().catch(() => undefined);
355
- await unlink(lockPath).catch(() => undefined);
406
+ try {
407
+ await unlink(lockPath);
408
+ } catch (error) {
409
+ if ((error as { code?: unknown }).code === "EACCES") {
410
+ try {
411
+ await chmod(runDirectory, 0o755);
412
+ await unlink(lockPath);
413
+ } catch (retryError) {
414
+ // best-effort cleanup: stale lock will surface as lease-held on next acquire (exit 2),
415
+ // but the true chmod/unlink cause must be recorded, not swallowed.
416
+ reportCleanupFailure(retryError);
417
+ }
418
+ } else {
419
+ // non-EACCES unlink failure is best-effort settlement cleanup; record true cause.
420
+ reportCleanupFailure(error);
421
+ }
422
+ }
356
423
  },
357
424
  };
358
425
  } catch (error) {
@@ -453,7 +520,7 @@ async function loadResumableRunRecord(
453
520
  runId: string,
454
521
  ): Promise<{
455
522
  readonly run: RoleRunRecord;
456
- readonly observation: TypedHttp429Observation;
523
+ readonly observation?: TypedHttp429Observation;
457
524
  readonly admittedFields: LoadedAdmittedRequestFields;
458
525
  }> {
459
526
  const runDirectory = await findRunDirectoryById(home, runId);
@@ -464,12 +531,8 @@ async function loadResumableRunRecord(
464
531
  if (run === undefined) {
465
532
  throw new CliUsageError(`unknown role run id: ${runId}`);
466
533
  }
467
- if (run.state === "terminal") {
468
- throw new CliUsageError(`role run is already terminal: ${runId}`);
469
- }
470
- if (run.state !== "resumable" || run.resumable === undefined) {
471
- throw new CliUsageError(`role run is not resumable: ${runId}`);
472
- }
534
+ // #416: removed terminal/resumable gates per owner decision "根本不要有限制" (2026-08-22).
535
+ // Only the exact Pi session principal check remains as honest failure.
473
536
  // Exact Pi session principal must be present before resume dispatches.
474
537
  if (!(await isSessionPrincipalAvailable(run.sessionFile))) {
475
538
  throw new CliUsageError(
@@ -618,7 +681,7 @@ async function loadResumableRunRecord(
618
681
  }
619
682
  return {
620
683
  run,
621
- observation: run.resumable,
684
+ ...(run.resumable === undefined ? {} : { observation: run.resumable }),
622
685
  admittedFields: {
623
686
  instruction,
624
687
  instructionEmpty,
@@ -641,25 +704,26 @@ async function loadResumableRunRecord(
641
704
  export type LoadedResumableJudgeRun = {
642
705
  readonly admitted: AdmittedJudgeInvocation;
643
706
  readonly run: RoleRunRecord;
644
- readonly observation: TypedHttp429Observation;
707
+ /** @deprecated #416: resumable observation no longer gates resume; may be undefined. */
708
+ readonly observation?: TypedHttp429Observation;
645
709
  };
646
710
 
647
711
  export type LoadedResumableCoderRun = {
648
712
  readonly admitted: AdmittedCoderInvocation;
649
713
  readonly run: RoleRunRecord;
650
- readonly observation: TypedHttp429Observation;
714
+ readonly observation?: TypedHttp429Observation;
651
715
  };
652
716
 
653
717
  export type LoadedResumableFixerRun = {
654
718
  readonly admitted: AdmittedFixerInvocation;
655
719
  readonly run: RoleRunRecord;
656
- readonly observation: TypedHttp429Observation;
720
+ readonly observation?: TypedHttp429Observation;
657
721
  };
658
722
 
659
723
  export type LoadedResumableReviewerRun = {
660
724
  readonly admitted: AdmittedReviewerInvocation;
661
725
  readonly run: RoleRunRecord;
662
- readonly observation: TypedHttp429Observation;
726
+ readonly observation?: TypedHttp429Observation;
663
727
  };
664
728
 
665
729
  /**
@@ -693,7 +757,7 @@ export async function loadResumableJudgeRun(
693
757
  return {
694
758
  admitted,
695
759
  run: loaded.run,
696
- observation: loaded.observation,
760
+ ...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
697
761
  };
698
762
  }
699
763
 
@@ -747,7 +811,7 @@ export async function loadResumableCoderRun(
747
811
  return {
748
812
  admitted,
749
813
  run: loaded.run,
750
- observation: loaded.observation,
814
+ ...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
751
815
  };
752
816
  }
753
817
 
@@ -806,7 +870,7 @@ export async function loadResumableFixerRun(
806
870
  return {
807
871
  admitted,
808
872
  run: loaded.run,
809
- observation: loaded.observation,
873
+ ...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
810
874
  };
811
875
  }
812
876
 
@@ -853,14 +917,14 @@ export async function loadResumableReviewerRun(
853
917
  return {
854
918
  admitted,
855
919
  run: loaded.run,
856
- observation: loaded.observation,
920
+ ...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
857
921
  };
858
922
  }
859
923
 
860
924
  export type LoadedResumableMergerRun = {
861
925
  readonly admitted: AdmittedMergerInvocation;
862
926
  readonly run: RoleRunRecord;
863
- readonly observation: TypedHttp429Observation;
927
+ readonly observation?: TypedHttp429Observation;
864
928
  };
865
929
 
866
930
  /**
@@ -913,7 +977,7 @@ export async function loadResumableMergerRun(
913
977
  return {
914
978
  admitted,
915
979
  run: loaded.run,
916
- observation: loaded.observation,
980
+ ...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
917
981
  };
918
982
  }
919
983
 
@@ -4,7 +4,8 @@
4
4
  * Controlled failures and audit human decisions settle here without washing causes.
5
5
  */
6
6
  import { randomUUID } from "node:crypto";
7
- import { lstat, mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
7
+ import { constants as fsConstants } from "node:fs";
8
+ import { appendFile, lstat, mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
8
9
  import { dirname, join } from "node:path";
9
10
 
10
11
  import { isAuditEscalationResult } from "../audit-escalation.ts";
@@ -14,6 +15,7 @@ import { JUDGE_AUDIT_TOOL_NAME } from "../judge-auditor.ts";
14
15
  import { REVIEWER_AUDIT_TOOL_NAME } from "../reviewer-auditor.ts";
15
16
  import { knownFailureFromProviderStop, type ExplicitInternalKnownFailure, readReviewerDispatchRejection } from "./explicit-internal.ts";
16
17
  import {
18
+ RESUME_TRANSPORT_ENVELOPE,
17
19
  isV1ResumableProvider,
18
20
  readLatestTypedProviderHttpObservation,
19
21
  readTypedHttp429Observation,
@@ -725,12 +727,24 @@ export async function readBoundAuditorKnownFailure(
725
727
  }
726
728
  const parentId = parentEntries.find((entry) => entry.type === "session")?.id;
727
729
  if (parentId === undefined) return undefined;
730
+ const RESUME_ENVELOPE = RESUME_TRANSPORT_ENVELOPE;
731
+ const isResumeEnvelope = (msg: unknown): boolean => {
732
+ if (!isRecord(msg) || msg.role !== "user") return false;
733
+ const text = typeof msg.text === "string" ? msg.text : typeof (msg as { content?: unknown }).content === "string" ? (msg as { content: string }).content : undefined;
734
+ if (text === RESUME_ENVELOPE) return true;
735
+ const content = (msg as { content?: unknown }).content;
736
+ if (Array.isArray(content)) {
737
+ return content.some((p) => isRecord(p) && (p.text === RESUME_ENVELOPE || p.content === RESUME_ENVELOPE));
738
+ }
739
+ return false;
740
+ };
728
741
  let latestParentUserIndex = -1;
729
742
  for (let i = parentEntries.length - 1; i >= 0; i -= 1) {
730
- if (parentEntries[i]?.type === "message" && parentEntries[i]?.message?.role === "user") {
731
- latestParentUserIndex = i;
732
- break;
733
- }
743
+ const entry = parentEntries[i];
744
+ if (entry?.type !== "message" || entry.message?.role !== "user") continue;
745
+ if (isResumeEnvelope(entry.message)) continue;
746
+ latestParentUserIndex = i;
747
+ break;
734
748
  }
735
749
  const childDirectory = join(dirname(sessionFile), "auditor-roles");
736
750
  let names: string[];
@@ -740,6 +754,12 @@ export async function readBoundAuditorKnownFailure(
740
754
  if (isMissingPathError(error)) return undefined;
741
755
  throw sessionReadFailure(error, "failed to read bound auditor session directory");
742
756
  }
757
+ // Auto-resume seam (owner A): stale check must ignore resume envelope and
758
+ // prioritize retention. Previous `attemptEntryIndex < latest` discarded the
759
+ // first attempt's child after resume advanced latest, losing retentionFailure
760
+ // when retry had no compliance entry. Fix: ignore envelope for staleness and
761
+ // prefer any valid compliance failure before falling back to primary.
762
+ const validAuditorFiles: Array<{ file: string; entries: SessionEntry[]; attemptEntryId?: string }> = [];
743
763
  for (const file of names.filter((name) => name.endsWith(".jsonl")).sort().reverse()) {
744
764
  let entries: SessionEntry[];
745
765
  try {
@@ -754,7 +774,10 @@ export async function readBoundAuditorKnownFailure(
754
774
  const attemptEntryId = typeof bindingParent?.attemptEntryId === "string" ? bindingParent.attemptEntryId : undefined;
755
775
  const attemptEntryIndex = attemptEntryId === undefined ? -1 : parentEntries.findIndex((entry) => entry.id === attemptEntryId);
756
776
  if (bindingParent?.sessionId !== parentId || bindingParent.sessionFile !== sessionFile || attemptEntryIndex < latestParentUserIndex) continue;
757
-
777
+ validAuditorFiles.push({ file, entries, ...(attemptEntryId === undefined ? {} : { attemptEntryId }) });
778
+ }
779
+ // Prefer compliance failure (retention) from any valid attempt, newest first.
780
+ for (const { entries, attemptEntryId } of validAuditorFiles) {
758
781
  const stop = extractSessionProviderStop(entries);
759
782
  if (stop === undefined) continue;
760
783
  for (let i = entries.length - 1; i >= 0; i -= 1) {
@@ -774,6 +797,11 @@ export async function readBoundAuditorKnownFailure(
774
797
  ...(isRecord(failure.details) ? { details: failure.details } : {}),
775
798
  };
776
799
  }
800
+ }
801
+ // No compliance failure: fall back to most recent provider stop (auto-resume latest attempt).
802
+ for (const { entries } of validAuditorFiles) {
803
+ const stop = extractSessionProviderStop(entries);
804
+ if (stop === undefined) continue;
777
805
  const primary = knownFailureFromProviderStop(stop)!;
778
806
  return {
779
807
  ...primary,
@@ -1852,26 +1880,131 @@ async function ensureAuditEvidenceDirectory(runDirectory: string): Promise<strin
1852
1880
  return artifactsDir;
1853
1881
  }
1854
1882
 
1855
- /** Publish the retained residual with exclusive, complete-write semantics. */
1883
+ /**
1884
+ * #419 per-attempt process history. 史必追加,指针可覆盖;指针可以覆盖的前提是史已落。
1885
+ * Reuses the run session principal's append-only JSONL custom-entry shape
1886
+ * (plain custom entries are state records and never enter LLM context), so no
1887
+ * second ledger mechanism is introduced.
1888
+ */
1889
+ export const ATTEMPT_HISTORY_ENTRY_TYPE = "ak_run_attempt_history" as const;
1890
+
1891
+ /** Complete per-attempt result as recorded in the appended history. */
1892
+ type AttemptHistoryOutcome =
1893
+ | TerminalRoleOutcome
1894
+ | ({ kind: "failure"; role: string } & ControlledFailure);
1895
+
1896
+ type AttemptHistorySource = {
1897
+ readonly role: string;
1898
+ readonly runId: string;
1899
+ readonly sessionFile: string;
1900
+ };
1901
+
1902
+ /**
1903
+ * Append one attempt's complete result to the run's session principal.
1904
+ * Append failure throws — callers must not overwrite a pointer artifact when
1905
+ * the history entry backing the overwrite did not land (fail closed).
1906
+ */
1907
+ export async function appendRunAttemptHistory(
1908
+ admitted: AttemptHistorySource,
1909
+ outcome: AttemptHistoryOutcome,
1910
+ ): Promise<void> {
1911
+ const entries = await readBoundSessionEntries(admitted.sessionFile);
1912
+ let parentId: string | null = null;
1913
+ let priorEntries = 0;
1914
+ for (const entry of entries) {
1915
+ if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
1916
+ if (
1917
+ entry.type === "custom" &&
1918
+ entry.customType === ATTEMPT_HISTORY_ENTRY_TYPE
1919
+ ) {
1920
+ priorEntries += 1;
1921
+ }
1922
+ }
1923
+ const timestamp = new Date().toISOString();
1924
+ // Shape mirrors pi SessionManager.appendCustomEntry.
1925
+ const line = `${JSON.stringify({
1926
+ type: "custom",
1927
+ customType: ATTEMPT_HISTORY_ENTRY_TYPE,
1928
+ data: {
1929
+ sequence: priorEntries + 1,
1930
+ role: admitted.role,
1931
+ runId: admitted.runId,
1932
+ recordedAt: timestamp,
1933
+ outcome,
1934
+ },
1935
+ id: randomUUID(),
1936
+ parentId,
1937
+ timestamp,
1938
+ })}\n`;
1939
+ await appendFile(admitted.sessionFile, line, "utf8");
1940
+ }
1941
+
1942
+ /**
1943
+ * Publish the retained residual with complete-write semantics (#419: the
1944
+ * previous attempt's pointer file is a rebuildable view once this attempt's
1945
+ * complete result is in the appended history; planted symlinks/directories
1946
+ * still fail loudly with their #182-A identities).
1947
+ */
1856
1948
  export async function publishComplianceAuditIncompleteEvidence(
1857
1949
  admitted: AdmittedRoleInvocation,
1858
1950
  outcome: ReturnType<typeof buildAuditIncompleteTerminalOutcome>,
1859
1951
  ): Promise<TerminalArtifactRef> {
1952
+ await appendRunAttemptHistory(admitted, outcome);
1953
+ if (
1954
+ typeof fsConstants.O_NOFOLLOW !== "number" ||
1955
+ typeof fsConstants.O_NONBLOCK !== "number"
1956
+ ) {
1957
+ // #418 fail-closed: on platforms without O_NOFOLLOW (e.g. Windows,
1958
+ // nodejs/node#41590) the JS bitwise-or below would silently drop the flag
1959
+ // and the #182-A anti-symlink protection would vanish. Refuse loudly via
1960
+ // the existing publication-failure channel instead of publishing
1961
+ // unprotected; the complete attempt result above stays in appended history.
1962
+ throw auditArtifactPublicationError(
1963
+ "audit evidence publication requires O_NOFOLLOW|O_NONBLOCK open-flag support (anti-symlink/anti-planted protection must not be silently dropped); refusing to publish",
1964
+ "ENOSYS",
1965
+ );
1966
+ }
1860
1967
  const artifactsDir = await ensureAuditEvidenceDirectory(admitted.runDirectory);
1861
1968
  const evidencePath = join(artifactsDir, "audit-incomplete.json");
1969
+ let existing: Awaited<ReturnType<typeof lstat>> | undefined;
1862
1970
  try {
1863
- const existing = await lstat(evidencePath);
1864
- throw auditArtifactPublicationError(
1865
- existing.isSymbolicLink()
1866
- ? "audit evidence destination is a symlink"
1867
- : "audit evidence destination collision",
1868
- existing.isSymbolicLink() ? "ELOOP" : "EEXIST",
1869
- );
1971
+ existing = await lstat(evidencePath);
1870
1972
  } catch (error) {
1871
1973
  if (!isMissingPathError(error)) throw error;
1872
1974
  }
1873
- const handle = await open(evidencePath, "wx", 0o600);
1975
+ if (existing?.isSymbolicLink()) {
1976
+ throw auditArtifactPublicationError(
1977
+ "audit evidence destination is a symlink",
1978
+ "ELOOP",
1979
+ );
1980
+ }
1981
+ if (existing && !existing.isFile()) {
1982
+ throw auditArtifactPublicationError(
1983
+ "audit evidence destination is not a regular file",
1984
+ "EEXIST",
1985
+ );
1986
+ }
1987
+ // #182-A protection restored atomically at this open seam: O_NOFOLLOW makes
1988
+ // the open itself refuse symlinks (ELOOP), so one swapped in after the lstat
1989
+ // above can never be followed or written through; O_NONBLOCK keeps a
1990
+ // race-planted FIFO from blocking this open forever. The fstat below
1991
+ // backstops any non-regular object that wins the window.
1992
+ const handle = await open(
1993
+ evidencePath,
1994
+ fsConstants.O_WRONLY |
1995
+ fsConstants.O_CREAT |
1996
+ fsConstants.O_TRUNC |
1997
+ fsConstants.O_NOFOLLOW |
1998
+ fsConstants.O_NONBLOCK,
1999
+ 0o600,
2000
+ );
1874
2001
  try {
2002
+ if (!(await handle.stat()).isFile()) {
2003
+ throw auditArtifactPublicationError(
2004
+ "audit evidence destination is not a regular file",
2005
+ "EEXIST",
2006
+ );
2007
+ }
1875
2008
  await handle.writeFile(`${JSON.stringify(outcome, null, 2)}\n`, "utf8");
1876
2009
  await handle.sync();
1877
2010
  } finally {
@@ -2213,6 +2346,9 @@ export async function publishJudgeArtifacts(
2213
2346
  roleOutcome: TerminalRoleOutcome,
2214
2347
  sessionDirectory: string,
2215
2348
  ): Promise<TerminalArtifactRef[]> {
2349
+ // #419: history first — report/evidence stay last-write-wins views only
2350
+ // because every attempt's complete result has already been appended.
2351
+ await appendRunAttemptHistory(admitted, roleOutcome);
2216
2352
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2217
2353
  const reportPath = join(artifactsDir, "report.json");
2218
2354
  const evidencePath = join(artifactsDir, "evidence.json");
@@ -2268,6 +2404,7 @@ export async function publishCoderArtifacts(
2268
2404
  readonly coderOutput?: CoderOutput;
2269
2405
  } = {},
2270
2406
  ): Promise<TerminalArtifactRef[]> {
2407
+ await appendRunAttemptHistory(admitted, roleOutcome);
2271
2408
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2272
2409
  const reportPath = join(artifactsDir, "report.json");
2273
2410
  const evidencePath = join(artifactsDir, "evidence.json");
@@ -2573,6 +2710,7 @@ export async function publishFixerArtifacts(
2573
2710
  readonly fixerOutput?: FixerOutput;
2574
2711
  },
2575
2712
  ): Promise<TerminalArtifactRef[]> {
2713
+ await appendRunAttemptHistory(admitted, roleOutcome);
2576
2714
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2577
2715
  const reportPath = join(artifactsDir, "report.json");
2578
2716
  const evidencePath = join(artifactsDir, "evidence.json");
@@ -2732,6 +2870,7 @@ export async function publishCollectorArtifacts(
2732
2870
  readonly collectorReceipt?: CollectorReceipt;
2733
2871
  } = {},
2734
2872
  ): Promise<TerminalArtifactRef[]> {
2873
+ await appendRunAttemptHistory(admitted, roleOutcome);
2735
2874
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2736
2875
  const reportPath = join(artifactsDir, "report.json");
2737
2876
  const evidencePath = join(artifactsDir, "evidence.json");
@@ -2891,6 +3030,7 @@ export async function publishDoctorArtifacts(
2891
3030
  readonly doctorOutput?: DoctorOutput;
2892
3031
  } = {},
2893
3032
  ): Promise<TerminalArtifactRef[]> {
3033
+ await appendRunAttemptHistory(admitted, roleOutcome);
2894
3034
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
2895
3035
  const reportPath = join(artifactsDir, "report.json");
2896
3036
  const evidencePath = join(artifactsDir, "evidence.json");
@@ -3155,6 +3295,7 @@ export async function publishReviewerArtifacts(
3155
3295
  readonly reviewerReceipt?: RuntimeReviewerReceiptV2;
3156
3296
  },
3157
3297
  ): Promise<TerminalArtifactRef[]> {
3298
+ await appendRunAttemptHistory(admitted, roleOutcome);
3158
3299
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
3159
3300
  const reportPath = join(artifactsDir, "report.json");
3160
3301
  const evidencePath = join(artifactsDir, "evidence.json");
@@ -3412,6 +3553,7 @@ export async function publishMergerArtifacts(
3412
3553
  readonly mergerOutput?: MergerOutput;
3413
3554
  },
3414
3555
  ): Promise<TerminalArtifactRef[]> {
3556
+ await appendRunAttemptHistory(admitted, roleOutcome);
3415
3557
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
3416
3558
  const reportPath = join(artifactsDir, "report.json");
3417
3559
  const evidencePath = join(artifactsDir, "evidence.json");
@@ -3746,6 +3888,19 @@ export async function publishFailureArtifacts(
3746
3888
  );
3747
3889
  const priorIssues: PublicationAttempt[] =
3748
3890
  baseAttempt === undefined ? [] : [baseAttempt];
3891
+ // #419: each attempt's complete failure result joins the appended history
3892
+ // before any fixed-name artifact view is rewritten. History failure must not
3893
+ // strand the original controlled failure outside settlement — it rides
3894
+ // publicationIssues instead of aborting durability.
3895
+ try {
3896
+ await appendRunAttemptHistory(admitted, {
3897
+ kind: "failure",
3898
+ role: admitted.role,
3899
+ ...failure,
3900
+ });
3901
+ } catch (error) {
3902
+ priorIssues.push(publicationAttemptFromError(admitted.sessionFile, error));
3903
+ }
3749
3904
 
3750
3905
  // Prefer conventional names; unique fallback dirs keep colliding fixed paths
3751
3906
  // from stranding the original failure outside settlement. Include the ledger
@@ -231,11 +231,16 @@ export function redactExactRunId(text: string, runId: string): string {
231
231
  * One admitted Role run's typed Terminal aggregate.
232
232
  * Resumable failures carry `resume` and must not re-disclose the run ID via
233
233
  * top-level `runId` or public artifact path components — only `resume.command`.
234
+ * #416: autoResumeCount (0..2) is a call-local observation of how many in-place
235
+ * auto-resumes occurred during this single LLM call; it is not persisted to
236
+ * run-state.json and does not participate in limit decisions.
234
237
  */
235
238
  export type TerminalResult = {
236
239
  roleOutcome: TerminalRoleOutcome;
237
240
  navigator: TerminalNavigatorFact;
238
241
  artifacts: readonly TerminalArtifactRef[];
242
+ /** Call-local auto-resume observation (0..2) for this single LLM call; read-only, not persisted. */
243
+ autoResumeCount?: number;
239
244
  } & (
240
245
  | {
241
246
  /** Resumable failure: run ID appears only inside resume.command. */
@@ -330,5 +335,8 @@ export function formatTerminalResult(result: TerminalResult): string {
330
335
  } else if (result.runId !== undefined) {
331
336
  lines.push(`run\t${encodeTerminalField(result.runId)}`);
332
337
  }
338
+ if (result.autoResumeCount !== undefined) {
339
+ lines.push(`autoResumeCount\t${encodeTerminalField(String(result.autoResumeCount))}`);
340
+ }
333
341
  return `${lines.join("\n")}\n`;
334
342
  }