@akagilnc/pi-workflow-roles 0.1.2157 → 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.
@@ -18866,7 +18866,22 @@ async function isSessionPrincipalAvailable(sessionFile) {
18866
18866
  return false;
18867
18867
  }
18868
18868
  }
18869
- async function acquireRunWriterLease(runDirectory) {
18869
+ function describeErrorIdentity(error) {
18870
+ const candidate = error;
18871
+ const name = typeof candidate?.name === "string" && candidate.name !== "" ? candidate.name : typeof error;
18872
+ const code = typeof candidate?.code === "string" || typeof candidate?.code === "number" ? ` code=${String(candidate.code)}` : "";
18873
+ const message = typeof candidate?.message === "string" && candidate.message !== "" ? `: ${candidate.message}` : "";
18874
+ return `${name}${code}${message}`;
18875
+ }
18876
+ async function acquireRunWriterLease(runDirectory, onCleanupFailure) {
18877
+ const reportCleanupFailure = (error) => {
18878
+ try {
18879
+ onCleanupFailure?.(
18880
+ `writer lease lock cleanup failed (best-effort continue; stale lock resurfaces as lease-held on next acquire) at ${join10(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`
18881
+ );
18882
+ } catch {
18883
+ }
18884
+ };
18870
18885
  const lockPath = join10(runDirectory, WRITER_LOCK_FILE);
18871
18886
  try {
18872
18887
  const handle = await open(lockPath, "wx");
@@ -18892,8 +18907,11 @@ async function acquireRunWriterLease(runDirectory) {
18892
18907
  try {
18893
18908
  await chmod(runDirectory, 493);
18894
18909
  await unlink3(lockPath);
18895
- } catch {
18910
+ } catch (retryError) {
18911
+ reportCleanupFailure(retryError);
18896
18912
  }
18913
+ } else {
18914
+ reportCleanupFailure(error);
18897
18915
  }
18898
18916
  }
18899
18917
  }
@@ -19886,7 +19904,8 @@ var init_navigator_invocation_identity = __esm({
19886
19904
 
19887
19905
  // src/public-cli/settlement.ts
19888
19906
  import { randomUUID } from "node:crypto";
19889
- import { lstat as lstat3, mkdir as mkdir3, open as open2, readFile as readFile9, readdir as readdir3, writeFile as writeFile5 } from "node:fs/promises";
19907
+ import { constants as fsConstants } from "node:fs";
19908
+ import { appendFile, lstat as lstat3, mkdir as mkdir3, open as open2, readFile as readFile9, readdir as readdir3, writeFile as writeFile5 } from "node:fs/promises";
19890
19909
  import { dirname as dirname6, join as join11 } from "node:path";
19891
19910
  function isChildDiagnosticFloodLine(line2) {
19892
19911
  if (/^at\s+/.test(line2)) return true;
@@ -20200,12 +20219,24 @@ async function readBoundAuditorKnownFailure(sessionFile) {
20200
20219
  }
20201
20220
  const parentId = parentEntries.find((entry) => entry.type === "session")?.id;
20202
20221
  if (parentId === void 0) return void 0;
20222
+ const RESUME_ENVELOPE = RESUME_TRANSPORT_ENVELOPE;
20223
+ const isResumeEnvelope = (msg) => {
20224
+ if (!isRecord5(msg) || msg.role !== "user") return false;
20225
+ const text = typeof msg.text === "string" ? msg.text : typeof msg.content === "string" ? msg.content : void 0;
20226
+ if (text === RESUME_ENVELOPE) return true;
20227
+ const content = msg.content;
20228
+ if (Array.isArray(content)) {
20229
+ return content.some((p) => isRecord5(p) && (p.text === RESUME_ENVELOPE || p.content === RESUME_ENVELOPE));
20230
+ }
20231
+ return false;
20232
+ };
20203
20233
  let latestParentUserIndex = -1;
20204
20234
  for (let i = parentEntries.length - 1; i >= 0; i -= 1) {
20205
- if (parentEntries[i]?.type === "message" && parentEntries[i]?.message?.role === "user") {
20206
- latestParentUserIndex = i;
20207
- break;
20208
- }
20235
+ const entry = parentEntries[i];
20236
+ if (entry?.type !== "message" || entry.message?.role !== "user") continue;
20237
+ if (isResumeEnvelope(entry.message)) continue;
20238
+ latestParentUserIndex = i;
20239
+ break;
20209
20240
  }
20210
20241
  const childDirectory = join11(dirname6(sessionFile), "auditor-roles");
20211
20242
  let names;
@@ -20215,6 +20246,7 @@ async function readBoundAuditorKnownFailure(sessionFile) {
20215
20246
  if (isMissingPathError2(error)) return void 0;
20216
20247
  throw sessionReadFailure(error, "failed to read bound auditor session directory");
20217
20248
  }
20249
+ const validAuditorFiles = [];
20218
20250
  for (const file of names.filter((name) => name.endsWith(".jsonl")).sort().reverse()) {
20219
20251
  let entries;
20220
20252
  try {
@@ -20229,6 +20261,9 @@ async function readBoundAuditorKnownFailure(sessionFile) {
20229
20261
  const attemptEntryId = typeof bindingParent?.attemptEntryId === "string" ? bindingParent.attemptEntryId : void 0;
20230
20262
  const attemptEntryIndex = attemptEntryId === void 0 ? -1 : parentEntries.findIndex((entry) => entry.id === attemptEntryId);
20231
20263
  if (bindingParent?.sessionId !== parentId || bindingParent.sessionFile !== sessionFile || attemptEntryIndex < latestParentUserIndex) continue;
20264
+ validAuditorFiles.push({ file, entries, ...attemptEntryId === void 0 ? {} : { attemptEntryId } });
20265
+ }
20266
+ for (const { entries, attemptEntryId } of validAuditorFiles) {
20232
20267
  const stop = extractSessionProviderStop(entries);
20233
20268
  if (stop === void 0) continue;
20234
20269
  for (let i = entries.length - 1; i >= 0; i -= 1) {
@@ -20248,6 +20283,10 @@ async function readBoundAuditorKnownFailure(sessionFile) {
20248
20283
  ...isRecord5(failure.details) ? { details: failure.details } : {}
20249
20284
  };
20250
20285
  }
20286
+ }
20287
+ for (const { entries } of validAuditorFiles) {
20288
+ const stop = extractSessionProviderStop(entries);
20289
+ if (stop === void 0) continue;
20251
20290
  const primary = knownFailureFromProviderStop(stop);
20252
20291
  return {
20253
20292
  ...primary,
@@ -20981,20 +21020,74 @@ async function ensureAuditEvidenceDirectory(runDirectory) {
20981
21020
  }
20982
21021
  return artifactsDir;
20983
21022
  }
21023
+ async function appendRunAttemptHistory(admitted, outcome) {
21024
+ const entries = await readBoundSessionEntries(admitted.sessionFile);
21025
+ let parentId = null;
21026
+ let priorEntries = 0;
21027
+ for (const entry of entries) {
21028
+ if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
21029
+ if (entry.type === "custom" && entry.customType === ATTEMPT_HISTORY_ENTRY_TYPE) {
21030
+ priorEntries += 1;
21031
+ }
21032
+ }
21033
+ const timestamp2 = (/* @__PURE__ */ new Date()).toISOString();
21034
+ const line2 = `${JSON.stringify({
21035
+ type: "custom",
21036
+ customType: ATTEMPT_HISTORY_ENTRY_TYPE,
21037
+ data: {
21038
+ sequence: priorEntries + 1,
21039
+ role: admitted.role,
21040
+ runId: admitted.runId,
21041
+ recordedAt: timestamp2,
21042
+ outcome
21043
+ },
21044
+ id: randomUUID(),
21045
+ parentId,
21046
+ timestamp: timestamp2
21047
+ })}
21048
+ `;
21049
+ await appendFile(admitted.sessionFile, line2, "utf8");
21050
+ }
20984
21051
  async function publishComplianceAuditIncompleteEvidence(admitted, outcome) {
21052
+ await appendRunAttemptHistory(admitted, outcome);
21053
+ if (typeof fsConstants.O_NOFOLLOW !== "number" || typeof fsConstants.O_NONBLOCK !== "number") {
21054
+ throw auditArtifactPublicationError(
21055
+ "audit evidence publication requires O_NOFOLLOW|O_NONBLOCK open-flag support (anti-symlink/anti-planted protection must not be silently dropped); refusing to publish",
21056
+ "ENOSYS"
21057
+ );
21058
+ }
20985
21059
  const artifactsDir = await ensureAuditEvidenceDirectory(admitted.runDirectory);
20986
21060
  const evidencePath = join11(artifactsDir, "audit-incomplete.json");
21061
+ let existing;
20987
21062
  try {
20988
- const existing = await lstat3(evidencePath);
20989
- throw auditArtifactPublicationError(
20990
- existing.isSymbolicLink() ? "audit evidence destination is a symlink" : "audit evidence destination collision",
20991
- existing.isSymbolicLink() ? "ELOOP" : "EEXIST"
20992
- );
21063
+ existing = await lstat3(evidencePath);
20993
21064
  } catch (error) {
20994
21065
  if (!isMissingPathError2(error)) throw error;
20995
21066
  }
20996
- const handle = await open2(evidencePath, "wx", 384);
21067
+ if (existing?.isSymbolicLink()) {
21068
+ throw auditArtifactPublicationError(
21069
+ "audit evidence destination is a symlink",
21070
+ "ELOOP"
21071
+ );
21072
+ }
21073
+ if (existing && !existing.isFile()) {
21074
+ throw auditArtifactPublicationError(
21075
+ "audit evidence destination is not a regular file",
21076
+ "EEXIST"
21077
+ );
21078
+ }
21079
+ const handle = await open2(
21080
+ evidencePath,
21081
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK,
21082
+ 384
21083
+ );
20997
21084
  try {
21085
+ if (!(await handle.stat()).isFile()) {
21086
+ throw auditArtifactPublicationError(
21087
+ "audit evidence destination is not a regular file",
21088
+ "EEXIST"
21089
+ );
21090
+ }
20998
21091
  await handle.writeFile(`${JSON.stringify(outcome, null, 2)}
20999
21092
  `, "utf8");
21000
21093
  await handle.sync();
@@ -21247,6 +21340,7 @@ async function extractNavigatorFactFromAdmittedSession(admitted) {
21247
21340
  }
21248
21341
  }
21249
21342
  async function publishJudgeArtifacts(admitted, roleOutcome, sessionDirectory) {
21343
+ await appendRunAttemptHistory(admitted, roleOutcome);
21250
21344
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
21251
21345
  const reportPath = join11(artifactsDir, "report.json");
21252
21346
  const evidencePath = join11(artifactsDir, "evidence.json");
@@ -21291,6 +21385,7 @@ async function publishJudgeArtifacts(admitted, roleOutcome, sessionDirectory) {
21291
21385
  ];
21292
21386
  }
21293
21387
  async function publishCoderArtifacts(admitted, roleOutcome, sessionDirectory, options = {}) {
21388
+ await appendRunAttemptHistory(admitted, roleOutcome);
21294
21389
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
21295
21390
  const reportPath = join11(artifactsDir, "report.json");
21296
21391
  const evidencePath = join11(artifactsDir, "evidence.json");
@@ -21460,6 +21555,7 @@ function extractFixerMethodInvocations(entries, options) {
21460
21555
  return Object.freeze(observed);
21461
21556
  }
21462
21557
  async function publishFixerArtifacts(admitted, roleOutcome, sessionDirectory, options) {
21558
+ await appendRunAttemptHistory(admitted, roleOutcome);
21463
21559
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
21464
21560
  const reportPath = join11(artifactsDir, "report.json");
21465
21561
  const evidencePath = join11(artifactsDir, "evidence.json");
@@ -21571,6 +21667,7 @@ async function settleLawfulFixerTerminalResult(admitted, options) {
21571
21667
  };
21572
21668
  }
21573
21669
  async function publishCollectorArtifacts(admitted, roleOutcome, sessionDirectory, options = {}) {
21670
+ await appendRunAttemptHistory(admitted, roleOutcome);
21574
21671
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
21575
21672
  const reportPath = join11(artifactsDir, "report.json");
21576
21673
  const evidencePath = join11(artifactsDir, "evidence.json");
@@ -21690,6 +21787,7 @@ async function trySettleCollectorTerminalResult(admitted) {
21690
21787
  return settleLawfulCollectorTerminalResult(admitted);
21691
21788
  }
21692
21789
  async function publishDoctorArtifacts(admitted, roleOutcome, sessionDirectory, options = {}) {
21790
+ await appendRunAttemptHistory(admitted, roleOutcome);
21693
21791
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
21694
21792
  const reportPath = join11(artifactsDir, "report.json");
21695
21793
  const evidencePath = join11(artifactsDir, "evidence.json");
@@ -21857,6 +21955,7 @@ function extractReviewerMethodInvocations(entries, options) {
21857
21955
  return Object.freeze(observed);
21858
21956
  }
21859
21957
  async function publishReviewerArtifacts(admitted, roleOutcome, sessionDirectory, options) {
21958
+ await appendRunAttemptHistory(admitted, roleOutcome);
21860
21959
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
21861
21960
  const reportPath = join11(artifactsDir, "report.json");
21862
21961
  const evidencePath = join11(artifactsDir, "evidence.json");
@@ -22025,6 +22124,7 @@ function extractMergerMethodInvocations(entries, options) {
22025
22124
  return Object.freeze(observed);
22026
22125
  }
22027
22126
  async function publishMergerArtifacts(admitted, roleOutcome, sessionDirectory, options) {
22127
+ await appendRunAttemptHistory(admitted, roleOutcome);
22028
22128
  const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
22029
22129
  const reportPath = join11(artifactsDir, "report.json");
22030
22130
  const evidencePath = join11(artifactsDir, "evidence.json");
@@ -22246,6 +22346,15 @@ async function publishFailureArtifacts(admitted, failure) {
22246
22346
  admitted.runDirectory
22247
22347
  );
22248
22348
  const priorIssues = baseAttempt === void 0 ? [] : [baseAttempt];
22349
+ try {
22350
+ await appendRunAttemptHistory(admitted, {
22351
+ kind: "failure",
22352
+ role: admitted.role,
22353
+ ...failure
22354
+ });
22355
+ } catch (error) {
22356
+ priorIssues.push(publicationAttemptFromError(admitted.sessionFile, error));
22357
+ }
22249
22358
  const underArtifacts = baseDir === join11(admitted.runDirectory, "artifacts");
22250
22359
  const uniqueFallbackDirs = uniqueFailureFallbackDirs(
22251
22360
  admitted.runDirectory,
@@ -22442,7 +22551,7 @@ function presentFailureTerminal(terminal, io) {
22442
22551
  }));
22443
22552
  }
22444
22553
  }
22445
- var CONCISE_DIAGNOSTIC_MAX_CHARS, COLLECTOR_INFRASTRUCTURE_TOOLS, COLLECTOR_INFRASTRUCTURE_FAILURE_SPEC, ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC;
22554
+ var CONCISE_DIAGNOSTIC_MAX_CHARS, COLLECTOR_INFRASTRUCTURE_TOOLS, COLLECTOR_INFRASTRUCTURE_FAILURE_SPEC, ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC, ATTEMPT_HISTORY_ENTRY_TYPE;
22446
22555
  var init_settlement = __esm({
22447
22556
  "src/public-cli/settlement.ts"() {
22448
22557
  "use strict";
@@ -22485,10 +22594,18 @@ var init_settlement = __esm({
22485
22594
  cause: "output",
22486
22595
  identityName: "EngineDetourInfrastructureError"
22487
22596
  };
22597
+ ATTEMPT_HISTORY_ENTRY_TYPE = "ak_run_attempt_history";
22488
22598
  }
22489
22599
  });
22490
22600
 
22491
22601
  // src/public-cli/auto-resume.ts
22602
+ function presentTerminal(terminal, io) {
22603
+ if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
22604
+ presentFailureTerminal(terminal, io);
22605
+ } else {
22606
+ io.stdout(formatTerminalResult(terminal));
22607
+ }
22608
+ }
22492
22609
  async function runWithAutoResumeLoop(options) {
22493
22610
  let autoResumeAttempts = 0;
22494
22611
  let isFirst = true;
@@ -22496,7 +22613,10 @@ async function runWithAutoResumeLoop(options) {
22496
22613
  while (true) {
22497
22614
  let lease;
22498
22615
  try {
22499
- lease = await acquireRunWriterLease(options.admitted.runDirectory);
22616
+ lease = await acquireRunWriterLease(
22617
+ options.admitted.runDirectory,
22618
+ (diagnostic) => options.io.stderr(diagnostic)
22619
+ );
22500
22620
  } catch (error) {
22501
22621
  if (error instanceof RunWriterLeaseHeldError) {
22502
22622
  presentStructuralRejection(error, options.io);
@@ -22516,39 +22636,12 @@ async function runWithAutoResumeLoop(options) {
22516
22636
  }
22517
22637
  return result2;
22518
22638
  }
22519
- if (terminal !== void 0 && (terminal.roleOutcome.kind === "incomplete" || terminal.roleOutcome.kind === "audit_incomplete")) {
22520
- if (terminal.roleOutcome.kind === "audit_incomplete") {
22521
- options.io.stdout(formatTerminalResult(terminal));
22522
- } else {
22523
- options.io.stdout(formatTerminalResult(terminal));
22524
- }
22525
- return result2;
22526
- }
22527
- if (terminal !== void 0 && terminal.roleOutcome.kind === "failure") {
22528
- const terminalJson = JSON.stringify(terminal);
22529
- if (terminalJson.includes("retentionFailure") || terminalJson.includes("ComplianceResponseRetentionError")) {
22530
- presentFailureTerminal(terminal, options.io);
22531
- return result2;
22532
- }
22533
- }
22534
22639
  if (autoResumeAttempts >= AUTO_RESUME_LIMIT) {
22535
- if (terminal !== void 0) {
22536
- if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
22537
- presentFailureTerminal(terminal, options.io);
22538
- } else {
22539
- options.io.stdout(formatTerminalResult(terminal));
22540
- }
22541
- }
22640
+ if (terminal !== void 0) presentTerminal(terminal, options.io);
22542
22641
  return result2;
22543
22642
  }
22544
22643
  if (!await isSessionPrincipalAvailable(options.admitted.sessionFile)) {
22545
- if (terminal !== void 0) {
22546
- if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
22547
- presentFailureTerminal(terminal, options.io);
22548
- } else {
22549
- options.io.stdout(formatTerminalResult(terminal));
22550
- }
22551
- }
22644
+ if (terminal !== void 0) presentTerminal(terminal, options.io);
22552
22645
  return result2;
22553
22646
  }
22554
22647
  autoResumeAttempts++;
@@ -22891,7 +22984,7 @@ async function runPublicCoderResume(argv, env, io) {
22891
22984
  const { admitted } = loaded;
22892
22985
  let lease;
22893
22986
  try {
22894
- lease = await acquireRunWriterLease(admitted.runDirectory);
22987
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
22895
22988
  } catch (error) {
22896
22989
  if (error instanceof RunWriterLeaseHeldError) {
22897
22990
  io.stderr(formatCliDiagnostic(error.message));
@@ -23155,7 +23248,7 @@ async function runPublicCollector(argv, env, io, parseCollectorArgv2) {
23155
23248
  await markRunAdmitted(admitted);
23156
23249
  let lease;
23157
23250
  try {
23158
- lease = await acquireRunWriterLease(admitted.runDirectory);
23251
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
23159
23252
  } catch (error) {
23160
23253
  if (error instanceof RunWriterLeaseHeldError) {
23161
23254
  presentStructuralRejection(error, io);
@@ -23390,7 +23483,7 @@ async function runPublicDoctor(argv, env, io, parseDoctorArgv2) {
23390
23483
  await markRunAdmitted(admitted);
23391
23484
  let lease;
23392
23485
  try {
23393
- lease = await acquireRunWriterLease(admitted.runDirectory);
23486
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
23394
23487
  } catch (error) {
23395
23488
  if (error instanceof RunWriterLeaseHeldError) {
23396
23489
  presentStructuralRejection(error, io);
@@ -23775,7 +23868,7 @@ async function runPublicFixerResume(argv, env, io) {
23775
23868
  const { admitted } = loaded;
23776
23869
  let lease;
23777
23870
  try {
23778
- lease = await acquireRunWriterLease(admitted.runDirectory);
23871
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
23779
23872
  } catch (error) {
23780
23873
  if (error instanceof RunWriterLeaseHeldError) {
23781
23874
  io.stderr(formatCliDiagnostic(error.message));
@@ -24137,7 +24230,7 @@ async function runPublicResume(argv, env, io) {
24137
24230
  const { admitted } = loaded;
24138
24231
  let lease;
24139
24232
  try {
24140
- lease = await acquireRunWriterLease(admitted.runDirectory);
24233
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
24141
24234
  } catch (error) {
24142
24235
  if (error instanceof RunWriterLeaseHeldError) {
24143
24236
  io.stderr(formatCliDiagnostic(error.message));
@@ -24586,7 +24679,7 @@ async function runPublicMergerResume(argv, env, io) {
24586
24679
  const { admitted } = loaded;
24587
24680
  let lease;
24588
24681
  try {
24589
- lease = await acquireRunWriterLease(admitted.runDirectory);
24682
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
24590
24683
  } catch (error) {
24591
24684
  if (error instanceof RunWriterLeaseHeldError) {
24592
24685
  io.stderr(formatCliDiagnostic(error.message));
@@ -25002,7 +25095,7 @@ async function runPublicReviewerResume(argv, env, io) {
25002
25095
  const { admitted } = loaded;
25003
25096
  let lease;
25004
25097
  try {
25005
- lease = await acquireRunWriterLease(admitted.runDirectory);
25098
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
25006
25099
  } catch (error) {
25007
25100
  if (error instanceof RunWriterLeaseHeldError) {
25008
25101
  io.stderr(formatCliDiagnostic(error.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.2157",
3
+ "version": "0.1.2169",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -10,6 +10,14 @@ import type { CliIo } from "./cli-io.ts";
10
10
 
11
11
  const dummyIo: CliIo = { stdout: () => {}, stderr: () => {} };
12
12
 
13
+ function presentTerminal(terminal: TerminalResult, io: CliIo): void {
14
+ if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
15
+ presentFailureTerminal(terminal, io);
16
+ } else {
17
+ io.stdout(formatTerminalResult(terminal));
18
+ }
19
+ }
20
+
13
21
  export type AutoResumeDispatchResult = {
14
22
  exitCode: number;
15
23
  terminal?: TerminalResult;
@@ -29,7 +37,9 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
29
37
  while (true) {
30
38
  let lease: RunWriterLease;
31
39
  try {
32
- lease = await acquireRunWriterLease(options.admitted.runDirectory);
40
+ lease = await acquireRunWriterLease(options.admitted.runDirectory, (diagnostic) =>
41
+ options.io.stderr(diagnostic),
42
+ );
33
43
  } catch (error) {
34
44
  if (error instanceof RunWriterLeaseHeldError) {
35
45
  presentStructuralRejection(error, options.io);
@@ -54,42 +64,12 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
54
64
  return result;
55
65
  }
56
66
 
57
- // Deterministic incomplete/audit_incomplete (merger/collector/judge) would duplicate callId bindings on retry and lose settlement
58
- if (terminal !== undefined && (terminal.roleOutcome.kind === "incomplete" || terminal.roleOutcome.kind === "audit_incomplete")) {
59
- if (terminal.roleOutcome.kind === "audit_incomplete") {
60
- options.io.stdout(formatTerminalResult(terminal));
61
- } else {
62
- options.io.stdout(formatTerminalResult(terminal));
63
- }
64
- return result;
65
- }
66
- // Rich auditor retention detail would be lost on retry (stale child binding)
67
- if (terminal !== undefined && terminal.roleOutcome.kind === "failure") {
68
- const terminalJson = JSON.stringify(terminal);
69
- if (terminalJson.includes("retentionFailure") || terminalJson.includes("ComplianceResponseRetentionError")) {
70
- presentFailureTerminal(terminal, options.io);
71
- return result;
72
- }
73
- }
74
-
75
67
  if (autoResumeAttempts >= AUTO_RESUME_LIMIT) {
76
- if (terminal !== undefined) {
77
- if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
78
- presentFailureTerminal(terminal, options.io);
79
- } else {
80
- options.io.stdout(formatTerminalResult(terminal));
81
- }
82
- }
68
+ if (terminal !== undefined) presentTerminal(terminal, options.io);
83
69
  return result;
84
70
  }
85
71
  if (!(await isSessionPrincipalAvailable(options.admitted.sessionFile))) {
86
- if (terminal !== undefined) {
87
- if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
88
- presentFailureTerminal(terminal, options.io);
89
- } else {
90
- options.io.stdout(formatTerminalResult(terminal));
91
- }
92
- }
72
+ if (terminal !== undefined) presentTerminal(terminal, options.io);
93
73
  return result;
94
74
  }
95
75
 
@@ -548,7 +548,7 @@ export async function runPublicCoderResume(
548
548
 
549
549
  let lease: RunWriterLease;
550
550
  try {
551
- lease = await acquireRunWriterLease(admitted.runDirectory);
551
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
552
552
  } catch (error) {
553
553
  if (error instanceof RunWriterLeaseHeldError) {
554
554
  // Concurrent resume: reject without second writer or dispatch.
@@ -355,7 +355,7 @@ export async function runPublicCollector(
355
355
 
356
356
  let lease: RunWriterLease;
357
357
  try {
358
- lease = await acquireRunWriterLease(admitted.runDirectory);
358
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
359
359
  } catch (error) {
360
360
  if (error instanceof RunWriterLeaseHeldError) {
361
361
  presentStructuralRejection(error, io);
@@ -334,7 +334,7 @@ export async function runPublicDoctor(
334
334
 
335
335
  let lease: RunWriterLease;
336
336
  try {
337
- lease = await acquireRunWriterLease(admitted.runDirectory);
337
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
338
338
  } catch (error) {
339
339
  if (error instanceof RunWriterLeaseHeldError) {
340
340
  presentStructuralRejection(error, io);
@@ -567,7 +567,7 @@ export async function runPublicFixerResume(
567
567
 
568
568
  let lease: RunWriterLease;
569
569
  try {
570
- lease = await acquireRunWriterLease(admitted.runDirectory);
570
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
571
571
  } catch (error) {
572
572
  if (error instanceof RunWriterLeaseHeldError) {
573
573
  io.stderr(formatCliDiagnostic(error.message));
@@ -536,7 +536,7 @@ export async function runPublicResume(
536
536
 
537
537
  let lease: RunWriterLease;
538
538
  try {
539
- lease = await acquireRunWriterLease(admitted.runDirectory);
539
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
540
540
  } catch (error) {
541
541
  if (error instanceof RunWriterLeaseHeldError) {
542
542
  // Concurrent resume: reject without second writer or dispatch.
@@ -648,7 +648,7 @@ export async function runPublicMergerResume(
648
648
 
649
649
  let lease: RunWriterLease;
650
650
  try {
651
- lease = await acquireRunWriterLease(admitted.runDirectory);
651
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
652
652
  } catch (error) {
653
653
  if (error instanceof RunWriterLeaseHeldError) {
654
654
  io.stderr(formatCliDiagnostic(error.message));
@@ -587,7 +587,7 @@ export async function runPublicReviewerResume(
587
587
 
588
588
  let lease: RunWriterLease;
589
589
  try {
590
- lease = await acquireRunWriterLease(admitted.runDirectory);
590
+ lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
591
591
  } catch (error) {
592
592
  if (error instanceof RunWriterLeaseHeldError) {
593
593
  io.stderr(formatCliDiagnostic(error.message));
@@ -340,13 +340,52 @@ export type RunWriterLease = {
340
340
  release(): Promise<void>;
341
341
  };
342
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
+
343
364
  /**
344
365
  * Acquire the one-writer lease for a Role run. Concurrent acquire rejects
345
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.
346
372
  */
347
373
  export async function acquireRunWriterLease(
348
374
  runDirectory: string,
375
+ onCleanupFailure?: (diagnostic: string) => void,
349
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
+ };
350
389
  const lockPath = join(runDirectory, WRITER_LOCK_FILE);
351
390
  try {
352
391
  const handle = await open(lockPath, "wx");
@@ -371,7 +410,14 @@ export async function acquireRunWriterLease(
371
410
  try {
372
411
  await chmod(runDirectory, 0o755);
373
412
  await unlink(lockPath);
374
- } catch {}
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);
375
421
  }
376
422
  }
377
423
  },
@@ -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