@akagilnc/pi-workflow-roles 0.1.5382 → 0.1.5462

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +4 -2
  2. package/README.zh-CN.md +4 -2
  3. package/dist/acp-host/production-host.js +8843 -9012
  4. package/dist/archivist-record-pointer.js +38 -0
  5. package/dist/compliance-transport.js +7 -5
  6. package/dist/doctor-auditor.js +1 -0
  7. package/dist/engine-detour-usage.js +9 -36
  8. package/dist/gatekeeper-role.js +4 -18
  9. package/dist/headless-host/description.js +0 -209
  10. package/dist/headless-host/production-host.js +7828 -8247
  11. package/dist/host-contracts.js +8 -0
  12. package/dist/judge-role.js +14 -1
  13. package/dist/migrate-book-topology.js +215 -96
  14. package/dist/navigator-attendance.js +10 -9
  15. package/dist/navigator-invocation-identity.js +18 -0
  16. package/dist/packaged-role-registry.js +1 -9
  17. package/dist/pi/role-turn-host.js +46 -19
  18. package/dist/public-cli/auto-resume.js +29 -66
  19. package/dist/public-cli/cli.js +1 -0
  20. package/dist/public-cli/countersign-run.js +4 -18
  21. package/dist/public-cli/instruction-seat-run.js +258 -69
  22. package/dist/public-cli/main.js +7488 -7758
  23. package/dist/public-cli/post-admission.js +451 -349
  24. package/dist/public-cli/process-cancel.js +8 -0
  25. package/dist/public-cli/run-lifecycle.js +24 -94
  26. package/dist/public-cli/settlement.js +624 -1471
  27. package/dist/public-cli/terminal.js +34 -53
  28. package/dist/public-cli/turn-request.js +3 -0
  29. package/dist/public-role-summons.js +3 -8
  30. package/dist/receipt-delivery-policy.js +162 -28
  31. package/dist/run-terminal-artifacts.js +19 -14
  32. package/dist/serialize-thrown-value.js +7 -1
  33. package/dist/submission-errors.js +11 -1
  34. package/dist/submission-gate.js +27 -4
  35. package/dist/submission-ledger.js +21 -89
  36. package/package.json +1 -1
  37. package/scripts/build-package.mjs +0 -2
  38. package/src/acp-host/role-turn-host.ts +168 -75
  39. package/src/archivist-record-pointer.ts +48 -0
  40. package/src/compliance-transport.ts +13 -8
  41. package/src/doctor-auditor.ts +3 -0
  42. package/src/engine-detour-usage.ts +10 -39
  43. package/src/external-host-turn-loop.ts +100 -43
  44. package/src/gatekeeper-role.ts +7 -22
  45. package/src/headless-host/description.ts +4 -214
  46. package/src/headless-host/production-host.ts +2 -1
  47. package/src/headless-host/role-turn-host.ts +153 -186
  48. package/src/host-contracts.ts +20 -10
  49. package/src/judge-role.ts +24 -5
  50. package/src/navigator-attendance.ts +17 -12
  51. package/src/navigator-invocation-identity.ts +31 -0
  52. package/src/navigator-public-session.ts +1 -1
  53. package/src/navigator-session-contracts.ts +1 -1
  54. package/src/packaged-role-registry.ts +2 -10
  55. package/src/pi/adapter.ts +33 -11
  56. package/src/pi/role-turn-host.ts +49 -28
  57. package/src/prepared-role-turn.ts +2 -0
  58. package/src/public-cli/auto-resume.ts +27 -66
  59. package/src/public-cli/cli.ts +1 -0
  60. package/src/public-cli/countersign-run.ts +4 -22
  61. package/src/public-cli/instruction-seat-run.ts +307 -74
  62. package/src/public-cli/post-admission.ts +615 -459
  63. package/src/public-cli/process-cancel.ts +9 -0
  64. package/src/public-cli/run-lifecycle.ts +25 -126
  65. package/src/public-cli/settlement.ts +801 -1859
  66. package/src/public-cli/terminal.ts +42 -70
  67. package/src/public-cli/turn-request.ts +10 -1
  68. package/src/public-role-summons.ts +3 -8
  69. package/src/receipt-delivery-policy.ts +219 -28
  70. package/src/role-envelope.ts +21 -10
  71. package/src/role-runtime-dependencies.ts +1 -0
  72. package/src/role-runtime.ts +79 -102
  73. package/src/run-terminal-artifacts.ts +21 -17
  74. package/src/serialize-thrown-value.ts +7 -1
  75. package/src/submission-correctable-error.ts +5 -2
  76. package/src/submission-errors.ts +12 -1
  77. package/src/submission-gate.ts +34 -4
  78. package/src/submission-ledger.ts +30 -116
  79. package/src/worker-role.ts +19 -7
  80. package/src/worker-submission-gates.ts +29 -7
  81. package/dist/audit-escalation.js +0 -7
  82. package/dist/pi/known-failure.js +0 -51
  83. package/dist/typed-provider-http.js +0 -57
  84. package/dist/upstream-error-testimony.js +0 -19
  85. package/src/pi/known-failure.ts +0 -76
  86. package/src/typed-provider-http.ts +0 -89
  87. package/src/upstream-error-testimony.ts +0 -45
@@ -4,97 +4,67 @@
4
4
  * Controlled failures and audit human decisions settle here without washing causes.
5
5
  */
6
6
  import { randomUUID } from "node:crypto";
7
- import { appendFile, lstat, readFile, readdir, rm, unlink, writeFile } from "node:fs/promises";
8
7
  import { dirname, join } from "node:path";
8
+ import { lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
9
 
10
+ import { writeHardenedArtifactFile } from "./auto-resume.ts";
10
11
  import {
11
12
  readAnalystGateCyclesFromAuditorRoles,
12
13
  type AnalystGateCycleRound,
13
14
  } from "../analyst-gate-cycles-read.ts";
14
- import { readSitianRecords, resolveSitianRecordPath, sitianReport } from "../sitian-facade.ts";
15
+ import { sitianReport } from "../sitian-facade.ts";
15
16
 
16
17
  import {
17
- hasFreshAttemptSubmission,
18
18
  readAttemptScopedSubmissionRows,
19
19
  readRecordedSubmissionRows,
20
20
  readRecordedSubmissions,
21
21
  } from "../submission-ledger.ts";
22
22
 
23
- import { isAuditEscalationResult } from "../audit-escalation.ts";
24
- import { AUDITOR_SOUL_ROLES } from "../auditor-soul.ts";
25
- import type { RoleTurnKnownFailure } from "../host-contracts.ts";
26
- import { knownFailureFromProviderStop } from "../pi/known-failure.ts";
27
- import { serializeThrownValue } from "../serialize-thrown-value.ts";
28
- import {
29
- isV1ResumableProvider,
30
- readLatestTypedProviderHttpObservation,
31
- readTypedHttp429Observation,
32
- type TypedHttp429Observation,
33
- type TypedProviderHttpObservation,
34
- } from "./run-lifecycle.ts";
23
+ import { CONTROLLED_FAILURE_CAUSES, type RoleTurnKnownFailure } from "../host-contracts.ts";
35
24
  import {
36
- AUDITOR_COMPLIANCE_FAILURE_ENTRY_TYPE,
37
- AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE,
38
- COMPLIANCE_RESPONSE_ENTRY_TYPE,
39
- readComplianceCandidate,
40
- type ComplianceDecision,
41
- } from "../compliance-transport.ts";
42
- // COMPLIANCE_RESPONSE_ENTRY_TYPE remains for boundRetainedAuditResponse (call/result
43
- // interval binding on historical session bytes). Provider-stop retain authority is Sitian.
44
- import {
45
- ENGINE_DETOUR_TOOL_NAME,
46
- } from "../engine-detour.ts";
25
+ childSignalDeathDiagnostic,
26
+ processCancelDiagnostic,
27
+ type CatchableProcessSignal,
28
+ } from "./process-cancel.ts";
29
+ import { serializeThrownValue } from "../serialize-thrown-value.ts";
30
+ import { describeErrorIdentity } from "./run-lifecycle.ts";
47
31
  import {
48
- projectEngineDetourToolUsageForPublicTerminal,
49
32
  readEngineDetourToolUsage,
50
33
  readInvocationEngineMounted,
51
34
  runDirectoryFromSessionDirectory,
52
35
  sessionFileFromSessionDirectory,
53
36
  withEngineDetourToolUsageFact,
54
37
  } from "../engine-detour-usage.ts";
55
- import {
56
- JUDGE_OUTPUT_TOOL_NAME,
57
- type JudgeVerdict,
58
- } from "../package-contracts/judge-output.ts";
59
- import {
60
- CODER_OUTPUT_TOOL_NAME,
61
- FIXER_OUTPUT_TOOL_NAME,
62
- } from "../package-contracts/worker-output.ts";
63
38
 
64
- import {
65
- DOCTOR_OUTPUT_TOOL_NAME,
66
- type DoctorCaseCost,
67
- } from "../doctor-contracts.ts";
39
+ import type { DoctorCaseCost } from "../doctor-contracts.ts";
68
40
  import { DOCTOR_CANDIDATE_ENTRY_TYPE } from "../dossier-resolution.ts";
69
41
  import {
70
- REVIEWER_OUTPUT_TOOL_NAME,
71
- } from "../package-contracts/reviewer-output.ts";
72
- import {
73
- packagedAuditToolName,
74
- packagedDurableOfficerEntry,
75
- packagedRoleAcceptedOutputTool,
76
- packagedRoleMetadata,
77
- packagedSkipsGateOnInfrastructureStage,
78
- type PackagedArtifactFace,
79
- type PackagedArtifactLeaf,
80
- type PackagedRole,
81
- } from "../packaged-role-registry.ts";
42
+ ensureRunArtifactsDir,
43
+ homeFromRunDirectory,
44
+ type AdmittedRoleInvocation,
45
+ } from "./invocation.ts";
82
46
  import {
83
47
  SECRETARIAT_COUNTERSIGN_TERMINAL_FACT_KEY,
84
48
  SECRETARIAT_GATE_OFFICER_ENTRY_TYPE,
85
49
  } from "../secretariat-contracts.ts";
86
50
  import {
87
- classifyPackagedRoleTerminalResult,
88
51
  findLatestDurablePackagedRoleTerminal,
89
- hasNavigatorInfrastructureFailureBase,
90
- isAcceptedPackagedRoleTerminalResult,
91
52
  NAVIGATOR_ROUTE_PLAYBOOK_FAILURE_ENTRY,
92
53
  } from "../navigator-invocation-identity.ts";
54
+ import {
55
+ packagedDurableOfficerEntry,
56
+ packagedRoleAcceptedOutputTool,
57
+ packagedRoleMetadata,
58
+ packagedSkipsGateOnInfrastructureStage,
59
+ type PackagedArtifactFace,
60
+ type PackagedArtifactLeaf,
61
+ } from "../packaged-role-registry.ts";
93
62
  import {
94
63
  NO_RECEIPT_LIFECYCLE_ENTRY_TYPE,
95
- RECEIPT_DELIVERY_TURN_LIMIT,
96
64
  noReceiptLifecycleFacts,
97
65
  parseNoReceiptLifecycleFacts,
66
+ priorReceiptContinuation,
67
+ receiptAttemptPointer,
98
68
  type NoReceiptLifecycleFacts,
99
69
  } from "../receipt-delivery-policy.ts";
100
70
  import type {
@@ -103,18 +73,17 @@ import type {
103
73
  DurablePrincipalCoordinates,
104
74
  } from "../host-contracts.ts";
105
75
  import { roleRunArtifactsDirectory } from "../role-run-placement.ts";
106
- import { rewriteRunDirectoryPathValue } from "../role-run-relocation.ts";
107
76
  import {
108
77
  listSeamOwnedUniqueErrorFacePaths,
78
+ uniqueErrorFallbackName,
79
+ UNIQUE_ERROR_FALLBACK_STEM,
109
80
  RUN_TERMINAL_ARTIFACT_FILES,
110
81
  RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS,
82
+ RUN_TERMINAL_REPORT_FILE,
83
+ RUN_TERMINAL_ERROR_FILE,
84
+ RUN_TERMINAL_EVIDENCE_FILE,
85
+ RUN_TERMINAL_ERROR_SETTLEMENT_FILE,
111
86
  } from "../run-terminal-artifacts.ts";
112
- import {
113
- ensureRunArtifactsDir,
114
- homeFromRunDirectory,
115
- type AdmittedJudgeInvocation,
116
- type AdmittedRoleInvocation,
117
- } from "./invocation.ts";
118
87
 
119
88
  /** Ledger reads use the run's machine home — not ambient process HOME (child write vs parent settle). */
120
89
  function sealedLedgerHome(admitted: Pick<AdmittedRoleInvocation, "runDirectory">): string {
@@ -126,12 +95,123 @@ function sealedLedgerHome(admitted: Pick<AdmittedRoleInvocation, "runDirectory">
126
95
  * courtAttemptId tags the new court; recorded payloads stay run-scoped (#836).
127
96
  */
128
97
  export type SettlementCourtScope = {
98
+ /** Only the dispatched host turn may record this attempt; later reads are projections. */
99
+ readonly recordAttemptHistory?: true;
100
+ /** Inspect a candidate without rewriting history or terminal artifact faces. */
101
+ readonly previewOnly?: true;
129
102
  readonly courtAttemptId?: string;
130
103
  /** Public-invocation scope from the shared Host envelope (#537). */
131
104
  readonly invocationScopeId?: string;
105
+ /**
106
+ * Package fault beside an already chosen terminal. The host report stays.
107
+ * Bound by the public court to the one post-dispatch receiver.
108
+ */
109
+ readonly notePackageFault?: (diagnostic: string) => void | Promise<void>;
132
110
  };
133
111
 
134
- function ledgerReadScope(
112
+ /**
113
+ * Hardened artifacts directory. A planted symlink at the run directory or the
114
+ * artifacts path must not receive a durable run artifact.
115
+ */
116
+ export async function ensureRealArtifactsDirectory(runDirectory: string): Promise<string> {
117
+ const runStat = await lstat(runDirectory);
118
+ if (runStat.isSymbolicLink() || !runStat.isDirectory()) {
119
+ throw new Error("run artifact retention: run directory is not a real directory");
120
+ }
121
+ const artifactsDir = roleRunArtifactsDirectory(runDirectory);
122
+ try {
123
+ const existing = await lstat(artifactsDir);
124
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
125
+ throw new Error("run artifact retention: artifacts path is not a real directory");
126
+ }
127
+ } catch (error) {
128
+ if (!isEnoent(error)) throw error;
129
+ await mkdir(artifactsDir, { recursive: true });
130
+ const created = await lstat(artifactsDir);
131
+ if (created.isSymbolicLink() || !created.isDirectory()) {
132
+ throw new Error("run artifact retention: artifacts directory is not a real directory");
133
+ }
134
+ }
135
+ return artifactsDir;
136
+ }
137
+
138
+ /**
139
+ * One retention path for a package fault beside an already chosen terminal.
140
+ * Session append is first when the caller has that channel. The artifact file
141
+ * is only the fallback after that append throws. Either failure is reported;
142
+ * neither replaces the terminal.
143
+ */
144
+ export async function retainPackageFault(input: {
145
+ readonly runDirectory: string;
146
+ readonly diagnostic: string;
147
+ /** Original package exception, independent of the host report. */
148
+ readonly error?: unknown;
149
+ readonly appendSession?: (payload: {
150
+ readonly diagnostic: string;
151
+ readonly recordedAt: string;
152
+ }) => Promise<void>;
153
+ readonly stderr?: (text: string) => void;
154
+ }): Promise<void> {
155
+ const payload = {
156
+ diagnostic: input.diagnostic,
157
+ recordedAt: new Date().toISOString(),
158
+ ...(Object.hasOwn(input, "error") ? { failure: projectThrownFailureLeaf(input.error) } : {}),
159
+ };
160
+ let retentionFailure: string | undefined;
161
+ const writeArtifact = async (appendFailure?: ControlledFailure): Promise<void> => {
162
+ const artifactsDir = await ensureRealArtifactsDirectory(input.runDirectory);
163
+ await writeHardenedArtifactFile(artifactsDir, "post-admission-diagnostic", {
164
+ version: 1,
165
+ ...payload,
166
+ ...(appendFailure === undefined ? {} : { retentionFailure: appendFailure }),
167
+ });
168
+ };
169
+ if (input.appendSession !== undefined) {
170
+ try {
171
+ await input.appendSession(payload);
172
+ } catch (appendError) {
173
+ retentionFailure =
174
+ `post-dispatch diagnostic session append failed (best-effort continue): dossier=${describeErrorIdentity(appendError)}`;
175
+ try {
176
+ await writeArtifact(projectThrownFailureLeaf(appendError));
177
+ } catch (artifactError) {
178
+ retentionFailure =
179
+ `post-dispatch diagnostic durable retention failed on both channels (best-effort continue): dossier=${describeErrorIdentity(appendError)}; artifact=${describeErrorIdentity(artifactError)}`;
180
+ }
181
+ }
182
+ } else {
183
+ try {
184
+ await writeArtifact();
185
+ } catch (artifactError) {
186
+ retentionFailure =
187
+ `post-dispatch diagnostic durable retention failed (best-effort continue): artifact=${describeErrorIdentity(artifactError)}`;
188
+ }
189
+ }
190
+ const stderr = input.stderr ?? ((text: string) => {
191
+ process.stderr.write(text);
192
+ });
193
+ try {
194
+ stderr(formatCliDiagnostic(input.diagnostic));
195
+ if (retentionFailure !== undefined) stderr(formatCliDiagnostic(retentionFailure));
196
+ } catch {
197
+ // Presentation is best-effort beside an already-formed host terminal.
198
+ }
199
+ }
200
+
201
+ /** Record a package fault without letting the note replace the terminal. */
202
+ export async function noteSettlementFault(
203
+ runDirectory: string,
204
+ scope: SettlementCourtScope | undefined,
205
+ diagnostic: string,
206
+ ): Promise<void> {
207
+ if (scope?.notePackageFault !== undefined) {
208
+ await scope.notePackageFault(diagnostic);
209
+ return;
210
+ }
211
+ await retainPackageFault({ runDirectory, diagnostic });
212
+ }
213
+
214
+ export function ledgerReadScope(
135
215
  admitted: Pick<AdmittedRoleInvocation, "runDirectory">,
136
216
  scope?: SettlementCourtScope,
137
217
  ): { home: string; sessionParent: string; attemptId?: string } {
@@ -207,29 +287,6 @@ async function sealedLedgerOutcome(
207
287
  return roleOutcomeFromRows(role, rows);
208
288
  }
209
289
 
210
- /**
211
- * True when this court/attempt itself already produced a sealed or
212
- * audit-escalation submission (#836 r12 class 2). Presentation
213
- * (submissions/payloads) always stays run-scoped and unfiltered — this is a
214
- * control-flow signal only, consumed to keep a stale prior-attempt
215
- * acceptance from outranking this attempt's own real host-turn failure.
216
- * Absent a courtAttemptId scope there is no distinct prior attempt to stale
217
- * against, so this defaults true (unchanged behavior for ordinary,
218
- * non-court dispatches).
219
- */
220
- export async function attemptProducedFreshSubmission(
221
- admitted: AdmittedRoleInvocation,
222
- scope?: SettlementCourtScope,
223
- ): Promise<boolean> {
224
- if (scope?.courtAttemptId === undefined || scope.courtAttemptId.length === 0) return true;
225
- return hasFreshAttemptSubmission(
226
- admitted.projectRoot,
227
- admitted.runId,
228
- scope.courtAttemptId,
229
- ledgerReadScope(admitted, scope),
230
- );
231
- }
232
-
233
290
  /** #836: every raw role payload in settle scope (调几次记几次). */
234
291
  export async function recordedSubmissionPayloads(
235
292
  admitted: Pick<AdmittedRoleInvocation, "projectRoot" | "runId" | "runDirectory">,
@@ -247,23 +304,8 @@ export function withSubmissions<T extends TerminalResult>(
247
304
  submissions: readonly unknown[],
248
305
  ): T {
249
306
  if (submissions.length === 0) return terminal;
250
- const roleOutcome = terminal.roleOutcome;
251
- // #881 / #836: full run history always lands on terminal.submissions.
252
- // #879: accepted/audit_escalation may still fill a missing this-court
253
- // payloads face from that history. #953: failure must not — prior receipts
254
- // stay on the historical carrier only, never as the current failure's
255
- // ordinary payloads/receipt face.
256
- if (roleOutcome.kind === "failure") {
257
- return { ...terminal, submissions };
258
- }
259
- const withPayloads =
260
- roleOutcome.kind === "accepted" || roleOutcome.kind === "audit_escalation"
261
- ? {
262
- ...roleOutcome,
263
- payloads: coalesceSubmissionRows(roleOutcome.payloads, submissions),
264
- }
265
- : roleOutcome;
266
- return { ...terminal, roleOutcome: withPayloads, submissions };
307
+ // Run history is presentation, never a substitute for this court's reply (#879).
308
+ return { ...terminal, submissions };
267
309
  }
268
310
 
269
311
  /** Attach full ledger submissions onto any settled terminal (#836). */
@@ -276,13 +318,19 @@ export async function attachRecordedSubmissions<T extends TerminalResult>(
276
318
  // pass courtAttemptId here — roleOutcome already carries this-court payloads
277
319
  // from sealedLedgerOutcome when scoped; withSubmissions keeps them.
278
320
  void scope;
279
- const result = withSubmissions(
280
- terminal,
281
- await recordedSubmissionPayloads(admitted, undefined),
282
- );
283
- const errorPath = privateFailureErrorPaths.get(terminal);
284
- if (errorPath !== undefined) privateFailureErrorPaths.set(result, errorPath);
285
- return result;
321
+ try {
322
+ return withSubmissions(
323
+ terminal,
324
+ await recordedSubmissionPayloads(admitted, undefined),
325
+ );
326
+ } catch (error) {
327
+ await noteSettlementFault(
328
+ admitted.runDirectory,
329
+ scope,
330
+ `submission history read failed beside host terminal: ${describeErrorIdentity(error)}`,
331
+ );
332
+ return terminal;
333
+ }
286
334
  }
287
335
 
288
336
  /**
@@ -294,32 +342,143 @@ export async function settleHostEndedNoReceipt(
294
342
  admitted: AdmittedRoleInvocation,
295
343
  authority: DurablePrincipalAuthority,
296
344
  scope?: SettlementCourtScope,
345
+ /**
346
+ * #1132: delivery requests this run actually issued. Zero stays zero when
347
+ * nothing was sent and this attempt has no lifecycle fact. A fact already
348
+ * written for this run and attempt supplies the count when it is larger.
349
+ * The budget is never the count, and another loop's resumes are not催交.
350
+ */
351
+ issuedDeliveryRequests = 0,
297
352
  ): Promise<TerminalResult> {
298
- const facts = noReceiptLifecycleFacts({
299
- terminalToolCalled: false,
300
- rejectedReceipts: [],
301
- deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
302
- runPointer: admitted.runDirectory,
303
- attemptPointer: `current:${admitted.runDirectory}`,
304
- });
305
353
  const coordinates = coordinatesFromAdmitted(authority, admitted);
306
- await clearOppositeTerminalArtifactFace(admitted.runDirectory);
307
- return withOptionalGateProjection(
308
- {
309
- roleOutcome: {
310
- kind: "no_receipt",
311
- role: admitted.role,
312
- status: "no-accepted-receipt",
313
- ...facts,
314
- decisiveFacts: facts,
315
- },
316
- navigator: await extractNavigatorFactFromAdmittedSession(coordinates.sessionFile),
317
- artifacts: [],
318
- runId: admitted.runId,
354
+ const binding = {
355
+ runPointer: admitted.runDirectory,
356
+ attemptPointer: receiptAttemptPointer(admitted.runDirectory, scope?.invocationScopeId),
357
+ };
358
+ // The delivery owner records what actually happened this attempt — whether the
359
+ // terminal tool was called and what was rejected. Present those facts; a fixed
360
+ // `false` / `[]` would assert an empty delivery that never happened
361
+ // (#1032: 无回执如实呈 no_receipt; 单一真源).
362
+ const recorded = await readRecordedNoReceiptFacts(coordinates.sessionFile, binding, scope?.invocationScopeId);
363
+ if (recorded instanceof LifecycleReadFailure) {
364
+ // A present record that cannot be read is not an empty delivery. Omit the
365
+ // facts and keep the real error beside the no_receipt terminal.
366
+ await noteSettlementFault(
367
+ admitted.runDirectory,
368
+ scope,
369
+ `no-receipt lifecycle record could not be read: ${describeErrorIdentity(recorded.error ?? recorded)}`,
370
+ );
371
+ return settleNoReceiptTerminal(admitted, authority, scope, undefined);
372
+ }
373
+ return settleNoReceiptTerminal(
374
+ admitted,
375
+ authority,
376
+ scope,
377
+ noReceiptLifecycleFacts({
378
+ terminalToolCalled: recorded?.terminalToolCalled ?? false,
379
+ rejectedReceipts: recorded?.rejectedReceipts ?? [],
380
+ deliveryTurns: Math.max(issuedDeliveryRequests, recorded?.deliveryTurns ?? 0),
381
+ ...binding,
382
+ }),
383
+ );
384
+ }
385
+
386
+ /** A lifecycle record that exists but could not be read or parsed. */
387
+ export class LifecycleReadFailure extends Error {
388
+ /** The underlying read or parse failure, kept whole. */
389
+ readonly error: unknown;
390
+ constructor(message: string, options?: { cause?: unknown }) {
391
+ super(message);
392
+ this.name = "LifecycleReadFailure";
393
+ this.error = options?.cause;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * The delivery owner's own lifecycle record for this attempt.
399
+ * - undefined: the owner wrote no record (genuine absence).
400
+ * - LifecycleReadFailure: a record exists but could not be read or parsed.
401
+ */
402
+ async function readRecordedNoReceiptFacts(
403
+ sessionFile: string,
404
+ binding: { runPointer: string; attemptPointer: string },
405
+ invocationScopeId?: string,
406
+ ): Promise<NoReceiptLifecycleFacts | undefined | LifecycleReadFailure> {
407
+ let entries: readonly SessionEntry[];
408
+ try {
409
+ entries = await readBoundSessionEntries(sessionFile);
410
+ } catch (error) {
411
+ // A session that was never written is genuine absence; anything else is a
412
+ // real read failure about this run.
413
+ if (isEnoent(error)) return undefined;
414
+ return new LifecycleReadFailure(
415
+ `session transcript unreadable: ${errorText(error)}`,
416
+ { cause: error },
417
+ );
418
+ }
419
+ const scoped = invocationScopeId !== undefined && invocationScopeId.trim() !== "";
420
+ const entry = (scoped ? entries : entries.slice(currentAttemptStartIndex(entries))).slice().reverse().find(
421
+ (item: SessionEntry) => {
422
+ if (item.customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE
423
+ && item.message?.customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE) return false;
424
+ const raw = item.data ?? item.message?.details;
425
+ return !scoped || !isRecord(raw) || raw.attemptPointer === undefined
426
+ || raw.attemptPointer === binding.attemptPointer;
319
427
  },
320
- coordinates.sessionDirectory,
321
- detourGateContext(admitted, scope),
322
428
  );
429
+ const raw = entry?.data ?? entry?.message?.details;
430
+ if (raw === undefined) {
431
+ if (!scoped) return undefined;
432
+ const prior = priorReceiptContinuation(entries, invocationScopeId!);
433
+ return noReceiptLifecycleFacts({ ...prior, ...binding });
434
+ }
435
+ try {
436
+ const facts = parseNoReceiptLifecycleFacts(raw);
437
+ return facts.runPointer === binding.runPointer
438
+ && facts.attemptPointer === binding.attemptPointer
439
+ ? (scoped ? noReceiptLifecycleFacts({ ...priorReceiptContinuation(entries, invocationScopeId!), ...binding }) : facts)
440
+ : undefined;
441
+ } catch (error) {
442
+ return new LifecycleReadFailure(
443
+ `no-receipt lifecycle record is malformed: ${errorText(error)}`,
444
+ { cause: error },
445
+ );
446
+ }
447
+ }
448
+
449
+ async function settleNoReceiptTerminal(
450
+ admitted: AdmittedRoleInvocation,
451
+ authority: DurablePrincipalAuthority,
452
+ scope: SettlementCourtScope | undefined,
453
+ facts: NoReceiptLifecycleFacts | undefined,
454
+ ): Promise<TerminalResult> {
455
+ const coordinates = coordinatesFromAdmitted(authority, admitted);
456
+ const roleOutcome: TerminalRoleOutcome = facts === undefined
457
+ ? {
458
+ kind: "no_receipt", role: admitted.role, status: "no-accepted-receipt",
459
+ decisiveFacts: {},
460
+ }
461
+ : {
462
+ kind: "no_receipt", role: admitted.role, status: "no-accepted-receipt",
463
+ ...facts, decisiveFacts: facts,
464
+ };
465
+ if (scope?.recordAttemptHistory === true) {
466
+ await appendRunAttemptHistory(
467
+ { role: admitted.role, runId: admitted.runId, sessionFile: coordinates.sessionFile },
468
+ roleOutcome,
469
+ );
470
+ }
471
+ if (scope?.previewOnly !== true) await clearOppositeTerminalArtifactFace(admitted.runDirectory);
472
+ return withOptionalGateProjection({
473
+ roleOutcome,
474
+ navigator: await extractNavigatorFactFromAdmittedSession(
475
+ coordinates.sessionFile,
476
+ admitted.runDirectory,
477
+ scope,
478
+ ),
479
+ artifacts: [],
480
+ runId: admitted.runId,
481
+ }, coordinates.sessionDirectory, detourGateContext(admitted, scope));
323
482
  }
324
483
 
325
484
  /** Transitional host-session reads remain only for non-sealed failure and audit evidence. */
@@ -330,7 +489,6 @@ function coordinatesFromAdmitted(
330
489
  return authority.decode(admitted.principal);
331
490
  }
332
491
  import {
333
- coalesceSubmissionRows,
334
492
  exitCodeForTerminalOutcome,
335
493
  formatTerminalResult,
336
494
  isLawfulTypedTerminalOutcome,
@@ -341,12 +499,11 @@ import {
341
499
  type TerminalGateSeat,
342
500
  type TerminalNavigatorFact,
343
501
  type TerminalResult,
344
- type TerminalResume,
345
502
  type TerminalRoleName,
346
503
  type TerminalRoleOutcome,
347
504
  } from "./terminal.ts";
348
505
 
349
- import { isEnoent, isMissingPathError, isRecord } from "../unknown-value.ts";
506
+ import { isEnoent, isMissingPathError, isRecord, errorText } from "../unknown-value.ts";
350
507
 
351
508
  export type { ControlledFailureCause };
352
509
 
@@ -356,39 +513,55 @@ export {
356
513
  isLawfulTypedTerminalOutcome,
357
514
  };
358
515
 
359
- /** Preserved post-admission failure (not a role Receipt). */
360
- export type ControlledFailure = {
361
- /** Typed class only when confirmed; omitted when unknown (#881 — no fabricated label). */
362
- readonly cause?: ControlledFailureCause;
516
+ /**
517
+ * Preserved post-admission failure (not a role Receipt). The host contract owns
518
+ * the shape; the only difference settlement requires is that a settled failure
519
+ * always carries a diagnostic to present, so `cause` and `identity` stay
520
+ * omitted when no typed fact confirms them (#881 — no fabricated label).
521
+ */
522
+ export type ControlledFailure = RoleTurnKnownFailure & {
363
523
  readonly diagnostic: string;
364
- readonly identity?: {
365
- readonly name?: string;
366
- readonly code?: string | number;
367
- };
368
- readonly details?: Readonly<Record<string, unknown>>;
524
+ /**
525
+ * Real facts this package observed while handling the call, kept beside the
526
+ * host's report rather than inside its open `details`. `details` belongs to
527
+ * the host: a key written there can collide with a key the host itself
528
+ * carries, and the host's value would be the one lost (host-contracts.ts:41
529
+ * — an open record with no reserved keys).
530
+ */
531
+ readonly packageFact?: PackageSideFact;
532
+ /**
533
+ * Host stderr that is not already this failure's diagnostic. A stderr.log
534
+ * or artifact write can fail; the original bytes stay on this object.
535
+ */
536
+ readonly stderr?: string;
369
537
  };
370
538
 
371
- // A resumable public Terminal omits artifact paths (#108); its actual error
372
- // publication remains available only to the CLI's resume presentation seam.
373
- const privateFailureErrorPaths = new WeakMap<TerminalResult, string>();
374
-
375
- export function publishedFailureErrorPath(terminal: TerminalResult): string | undefined {
376
- return privateFailureErrorPaths.get(terminal);
377
- }
378
-
379
- export function rewritePublishedFailureErrorPath(
380
- terminal: TerminalResult,
381
- oldRunDirectory: string,
382
- newRunDirectory: string,
383
- ): void {
384
- const errorPath = privateFailureErrorPaths.get(terminal);
385
- if (errorPath !== undefined) {
386
- privateFailureErrorPaths.set(
387
- terminal,
388
- rewriteRunDirectoryPathValue(errorPath, oldRunDirectory, newRunDirectory) as string,
389
- );
390
- }
391
- }
539
+ /**
540
+ * Facts the package itself established, each kept whole. None of them is ever
541
+ * promoted into the host's cause, diagnostic or details.
542
+ */
543
+ export type PackageSideFact = {
544
+ /**
545
+ * This package's own view of the host's exit for this call. Kept beside the
546
+ * host's `details`, never inside it.
547
+ */
548
+ readonly exitCode?: number | null;
549
+ readonly timedOut?: boolean;
550
+ /**
551
+ * The signal that ended the host child on this call, when the host reported
552
+ * one. Beside the host's details, never inside them.
553
+ */
554
+ readonly signal?: string;
555
+ /**
556
+ * Catchable process signal this call actually received. Beside the host's
557
+ * report, never a replacement for it.
558
+ */
559
+ readonly cancelName?: string;
560
+ /** An exception caught after the host already reported this call. */
561
+ readonly thrown?: RoleTurnKnownFailure & { readonly diagnostic: string };
562
+ /** A durable stderr.log write that failed while handling this call. */
563
+ readonly stderrLogWriteFailure?: unknown;
564
+ };
392
565
 
393
566
  /**
394
567
  * Host stderr as recorded — full bytes, no flood filter, no char clip (#836).
@@ -405,14 +578,13 @@ export function formatCliDiagnostic(message: string): string {
405
578
  return `ak-role: ${message}\n`;
406
579
  }
407
580
 
408
- /**
409
- * One concise stderr line for humans. Durable Error Artifact / Terminal keep the
410
- * full original diagnostic — presentation collapses newlines and flood frames.
411
- */
412
581
  /** #836: full diagnostic on stderr — no first-line clip / flood filter. */
413
582
  export function formatFailureStderrDiagnostic(failure: ControlledFailure): string {
414
583
  const text = failure.diagnostic.trim().length > 0 ? failure.diagnostic : "failure";
415
- return formatCliDiagnostic(text);
584
+ const head = formatCliDiagnostic(text);
585
+ const beside = failure.stderr;
586
+ if (beside === undefined || beside.length === 0 || beside === failure.diagnostic) return head;
587
+ return beside.endsWith("\n") ? `${head}${beside}` : `${head}${beside}\n`;
416
588
  }
417
589
 
418
590
  /**
@@ -456,42 +628,27 @@ export function presentControlledFailure(
456
628
  io.stderr(formatFailureStderrDiagnostic(failure));
457
629
  }
458
630
 
459
- /** Session readiness after an admitted activation attempt. */
460
- export type SessionReadiness =
461
- | { readonly state: "missing" }
462
- | { readonly state: "unreadable"; readonly diagnostic: string }
463
- | { readonly state: "present" };
464
-
465
- export async function inspectJudgeSession(
466
- sessionFile: string,
467
- ): Promise<SessionReadiness> {
468
- try {
469
- await readFile(sessionFile, "utf8");
470
- return { state: "present" };
471
- } catch (error) {
472
- if (isEnoent(error)) return { state: "missing" };
473
- return {
474
- state: "unreadable",
475
- diagnostic:
476
- error instanceof Error
477
- ? error.message || error.name
478
- : String(error),
479
- };
480
- }
481
- }
631
+ /**
632
+ * A caught error's identity, read structurally off the thrown value: its name
633
+ * and its `code` when it carries one (an OS errno, typically). The errno
634
+ * travels as data so a consumer asserts the identity instead of recognising
635
+ * the system error by its message wording (质量法: 机器只咬契约,不咬呈现).
636
+ */
637
+ export type ThrownErrorIdentity = {
638
+ readonly name: string;
639
+ readonly code?: string | number;
640
+ };
482
641
 
483
- function thrownIdentity(error: Error): {
484
- name?: string;
485
- code?: string | number;
486
- } {
487
- const identity: { name?: string; code?: string | number } = {
642
+ function thrownIdentity(error: Error): ThrownErrorIdentity {
643
+ const code = (error as { code?: unknown }).code;
644
+ return {
488
645
  name: error.name,
646
+ ...(typeof code === "string" || typeof code === "number" ? { code } : {}),
489
647
  };
490
- const code = (error as { code?: unknown }).code;
491
- if (typeof code === "string" || typeof code === "number") {
492
- identity.code = code;
493
- }
494
- return identity;
648
+ }
649
+
650
+ function isControlledFailureCause(cause: unknown): cause is ControlledFailureCause {
651
+ return CONTROLLED_FAILURE_CAUSES.some((known) => known === cause);
495
652
  }
496
653
 
497
654
  /** Production-owned typed thrown failure (explicit-internal channel). */
@@ -504,48 +661,43 @@ function isTypedActivationError(
504
661
  } {
505
662
  if (!(error instanceof Error)) return false;
506
663
  const cause = (error as { knownCause?: unknown }).knownCause;
507
- return (
508
- cause === "provider" ||
509
- cause === "activation" ||
510
- cause === "session" ||
511
- cause === "output" ||
512
- cause === "timeout"
513
- );
514
- }
515
-
516
- /** Flatten nested AggregateError leaves; non-aggregate values stay as one fact. */
517
- function flattenThrownFailureLeaves(error: unknown): unknown[] {
518
- if (!(error instanceof AggregateError)) {
519
- return [error];
520
- }
521
- const leaves: unknown[] = [];
522
- for (const item of error.errors) {
523
- leaves.push(...flattenThrownFailureLeaves(item));
524
- }
525
- return leaves;
664
+ return isControlledFailureCause(cause);
526
665
  }
527
666
 
528
667
  /**
529
668
  * Project one thrown value into a ControlledFailure leaf.
530
669
  * Sole owner for thrown-leaf identity/diagnostic mapping.
531
- * AggregateError nesting is handled by classifyThrownFailure.
670
+ * Aggregate shells and their direct children remain distinct facts.
532
671
  */
533
672
  export function projectThrownFailureLeaf(error: unknown): ControlledFailure {
673
+ const originalDetails = error !== null && typeof error === "object"
674
+ ? (error as { details?: Readonly<Record<string, unknown>> }).details
675
+ : undefined;
676
+ const testimony = {
677
+ error: serializeThrownValue(error),
678
+ ...(error instanceof AggregateError
679
+ ? { concurrentFailures: error.errors.map(projectThrownFailureLeaf) }
680
+ : {}),
681
+ };
682
+ const details = originalDetails === undefined ? testimony : originalDetails;
683
+ const packageFact = originalDetails === undefined ? {} : {
684
+ packageFact: {
685
+ thrown: {
686
+ diagnostic: error instanceof Error ? error.message || error.name || "exception" : "non-Error throw",
687
+ details: testimony,
688
+ },
689
+ },
690
+ };
534
691
  if (isTypedActivationError(error)) {
535
692
  const identity = thrownIdentity(error);
536
- if (error.failureCode !== undefined && identity.code === undefined) {
537
- identity.code = error.failureCode;
538
- }
539
693
  return {
540
694
  cause: error.knownCause,
541
695
  diagnostic: error.message || error.name || "exception",
542
- identity,
543
- details: {
544
- ...(typeof error.details === "object" && error.details !== null
545
- ? error.details as Record<string, unknown>
546
- : error.details === undefined ? {} : { priorDetails: error.details }),
547
- error: serializeThrownValue(error),
548
- },
696
+ identity: error.failureCode !== undefined && identity.code === undefined
697
+ ? { ...identity, code: error.failureCode }
698
+ : identity,
699
+ details,
700
+ ...packageFact,
549
701
  };
550
702
  }
551
703
  if (error instanceof Error) {
@@ -554,72 +706,39 @@ export function projectThrownFailureLeaf(error: unknown): ControlledFailure {
554
706
  return {
555
707
  diagnostic: error.message || error.name || "exception",
556
708
  identity,
557
- details: { error: serializeThrownValue(error) },
709
+ details,
710
+ ...packageFact,
558
711
  };
559
712
  }
560
713
  return {
561
714
  diagnostic: error !== null && typeof error === "object" ? "non-Error throw" : String(error),
562
- details: { error: serializeThrownValue(error) },
715
+ details,
716
+ ...packageFact,
563
717
  };
564
718
  }
565
719
 
566
720
  /**
567
- * Concurrent thrown failures (host + cleanup, etc.):
568
- * primary leaf owns cause/diagnostic/identity; remaining leaves stay as
569
- * details.concurrentFailures so neither fact covers the other.
721
+ * Merge caller-owned secondary evidence into a classified failure without
722
+ * washing path facts. The package's own facts ride along on every branch, so
723
+ * no fallback path silently drops them.
570
724
  */
571
- function classifyThrownFailure(error: unknown): ControlledFailure {
572
- if (!(error instanceof AggregateError)) {
573
- return projectThrownFailureLeaf(error);
574
- }
575
- const leaves = flattenThrownFailureLeaves(error);
576
- if (leaves.length === 0) {
577
- // Empty aggregate — retain the aggregate shell rather than invent a cause.
578
- return projectThrownFailureLeaf(error);
579
- }
580
- const primary = projectThrownFailureLeaf(leaves[0]);
581
- if (leaves.length === 1) {
582
- return primary;
583
- }
584
- const priorConcurrent = Array.isArray(primary.details?.concurrentFailures)
585
- ? primary.details.concurrentFailures
586
- : [];
587
- const concurrentFailures = [
588
- ...priorConcurrent,
589
- ...leaves.slice(1).map((leaf) => {
590
- const secondary = projectThrownFailureLeaf(leaf);
591
- return {
592
- ...(secondary.cause === undefined ? {} : { cause: secondary.cause }),
593
- diagnostic: secondary.diagnostic,
594
- ...(secondary.identity === undefined ? {} : { identity: secondary.identity }),
595
- ...(secondary.details === undefined ? {} : { details: secondary.details }),
596
- };
597
- }),
598
- ];
599
- return {
600
- ...(primary.cause === undefined ? {} : { cause: primary.cause }),
601
- diagnostic: primary.diagnostic,
602
- ...(primary.identity === undefined ? {} : { identity: primary.identity }),
603
- details: {
604
- ...(primary.details ?? {}),
605
- concurrentFailures,
606
- },
607
- };
608
- }
609
-
610
- /** Merge caller-owned secondary evidence into a classified failure without washing path facts. */
611
725
  function withKnownDetails(
612
726
  failure: ControlledFailure,
613
727
  knownDetails: Readonly<Record<string, unknown>> | undefined,
728
+ packageFact?: PackageSideFact,
614
729
  ): ControlledFailure {
615
- if (knownDetails === undefined) return failure;
616
- const { timedOut: _knownTimedOut, ...rest } = knownDetails;
730
+ const facts = packageFact === undefined || Object.keys(packageFact).length === 0
731
+ ? undefined
732
+ : packageFact;
733
+ // The host's own details win outright: this helper must not displace a key the
734
+ // host carried. Only when the host supplied no record at all does the
735
+ // classified failure keep the details it derived from the host's exit.
617
736
  return {
618
737
  ...failure,
619
- details: {
620
- ...rest,
621
- ...(failure.details ?? {}),
622
- },
738
+ ...(facts === undefined ? {} : { packageFact: facts }),
739
+ // The host's own record replaces the derived one when it supplied one; when
740
+ // it supplied none, the failure keeps what it derived from the host's exit.
741
+ ...(knownDetails === undefined ? {} : { details: knownDetails }),
623
742
  };
624
743
  }
625
744
 
@@ -627,11 +746,49 @@ function withKnownDetails(
627
746
  * Classify a controlled post-admission failure without washing original identities.
628
747
  * Cause classes are closed; diagnostic text retains the original identity when known.
629
748
  *
630
- * Order: thrown → knownCause → timeout → activation (nonzero) → session → output.
749
+ * Order: thrown → knownCause → timeout → activation (nonzero) → clean exit.
631
750
  * knownCause precedes timeout so a co-present typed provider/session identity is not
632
751
  * washed when the child also timed out. Cause is never inferred from stderr wording.
633
- * AggregateError concurrent leaves keep primary identity and secondary facts in details.
752
+ * AggregateError shells, direct children and raw testimony stay distinct.
634
753
  */
754
+ /**
755
+ * Wording for a failure the host reported without a diagnostic of its own. The
756
+ * host's signal or cancellation is named when it gave one; otherwise the exit
757
+ * is. Only ever a fallback — a host that reported a diagnostic keeps it
758
+ * (owner 4743ade7: 代码凭什么要去决定cli的失败原因?).
759
+ */
760
+ function hostReportedFallbackOf(input: {
761
+ readonly timedOut?: boolean;
762
+ readonly code?: number | null;
763
+ readonly signal?: string;
764
+ readonly cancelName?: string;
765
+ }): string {
766
+ if (input.cancelName !== undefined) return processCancelDiagnostic(input.cancelName as CatchableProcessSignal);
767
+ if (input.signal !== undefined) return childSignalDeathDiagnostic(input.signal);
768
+ if (input.timedOut === true) return "role run timed out";
769
+ return `role run failed with exit ${input.code ?? "null"}`;
770
+ }
771
+
772
+ /** This call's package-side facts, or undefined when there are none. */
773
+ function packageFactsOf(input: {
774
+ readonly packageFact?: PackageSideFact;
775
+ readonly code?: number | null;
776
+ readonly timedOut?: boolean;
777
+ readonly signal?: string;
778
+ readonly cancelName?: string;
779
+ }): PackageSideFact | undefined {
780
+ const facts: PackageSideFact = {
781
+ ...(input.code === undefined ? {} : { exitCode: input.code }),
782
+ ...(input.timedOut === true ? { timedOut: true } : {}),
783
+ ...(input.signal === undefined || input.signal.length === 0 ? {} : { signal: input.signal }),
784
+ ...(input.cancelName === undefined || input.cancelName.length === 0
785
+ ? {}
786
+ : { cancelName: input.cancelName }),
787
+ ...(input.packageFact ?? {}),
788
+ };
789
+ return Object.keys(facts).length === 0 ? undefined : facts;
790
+ }
791
+
635
792
  export function classifyPostAdmissionFailure(input: {
636
793
  timedOut: boolean;
637
794
  code: number | null;
@@ -642,7 +799,6 @@ export function classifyPostAdmissionFailure(input: {
642
799
  * being washed into activation/null-exit paths that treat missing thrown as absence.
643
800
  */
644
801
  thrown?: unknown;
645
- session?: SessionReadiness;
646
802
  /** Upstream-typed cause when the failure origin is already known. */
647
803
  knownCause?: ControlledFailureCause;
648
804
  /** Optional identity paired with knownCause (production channel). */
@@ -657,71 +813,112 @@ export function classifyPostAdmissionFailure(input: {
657
813
  knownDiagnostic?: string;
658
814
  /** Secondary evidence already carried by the typed production failure. */
659
815
  knownDetails?: Readonly<Record<string, unknown>>;
816
+ /** The signal that killed the host child, when one did (a non-normal exit). */
817
+ signal?: string;
818
+ /**
819
+ * A catchable process-signal cancellation the host reported. Like `signal`,
820
+ * it only supplies fallback wording: the host's own diagnostic always wins.
821
+ */
822
+ readonly cancelName?: string;
823
+ /** Further package-side facts merged under `packageFact`, never the cause. */
824
+ packageFact?: PackageSideFact;
660
825
  }): ControlledFailure {
661
826
  // Own-key presence, not value: `throw undefined` is a real caught exception.
827
+ // An exception caught after the host already reported its own failure carries
828
+ // both real facts: the host's report stays the cause, and the later exception
829
+ // is kept beside it (失败诚实宪法:接住可以,洗白不行).
662
830
  if (Object.hasOwn(input, "thrown")) {
663
- return classifyThrownFailure(input.thrown);
664
- }
665
- if (input.knownCause !== undefined) {
666
- const fallback =
667
- input.knownCause === "provider"
668
- ? "provider failure"
669
- : input.knownCause === "session"
670
- ? "session unreadable"
671
- : input.knownCause === "output"
672
- ? "role run completed without a lawful typed terminal result"
673
- : `role run failed (${input.knownCause})`;
674
- const diagnostic =
675
- input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== ""
676
- ? input.knownDiagnostic
677
- : conciseChildDiagnostic(input.stderr, fallback);
678
- const { timedOut: _knownTimedOut, ...knownDetails } =
679
- input.knownDetails ?? {};
680
- const remoteCode = knownDetails.code;
831
+ const thrown = projectThrownFailureLeaf(input.thrown);
832
+ // A host that carried a details record reported *something* for this call,
833
+ // even with no typed class: that record is its own and must not be
834
+ // displaced by the exception.
835
+ const hostReported = input.knownCause !== undefined
836
+ || (input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== "")
837
+ || input.knownIdentity !== undefined
838
+ || input.knownDetails !== undefined;
839
+ // The host reported no *typed* class, but it did report this call: a
840
+ // nonzero exit, a timeout, or a signal death are its own facts. A later
841
+ // settlement exception does not replace them — it is a different failure
842
+ // and rides beside them (owner 4743ade7: 代码凭什么要去决定cli的失败原因?).
843
+ if (!hostReported) {
844
+ // A number is the host having reported an exit. A null code with a signal
845
+ // is its native signal-death report. Anything else means the host produced
846
+ // no result at all, and then the exception really is the only fact there
847
+ // is — there is nothing of the host's to preserve over it.
848
+ const hostFailed = input.timedOut
849
+ || input.signal !== undefined
850
+ || (typeof input.code === "number" && input.code !== 0);
851
+ if (!hostFailed) {
852
+ const facts = packageFactsOf(input);
853
+ if (facts?.cancelName === undefined && facts?.signal === undefined) return thrown;
854
+ return {
855
+ ...thrown,
856
+ packageFact: { ...facts, ...(thrown.packageFact ?? {}) },
857
+ };
858
+ }
859
+ const facts = packageFactsOf(input);
860
+ const packageFact = facts === undefined ? { thrown } : { ...facts, thrown };
861
+ return {
862
+ ...(input.timedOut ? { cause: "timeout" as const } : {}),
863
+ ...(input.signal === undefined ? {} : { identity: { name: "ChildSignalDeath", code: input.signal } }),
864
+ diagnostic: conciseChildDiagnostic(
865
+ input.stderr,
866
+ hostReportedFallbackOf(input),
867
+ ),
868
+ // The host's record is untouched; this package's view of the exit and
869
+ // the caught exception both ride under `packageFact`.
870
+ packageFact,
871
+ };
872
+ }
873
+ // The host's report is presented exactly as it gave it — a field it left out
874
+ // is not filled in from the exception, which is a different failure. The
875
+ // exception is kept whole beside it under its own key, so neither erases
876
+ // the other (失败诚实宪法:接住可以,洗白不行).
681
877
  return {
682
- cause: input.knownCause,
683
- diagnostic,
684
- details: {
685
- ...knownDetails,
686
- ...(remoteCode === undefined ? {} : { code: remoteCode }),
687
- exitCode: input.code,
688
- ...(input.timedOut ? { timedOut: true as const } : {}),
689
- },
878
+ ...(input.knownCause === undefined ? {} : { cause: input.knownCause }),
879
+ // A settled failure always presents a diagnostic. The host's own is used
880
+ // when it gave one; when it gave none, this call's own stderr supplies the
881
+ // text. The exception is a different failure and never fills this field —
882
+ // it rides whole under `packageFact` (owner 4743ade7: 代码凭什么要去决定cli的失败原因?).
883
+ diagnostic: input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== ""
884
+ ? input.knownDiagnostic
885
+ : conciseChildDiagnostic(input.stderr, hostReportedFallbackOf(input)),
690
886
  ...(input.knownIdentity === undefined
691
887
  ? {}
692
888
  : { identity: input.knownIdentity }),
889
+ // The host's details are handed back exactly as given.
890
+ ...(input.knownDetails === undefined ? {} : { details: input.knownDetails }),
891
+ // Every fact this package captured on this call — the caught exception and
892
+ // any earlier auxiliary read failure — is reported, none of them dropped.
893
+ packageFact: { ...packageFactsOf(input), thrown },
693
894
  };
694
895
  }
695
- // #881: original failure testimony without a typed class — keep diagnostic/identity;
696
- // do not fall through to activation/output wash that would mint a substitute class.
697
- // Bare knownDetails alone is secondary evidence for later branches (withKnownDetails),
698
- // not a stand-in primary failure record.
699
- if (
896
+ // #881: untyped original testimony retains diagnostic/identity, not a
897
+ // fabricated activation/output cause. Bare details remain secondary evidence.
898
+ if (input.knownCause !== undefined ||
700
899
  (input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== "") ||
701
- input.knownIdentity !== undefined
702
- ) {
703
- const diagnostic =
704
- input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== ""
705
- ? input.knownDiagnostic
706
- : conciseChildDiagnostic(input.stderr, "role run failed");
707
- const { timedOut: _knownTimedOut, ...knownDetails } =
708
- input.knownDetails ?? {};
709
- const remoteCode = knownDetails.code;
710
- return withKnownDetails(
711
- {
712
- diagnostic,
713
- details: {
714
- ...knownDetails,
715
- ...(remoteCode === undefined ? {} : { code: remoteCode }),
716
- exitCode: input.code,
717
- ...(input.timedOut ? { timedOut: true as const } : {}),
718
- },
719
- ...(input.knownIdentity === undefined
720
- ? {}
721
- : { identity: input.knownIdentity }),
722
- },
723
- undefined,
724
- );
900
+ input.knownIdentity !== undefined) {
901
+ const fallback =
902
+ input.knownCause === "provider" ? "provider failure"
903
+ : input.knownCause === "session" ? "session unreadable"
904
+ : input.knownCause === "output" ? "role run completed without a lawful typed terminal result"
905
+ : input.knownCause === undefined ? "role run failed"
906
+ : `role run failed (${input.knownCause})`;
907
+ const diagnostic = input.knownDiagnostic !== undefined && input.knownDiagnostic.trim() !== ""
908
+ ? input.knownDiagnostic
909
+ : conciseChildDiagnostic(input.stderr, fallback);
910
+ const packageFact = packageFactsOf(input);
911
+ return {
912
+ ...(input.knownCause === undefined ? {} : { cause: input.knownCause }),
913
+ diagnostic,
914
+ // The host's details are handed back byte-for-byte. This package's own
915
+ // view of the exit rides beside them — writing `exitCode` in there would
916
+ // overwrite a value the host itself carried (host-contracts.ts:41: an
917
+ // open read-only record with no reserved keys).
918
+ ...(input.knownDetails === undefined ? {} : { details: input.knownDetails }),
919
+ ...(packageFact === undefined ? {} : { packageFact }),
920
+ ...(input.knownIdentity === undefined ? {} : { identity: input.knownIdentity }),
921
+ };
725
922
  }
726
923
  if (input.timedOut) {
727
924
  return withKnownDetails(
@@ -731,46 +928,38 @@ export function classifyPostAdmissionFailure(input: {
731
928
  details: { timedOut: true, exitCode: input.code },
732
929
  },
733
930
  input.knownDetails,
931
+ packageFactsOf(input),
734
932
  );
735
933
  }
736
934
  if (input.code !== 0) {
737
- const fallback = `role run failed with exit ${input.code ?? "null"}`;
935
+ // A nonzero exit says the run failed, not why. No typed fact confirmed a
936
+ // class, so `cause` stays absent and the CLI's own diagnostic and exit code
937
+ // are what remains (host contract: omit cause, keep the original). A signal
938
+ // or cancellation the host did report is named, not restated as an exit.
939
+ const fallback = hostReportedFallbackOf(input);
738
940
  return withKnownDetails(
739
941
  {
740
- cause: "activation",
741
942
  diagnostic: conciseChildDiagnostic(input.stderr, fallback),
742
943
  details: { exitCode: input.code },
743
944
  },
744
945
  input.knownDetails,
946
+ packageFactsOf(input),
745
947
  );
746
948
  }
747
- if (input.session?.state === "missing") {
748
- return withKnownDetails(
749
- {
750
- cause: "session",
751
- diagnostic: "role run left no readable session transcript",
752
- details: { exitCode: input.code, session: "missing" },
753
- },
754
- input.knownDetails,
755
- );
756
- }
757
- if (input.session?.state === "unreadable") {
758
- return withKnownDetails(
759
- {
760
- cause: "session",
761
- diagnostic: input.session.diagnostic,
762
- details: { exitCode: input.code, session: "unreadable" },
763
- },
764
- input.knownDetails,
765
- );
766
- }
949
+ // No typed fact confirmed a class, so none is asserted: the host exited
950
+ // cleanly, and the package has nothing to add (host-contracts.ts:19–42: omit
951
+ // cause and keep the original when no typed confirmation exists). A caller
952
+ // that must present a failure for a clean run settles it on the no_receipt
953
+ // path, not by minting a class here.
767
954
  return withKnownDetails(
768
955
  {
769
- cause: "output",
770
- diagnostic: "role run completed without a lawful typed terminal result",
771
- details: { exitCode: input.code },
956
+ diagnostic: conciseChildDiagnostic(
957
+ input.stderr,
958
+ "role run completed without a lawful typed terminal result",
959
+ ),
772
960
  },
773
961
  input.knownDetails,
962
+ packageFactsOf(input),
774
963
  );
775
964
  }
776
965
 
@@ -831,996 +1020,26 @@ type SessionEntry = {
831
1020
  /** Session principal id from the durable header entry. */
832
1021
  id?: string;
833
1022
  /** Session cwd from the durable header entry. */
834
- cwd?: string;
835
- /** Parent session principal on durable child session headers. */
836
- parentSession?: string;
837
- };
838
-
839
- /**
840
- * Preserve session-read failure identity as a typed session cause.
841
- * SyntaxError keeps its name so durable settlement does not wash malformed JSONL
842
- * into generic output absence.
843
- */
844
- function sessionReadFailure(
845
- error: unknown,
846
- fallbackMessage: string,
847
- ): Error & {
848
- knownCause: ControlledFailureCause;
849
- failureCode?: string | number;
850
- } {
851
- if (error instanceof SyntaxError) {
852
- const failed = new SyntaxError(
853
- error.message || fallbackMessage,
854
- ) as SyntaxError & {
855
- knownCause: ControlledFailureCause;
856
- failureCode?: string | number;
857
- };
858
- failed.knownCause = "session";
859
- return failed;
860
- }
861
- if (error instanceof Error) {
862
- const failed = new Error(
863
- error.message || error.name || fallbackMessage,
864
- ) as Error & {
865
- knownCause: ControlledFailureCause;
866
- failureCode?: string | number;
867
- code?: string | number;
868
- };
869
- failed.name = error.name || "Error";
870
- failed.knownCause = "session";
871
- const code = (error as { code?: unknown }).code;
872
- if (typeof code === "string" || typeof code === "number") {
873
- failed.failureCode = code;
874
- failed.code = code;
875
- }
876
- return failed;
877
- }
878
- const failed = new Error(String(error)) as Error & {
879
- knownCause: ControlledFailureCause;
880
- failureCode?: string | number;
881
- };
882
- failed.knownCause = "session";
883
- return failed;
884
- }
885
-
886
- /**
887
- * Read the exact bound Pi session file principal.
888
- * Does not scan the session directory for "latest" — resume identity is the file.
889
- */
890
- export async function readBoundSessionEntries(
891
- sessionFile: string,
892
- ): Promise<SessionEntry[]> {
893
- const text = await readFile(sessionFile, "utf8");
894
- const entries: SessionEntry[] = [];
895
- for (const line of text.trim().split("\n").filter(Boolean)) {
896
- try {
897
- entries.push(JSON.parse(line) as SessionEntry);
898
- } catch (error) {
899
- throw sessionReadFailure(error, "malformed session JSONL");
900
- }
901
- }
902
- return entries;
903
- }
904
-
905
- /**
906
- * Latest native assistant provider-stop in a session (stopReason error|aborted).
907
- * Only the final assistant turn decides terminality — an older error followed by a
908
- * later non-error stop is not a provider failure (would wash a no-lawful-output path).
909
- * Typed production source for provider cause — not child stderr prose.
910
- */
911
- type SessionProviderStop = {
912
- stopReason: "error" | "aborted";
913
- errorMessage?: string;
914
- provider?: string;
915
- model?: string;
916
- api?: string;
917
- rawStopReason?: string;
918
- diagnostics?: unknown;
919
- httpStatus?: number;
920
- body?: unknown;
921
- code?: unknown;
922
- errno?: unknown;
923
- };
924
-
925
- function typedHttpStatusFromMessage(message: SessionMessage): number | undefined {
926
- for (const candidate of [message.httpStatus, message.statusCode, message.status]) {
927
- if (typeof candidate === "number" && (candidate < 200 || candidate >= 300)) return candidate;
928
- }
929
- return undefined;
930
- }
931
-
932
- function sessionProviderStopFromAssistant(message: SessionMessage | undefined): SessionProviderStop | undefined {
933
- if (message?.role !== "assistant") return undefined;
934
- if (message.stopReason !== "error" && message.stopReason !== "aborted") return undefined;
935
- const httpStatus = typedHttpStatusFromMessage(message);
936
- return {
937
- stopReason: message.stopReason,
938
- // Preserve held errorMessage bytes — emptiness check must not rewrite.
939
- ...(typeof message.errorMessage === "string" && message.errorMessage.trim() !== ""
940
- ? { errorMessage: message.errorMessage }
941
- : {}),
942
- ...(typeof message.provider === "string" && message.provider.trim() !== ""
943
- ? { provider: message.provider }
944
- : {}),
945
- ...(typeof message.model === "string" && message.model.trim() !== ""
946
- ? { model: message.model }
947
- : {}),
948
- ...(typeof message.api === "string" && message.api.trim() !== ""
949
- ? { api: message.api }
950
- : {}),
951
- ...(typeof message.rawStopReason === "string" && message.rawStopReason.trim() !== ""
952
- ? { rawStopReason: message.rawStopReason }
953
- : {}),
954
- ...(message.diagnostics === undefined ? {} : { diagnostics: message.diagnostics }),
955
- ...(httpStatus === undefined ? {} : { httpStatus }),
956
- ...(message.body === undefined ? {} : { body: message.body }),
957
- ...(message.code === undefined ? {} : { code: message.code }),
958
- ...(message.errno === undefined ? {} : { errno: message.errno }),
959
- };
960
- }
961
-
962
- export function extractSessionProviderStop(
963
- entries: readonly SessionEntry[],
964
- ): SessionProviderStop | undefined {
965
- // A resumed dispatch appends a typed top-level user turn to the same session.
966
- // Older attempt native stops must not replace the newer attempt's stop.
967
- // Sessions without a user turn are the initial attempt.
968
- // Auditor retained responses live in Sitian (kind=auditor); see readSessionProviderStop.
969
- let attemptStart = 0;
970
- for (let i = entries.length - 1; i >= 0; i -= 1) {
971
- const entry = entries[i];
972
- if (entry?.type === "message" && entry.message?.role === "user") {
973
- attemptStart = i;
974
- break;
975
- }
976
- }
977
-
978
- for (let i = entries.length - 1; i >= attemptStart; i -= 1) {
979
- const entry = entries[i];
980
- if (entry?.type !== "message") continue;
981
- const message = entry.message;
982
- if (message?.role !== "assistant") continue;
983
- // Latest assistant in the current attempt only.
984
- return sessionProviderStopFromAssistant(message);
985
- }
986
- return undefined;
987
- }
988
-
989
- /**
990
- * Latest Sitian-retained auditor response stop for this parent session principal.
991
- * Writer: retainComplianceResponse → sitianReport(kind=auditor, payload={version,response}).
992
- * Payload stopReason is preserved as retained — aborted stays aborted; no 500/error wash here.
993
- */
994
- async function readSitianRetainedAuditorProviderStop(
995
- sessionFile: string,
996
- ): Promise<SessionProviderStop | undefined> {
997
- try {
998
- const { recordFile } = resolveSitianRecordPath({
999
- level: "event",
1000
- kind: "auditor",
1001
- sessionParent: sessionFile,
1002
- // Path is driven by sessionParent when under ledger home; cwd is a fallback only.
1003
- cwd: dirname(sessionFile),
1004
- });
1005
- const { records } = await readSitianRecords(recordFile);
1006
- for (let i = records.length - 1; i >= 0; i -= 1) {
1007
- const payload = records[i]?.payload;
1008
- if (!isRecord(payload) || !isRecord(payload.response)) continue;
1009
- // Lifecycle events carry `type` (binding / compliance_failure); retain does not.
1010
- if (typeof payload.type === "string") continue;
1011
- const stop = sessionProviderStopFromAssistant(payload.response as SessionMessage);
1012
- if (stop !== undefined) return stop;
1013
- // Latest retain exists but is not a provider-stop — do not scan older retains
1014
- // (mirrors former session COMPLIANCE_RESPONSE preference break).
1015
- break;
1016
- }
1017
- } catch {
1018
- // Missing volume or unreadable path is absence, not a settlement failure.
1019
- }
1020
- return undefined;
1021
- }
1022
-
1023
- /** Read retained auditor stop (Sitian) then native session assistant stop, if any. */
1024
- export async function readSessionProviderStop(
1025
- sessionFile: string,
1026
- ): Promise<SessionProviderStop | undefined> {
1027
- const retained = await readSitianRetainedAuditorProviderStop(sessionFile);
1028
- if (retained !== undefined) return retained;
1029
- try {
1030
- const entries = await readBoundSessionEntries(sessionFile);
1031
- return extractSessionProviderStop(entries);
1032
- } catch {
1033
- return undefined;
1034
- }
1035
- }
1036
-
1037
- type BoundAuditorVolume = {
1038
- readonly entries: SessionEntry[];
1039
- readonly attemptEntryId?: string;
1040
- readonly parentId: string;
1041
- readonly sessionFile: string;
1042
- };
1043
-
1044
- async function loadBoundAuditorVolumes(
1045
- sessionFile: string,
1046
- ): Promise<readonly BoundAuditorVolume[] | undefined> {
1047
- let parentEntries: SessionEntry[];
1048
- try {
1049
- parentEntries = await readBoundSessionEntries(sessionFile);
1050
- } catch (error) {
1051
- if (isEnoent(error)) return undefined;
1052
- throw sessionReadFailure(error, "failed to read parent session for auditor binding");
1053
- }
1054
- const parentId = parentEntries.find((entry) => entry.type === "session")?.id;
1055
- if (parentId === undefined) return undefined;
1056
- const childDirectories = [join(dirname(sessionFile), "auditor-roles")];
1057
- const valid: BoundAuditorVolume[] = [];
1058
- let sawAnyDirectory = false;
1059
- for (const childDirectory of childDirectories) {
1060
- let names: string[];
1061
- try {
1062
- names = await readdir(childDirectory);
1063
- sawAnyDirectory = true;
1064
- } catch (error) {
1065
- if (isEnoent(error)) continue;
1066
- throw sessionReadFailure(error, "failed to read bound auditor session directory");
1067
- }
1068
- for (const file of names.filter((name) => name.endsWith(".jsonl")).sort().reverse()) {
1069
- let entries: SessionEntry[];
1070
- try {
1071
- entries = await readBoundSessionEntries(join(childDirectory, file));
1072
- } catch (error) {
1073
- throw sessionReadFailure(error, "failed to read discovered auditor session");
1074
- }
1075
- const header = entries.find((entry) => entry.type === "session");
1076
- if (!isRecord(header)) continue;
1077
- // Parent-attempt binding owns its interval on multi-attempt volumes
1078
- // (never whole-volume provider/compliance).
1079
- const bindingIndexes: number[] = [];
1080
- for (let i = 0; i < entries.length; i += 1) {
1081
- const entry = entries[i];
1082
- if (entry?.type === "custom" && entry.customType === AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE) {
1083
- bindingIndexes.push(i);
1084
- }
1085
- }
1086
- const bindingPasses: Array<{ entry: SessionEntry | undefined; start: number; end: number }> =
1087
- bindingIndexes.length > 0
1088
- ? bindingIndexes.map((start, idx) => ({
1089
- entry: entries[start],
1090
- start,
1091
- end: idx + 1 < bindingIndexes.length ? bindingIndexes[idx + 1]! : entries.length,
1092
- }))
1093
- : [{ entry: undefined, start: 0, end: entries.length }];
1094
- for (const { entry: bindingEntry, start, end } of bindingPasses) {
1095
- const bindingParent =
1096
- bindingEntry !== undefined &&
1097
- isRecord(bindingEntry.data) &&
1098
- isRecord(bindingEntry.data.parent)
1099
- ? bindingEntry.data.parent
1100
- : undefined;
1101
- const attemptEntryId =
1102
- typeof bindingParent?.attemptEntryId === "string"
1103
- ? bindingParent.attemptEntryId
1104
- : undefined;
1105
- const boundSessionFile =
1106
- typeof bindingParent?.sessionFile === "string"
1107
- ? bindingParent.sessionFile
1108
- : typeof header.parentSession === "string"
1109
- ? header.parentSession
1110
- : undefined;
1111
- if (boundSessionFile !== sessionFile) continue;
1112
- if (bindingParent !== undefined && bindingParent.sessionId !== parentId) continue;
1113
- if (bindingParent === undefined && header.parentSession !== sessionFile) continue;
1114
- valid.push({
1115
- entries: entries.slice(start, end),
1116
- parentId,
1117
- sessionFile,
1118
- ...(attemptEntryId === undefined ? {} : { attemptEntryId }),
1119
- });
1120
- // Keep every interval bound to this parent. Auditor payload is relayed as
1121
- // recorded; code does not expire it from later user-message shape (#858).
1122
- }
1123
- }
1124
- }
1125
- if (!sawAnyDirectory && valid.length === 0) return undefined;
1126
- return valid;
1127
- }
1128
-
1129
- function complianceFailureFromAuditorVolumes(
1130
- volumes: readonly BoundAuditorVolume[],
1131
- ): RoleTurnKnownFailure | undefined {
1132
- for (const { entries, attemptEntryId, parentId, sessionFile } of volumes) {
1133
- const stop = extractSessionProviderStop(entries);
1134
- if (stop === undefined) continue;
1135
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1136
- const entry = entries[i];
1137
- if (entry?.type !== "custom" || entry.customType !== AUDITOR_COMPLIANCE_FAILURE_ENTRY_TYPE || !isRecord(entry.data)) continue;
1138
- const parent = isRecord(entry.data.parent) ? entry.data.parent : undefined;
1139
- const failure = isRecord(entry.data.failure) ? entry.data.failure : undefined;
1140
- if (parent?.sessionId !== parentId || parent.sessionFile !== sessionFile || parent.attemptEntryId !== attemptEntryId) continue;
1141
- // #881: keep the recorded failure as written — typed cause when present, else raw diagnostic only.
1142
- if (failure === undefined) continue;
1143
- const identity = isRecord(failure.identity) ? failure.identity : undefined;
1144
- const typedCause =
1145
- failure.cause === "provider" ||
1146
- failure.cause === "activation" ||
1147
- failure.cause === "session" ||
1148
- failure.cause === "output" ||
1149
- failure.cause === "timeout"
1150
- ? (failure.cause as ControlledFailureCause)
1151
- : undefined;
1152
- return {
1153
- ...(typedCause === undefined ? {} : { cause: typedCause }),
1154
- ...(identity === undefined ? {} : { identity: {
1155
- ...(typeof identity.name === "string" ? { name: identity.name } : {}),
1156
- ...(typeof identity.code === "string" || typeof identity.code === "number" ? { code: identity.code } : {}),
1157
- } }),
1158
- ...(typeof failure.diagnostic === "string" ? { diagnostic: failure.diagnostic } : {}),
1159
- ...(isRecord(failure.details) ? { details: failure.details } : {}),
1160
- };
1161
- }
1162
- }
1163
- return undefined;
1164
- }
1165
-
1166
- function providerStopFallbackFromAuditorVolumes(
1167
- volumes: readonly BoundAuditorVolume[],
1168
- ): RoleTurnKnownFailure | undefined {
1169
- for (const { entries } of volumes) {
1170
- const stop = extractSessionProviderStop(entries);
1171
- if (stop === undefined) continue;
1172
- const primary = knownFailureFromProviderStop(stop)!;
1173
- return {
1174
- ...primary,
1175
- details: {
1176
- ...(primary.details ?? {}),
1177
- secondaryEvidence: "unavailable",
1178
- },
1179
- };
1180
- }
1181
- return undefined;
1182
- }
1183
-
1184
- /** Strong auditor tier only — retained compliance-failure entries, no provider-stop fallback. */
1185
- async function readBoundAuditorComplianceFailure(
1186
- sessionFile: string,
1187
- ): Promise<RoleTurnKnownFailure | undefined> {
1188
- const volumes = await loadBoundAuditorVolumes(sessionFile);
1189
- if (volumes === undefined) return undefined;
1190
- return complianceFailureFromAuditorVolumes(volumes);
1191
- }
1192
-
1193
- /** Weaker auditor tier: provider stop without a retained compliance-failure entry. */
1194
- async function readBoundAuditorProviderStopFallback(
1195
- sessionFile: string,
1196
- ): Promise<RoleTurnKnownFailure | undefined> {
1197
- const volumes = await loadBoundAuditorVolumes(sessionFile);
1198
- if (volumes === undefined) return undefined;
1199
- return providerStopFallbackFromAuditorVolumes(volumes);
1200
- }
1201
-
1202
- function typedFailedTerminatingToolKnownFailure(
1203
- entries: readonly SessionEntry[],
1204
- ): RoleTurnKnownFailure | undefined {
1205
- let attemptStart = 0;
1206
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1207
- if (entries[i]?.type === "message" && entries[i]?.message?.role === "user") {
1208
- attemptStart = i;
1209
- break;
1210
- }
1211
- }
1212
- const attemptEntries = entries.slice(attemptStart);
1213
- for (let i = attemptEntries.length - 1; i >= 0; i -= 1) {
1214
- const message = attemptEntries[i]?.message;
1215
- if (attemptEntries[i]?.type !== "message" || message?.role !== "toolResult") continue;
1216
- const classification = classifyPackagedRoleTerminalResult(message);
1217
- if (classification.kind !== "infrastructure") continue;
1218
- if (typeof message.toolCallId !== "string" || typeof message.toolName !== "string") continue;
1219
- if (boundRoleToolCallForResult(attemptEntries, i, message, message.toolName) === undefined) continue;
1220
- const textPart = Array.isArray(message.content)
1221
- ? message.content.find((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
1222
- : undefined;
1223
- const diagnostic = isRecord(textPart) ? textPart.text : undefined;
1224
- // Durable details already carry fact + typed evidence from envelope one-shot projection (#475).
1225
- // Do not re-parse retained compliance responses here.
1226
- // Host infrastructure must NOT map to cause=output — that cause is reserved for
1227
- // role-output failures (isError residual / no sealed receipt), not host infra.
1228
- const details = isRecord(message.details) ? message.details : classification.fact;
1229
- return {
1230
- cause: "activation",
1231
- identity: { name: message.toolName, code: message.toolCallId },
1232
- ...(typeof diagnostic === "string" && diagnostic.trim() !== "" ? { diagnostic } : {}),
1233
- details,
1234
- };
1235
- }
1236
- return undefined;
1237
- }
1238
-
1239
- /**
1240
- * One audited-runner resolution: knownFailure plus the typed-HTTP sidecar outcome
1241
- * from the same read. Callers that also decide v1 resume must consume this once —
1242
- * never re-read the sidecar in presentControlledFailure.
1243
- */
1244
- export type AuditedRunnerFailureResolution = {
1245
- readonly knownFailure?: RoleTurnKnownFailure;
1246
- /** Successful sidecar read (not absence). */
1247
- readonly typedHttpObservation?: TypedProviderHttpObservation;
1248
- /**
1249
- * True when this resolution already performed the typed-HTTP sidecar read
1250
- * (success, absence, or non-absence failure folded into knownFailure).
1251
- * False when an earlier evidence tier short-circuited before the sidecar.
1252
- */
1253
- readonly typedHttpObservationSettled: boolean;
1254
- };
1255
-
1256
- function resolutionOf(
1257
- knownFailure: RoleTurnKnownFailure | undefined,
1258
- typedHttp: {
1259
- readonly settled: boolean;
1260
- readonly observation?: TypedProviderHttpObservation;
1261
- } = { settled: false },
1262
- ): AuditedRunnerFailureResolution {
1263
- return {
1264
- ...(knownFailure === undefined ? {} : { knownFailure }),
1265
- ...(typedHttp.observation === undefined ? {} : { typedHttpObservation: typedHttp.observation }),
1266
- typedHttpObservationSettled: typedHttp.settled,
1267
- };
1268
- }
1269
-
1270
- /** Sole evidence-priority owner for public runners with Soul auditors. */
1271
- export async function resolveAuditedRunnerFailureResolution(input: {
1272
- runner: RoleTurnKnownFailure | undefined;
1273
- sessionFile: string;
1274
- credential: RoleTurnKnownFailure | undefined;
1275
- /** Optional run directory for typed provider HTTP observation (resume/429). */
1276
- runDirectory?: string;
1277
- }): Promise<AuditedRunnerFailureResolution> {
1278
- if (input.runner !== undefined) return resolutionOf(input.runner);
1279
- // Bound auditor compliance-failure retention outranks a parent failure that the
1280
- // auditor path itself caused (retention EISDIR race). A typed terminating-tool
1281
- // host failure is next — it outranks weaker auditor provider-stop fallback so
1282
- // parent failInfrastructure abort pollution cannot wash a real diagnostic (#475).
1283
- try {
1284
- const auditorCompliance = await readBoundAuditorComplianceFailure(input.sessionFile);
1285
- if (auditorCompliance !== undefined) return resolutionOf(auditorCompliance);
1286
- } catch (error) {
1287
- const failure = sessionReadFailure(error, "failed to recover bound auditor failure");
1288
- return resolutionOf({
1289
- cause: "session",
1290
- identity: thrownIdentity(failure),
1291
- diagnostic: failure.message || failure.name,
1292
- });
1293
- }
1294
- try {
1295
- const terminatingFailure = typedFailedTerminatingToolKnownFailure(
1296
- await readBoundSessionEntries(input.sessionFile),
1297
- );
1298
- if (terminatingFailure !== undefined) return resolutionOf(terminatingFailure);
1299
- } catch (error) {
1300
- if (!isEnoent(error)) {
1301
- const failure = sessionReadFailure(error, "failed to recover typed terminating-tool failure");
1302
- return resolutionOf({
1303
- cause: "session",
1304
- identity: thrownIdentity(failure),
1305
- diagnostic: failure.message || failure.name,
1306
- });
1307
- }
1308
- }
1309
- try {
1310
- const auditorStop = await readBoundAuditorProviderStopFallback(input.sessionFile);
1311
- if (auditorStop !== undefined) return resolutionOf(auditorStop);
1312
- } catch (error) {
1313
- const failure = sessionReadFailure(error, "failed to recover bound auditor provider stop");
1314
- return resolutionOf({
1315
- cause: "session",
1316
- identity: thrownIdentity(failure),
1317
- diagnostic: failure.message || failure.name,
1318
- });
1319
- }
1320
- // Parent session provider-stop is next; credential is last.
1321
- const parentStop = await readSessionProviderStop(input.sessionFile);
1322
- // Typed HTTP observation: ENOENT=absence; other read/parse/shape failures keep real cause.
1323
- // This is the single sidecar read for both knownFailure projection and v1 resume.
1324
- let httpObservation: TypedProviderHttpObservation | undefined;
1325
- if (input.runDirectory !== undefined) {
1326
- try {
1327
- httpObservation = await readLatestTypedProviderHttpObservation(input.runDirectory);
1328
- } catch (error) {
1329
- const failure = error instanceof Error ? error : new Error(String(error));
1330
- return resolutionOf(
1331
- {
1332
- cause: "session",
1333
- identity: thrownIdentity(failure),
1334
- diagnostic: failure.message || failure.name,
1335
- },
1336
- { settled: true },
1337
- );
1338
- }
1339
- }
1340
- const typedHttp = {
1341
- settled: input.runDirectory !== undefined,
1342
- ...(httpObservation === undefined ? {} : { observation: httpObservation }),
1343
- };
1344
- if (parentStop === undefined) {
1345
- if (input.credential !== undefined) return resolutionOf(input.credential, typedHttp);
1346
- if (httpObservation === undefined) return resolutionOf(undefined, typedHttp);
1347
- // Project the HTTP observation's status + provider/source association.
1348
- return resolutionOf(
1349
- knownFailureFromProviderStop({
1350
- stopReason: "error",
1351
- httpStatus: httpObservation.httpStatus,
1352
- provider: httpObservation.provider,
1353
- }),
1354
- typedHttp,
1355
- );
1356
- }
1357
- return resolutionOf(
1358
- knownFailureFromProviderStop({
1359
- ...parentStop,
1360
- ...(httpObservation === undefined
1361
- ? {}
1362
- : {
1363
- httpStatus: httpObservation.httpStatus,
1364
- // Observation association outranks session-configured provider name alone.
1365
- provider: httpObservation.provider,
1366
- }),
1367
- }),
1368
- typedHttp,
1369
- );
1370
- }
1371
-
1372
- /** Sole evidence-priority owner for public runners with Soul auditors. */
1373
- export async function resolveAuditedRunnerKnownFailure(input: {
1374
- runner: RoleTurnKnownFailure | undefined;
1375
- sessionFile: string;
1376
- credential: RoleTurnKnownFailure | undefined;
1377
- /** Optional run directory for typed provider HTTP observation (resume/429). */
1378
- runDirectory?: string;
1379
- }): Promise<RoleTurnKnownFailure | undefined> {
1380
- return (await resolveAuditedRunnerFailureResolution(input)).knownFailure;
1381
- }
1382
-
1383
- /**
1384
- * v1 resume observation for controlled-failure settlement — at most one sidecar read.
1385
- * Prefer the pre-resolved outcome from resolveAuditedRunnerFailureResolution.
1386
- * Non-absence failures never throw: they return observationReadFailure for the
1387
- * existing controlled-failure → error.json chain.
1388
- */
1389
- export async function resolveControlledFailureResumeObservation(input: {
1390
- readonly runDirectory: string;
1391
- readonly typedHttpObservationSettled?: boolean;
1392
- readonly typedHttpObservation?: TypedProviderHttpObservation;
1393
- }): Promise<{
1394
- readonly typedHttp429?: TypedHttp429Observation;
1395
- readonly observationReadFailure?: RoleTurnKnownFailure;
1396
- }> {
1397
- if (input.typedHttpObservationSettled === true) {
1398
- const observation = input.typedHttpObservation;
1399
- if (
1400
- observation !== undefined &&
1401
- observation.httpStatus === 429 &&
1402
- isV1ResumableProvider(observation.provider)
1403
- ) {
1404
- return {
1405
- typedHttp429: { httpStatus: 429, provider: observation.provider },
1406
- };
1407
- }
1408
- return {};
1409
- }
1410
- try {
1411
- const typedHttp429 = await readTypedHttp429Observation(input.runDirectory);
1412
- return typedHttp429 === undefined ? {} : { typedHttp429 };
1413
- } catch (error) {
1414
- const failure = error instanceof Error ? error : new Error(String(error));
1415
- return {
1416
- observationReadFailure: {
1417
- cause: "session",
1418
- identity: thrownIdentity(failure),
1419
- diagnostic: failure.message || failure.name,
1420
- },
1421
- };
1422
- }
1423
- }
1424
-
1425
- /** Spread into presentControlledFailure failureInput from one audited resolution. */
1426
- export function controlledFailureInputFromResolution(
1427
- resolution: AuditedRunnerFailureResolution,
1428
- ): {
1429
- knownFailure?: RoleTurnKnownFailure;
1430
- typedHttpObservationSettled?: true;
1431
- typedHttpObservation?: TypedProviderHttpObservation;
1432
- } {
1433
- return {
1434
- ...(resolution.knownFailure === undefined ? {} : { knownFailure: resolution.knownFailure }),
1435
- ...(resolution.typedHttpObservationSettled
1436
- ? {
1437
- typedHttpObservationSettled: true as const,
1438
- ...(resolution.typedHttpObservation === undefined
1439
- ? {}
1440
- : { typedHttpObservation: resolution.typedHttpObservation }),
1441
- }
1442
- : {}),
1443
- };
1444
- }
1445
-
1446
- function safelyRead(object: object, key: string): { readable: true; value: unknown } | { readable: false } {
1447
- try {
1448
- return { readable: true, value: (object as Record<string, unknown>)[key] };
1449
- } catch {
1450
- return { readable: false };
1451
- }
1452
- }
1453
-
1454
- function toolResultText(message: SessionMessage): string {
1455
- const content = message.content;
1456
- if (typeof content === "string") return content.trim();
1457
- if (!Array.isArray(content)) return "";
1458
- return content
1459
- .map((part) => {
1460
- if (
1461
- isRecord(part) &&
1462
- typeof (part as { text?: unknown }).text === "string"
1463
- ) {
1464
- return (part as { text: string }).text;
1465
- }
1466
- return "";
1467
- })
1468
- .join("")
1469
- .trim();
1470
- }
1471
-
1472
- type BoundErroredToolCandidate = {
1473
- candidate: unknown;
1474
- diagnostic: string;
1475
- callIndex: number;
1476
- };
1477
-
1478
- function boundErroredToolCandidate(
1479
- entries: readonly SessionEntry[],
1480
- resultIndex: number,
1481
- message: SessionMessage,
1482
- toolName: string,
1483
- ): BoundErroredToolCandidate | undefined {
1484
- if (message.toolName !== toolName || message.isError !== true) return undefined;
1485
- const bound = boundRoleToolCallForResult(entries, resultIndex, message, toolName);
1486
- const diagnostic = toolResultText(message);
1487
- return bound === undefined || diagnostic === ""
1488
- ? undefined
1489
- : { candidate: bound.candidate, diagnostic, callIndex: bound.callIndex };
1490
- }
1491
-
1492
- /** Shared match/cause/identity knobs for session-principal infrastructure failures. */
1493
- type InfrastructureFailureSpec = Readonly<{
1494
- matchTool: (toolName: string) => boolean;
1495
- cause: ControlledFailureCause;
1496
- identityName: string;
1497
- /**
1498
- * Errored results only count as infrastructure when the durable details carry
1499
- * the typed navigator fact.
1500
- */
1501
- requireInfrastructureFact?: (toolName: string) => boolean;
1502
- }>;
1503
-
1504
- const ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC: InfrastructureFailureSpec = {
1505
- matchTool: (toolName) => toolName === ENGINE_DETOUR_TOOL_NAME,
1506
- cause: "output",
1507
- identityName: "EngineDetourInfrastructureError",
1508
- };
1509
-
1510
- /**
1511
- * Prefer a real infrastructure tool failure already on the session principal
1512
- * over a later secondary provider-stop (failure-honesty).
1513
- * Tool match + cause + identity are call-site parameters — one extraction body.
1514
- */
1515
- function extractInfrastructureToolFailure(
1516
- entries: readonly SessionEntry[],
1517
- spec: InfrastructureFailureSpec,
1518
- ): ControlledFailure | undefined {
1519
- for (let i = entries.length - 1; i >= 0; i -= 1) {
1520
- const entry = entries[i];
1521
- if (entry?.type !== "message") continue;
1522
- const message = entry.message;
1523
- if (message?.role !== "toolResult") continue;
1524
- if (message.isError !== true) continue;
1525
- if (
1526
- typeof message.toolName !== "string" ||
1527
- !spec.matchTool(message.toolName)
1528
- ) {
1529
- continue;
1530
- }
1531
- if (spec.requireInfrastructureFact?.(message.toolName) === true) {
1532
- if (!hasNavigatorInfrastructureFailureBase(message.details)) continue;
1533
- }
1534
- const diagnostic = toolResultText(message);
1535
- if (diagnostic.length === 0) continue;
1536
- return {
1537
- cause: spec.cause,
1538
- diagnostic,
1539
- identity: { name: spec.identityName },
1540
- };
1541
- }
1542
- return undefined;
1543
- }
1544
-
1545
- /**
1546
- * Read the bound session principal for a parameterized infrastructure tool failure.
1547
- * `currentAttemptOnly` bounds the reverse scan to the latest top-level user turn
1548
- * so a prior attempt's residual cannot mask the current failure (#633).
1549
- */
1550
- async function readInfrastructureToolFailure(
1551
- sessionFile: string,
1552
- spec: InfrastructureFailureSpec,
1553
- options: { readonly currentAttemptOnly?: boolean } = {},
1554
- ): Promise<ControlledFailure | undefined> {
1555
- try {
1556
- let entries = await readBoundSessionEntries(sessionFile);
1557
- if (options.currentAttemptOnly === true) {
1558
- entries = entries.slice(currentAttemptStartIndex(entries));
1559
- }
1560
- return extractInfrastructureToolFailure(entries, spec);
1561
- } catch {
1562
- return undefined;
1563
- }
1564
- }
1565
-
1566
- /**
1567
- * Prefer a real engine-detour infrastructure tool failure already on the session
1568
- * principal over a later secondary provider-stop (failure-honesty / #357 T2).
1569
- * Cause stays `output` — labor leg failed before accepted typed Receipt.
1570
- */
1571
- export function extractEngineDetourInfrastructureFailure(
1572
- entries: readonly SessionEntry[],
1573
- ): ControlledFailure | undefined {
1574
- return extractInfrastructureToolFailure(
1575
- entries,
1576
- ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC,
1577
- );
1578
- }
1579
-
1580
- /** Read the bound session principal for an engine-detour infrastructure failure. */
1581
- export async function readEngineDetourInfrastructureFailure(
1582
- sessionFile: string,
1583
- ): Promise<ControlledFailure | undefined> {
1584
- return readInfrastructureToolFailure(
1585
- sessionFile,
1586
- ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC,
1587
- );
1588
- }
1589
-
1590
- function knownFailureFromControlled(failure: ControlledFailure): RoleTurnKnownFailure {
1591
- return {
1592
- ...(failure.cause === undefined ? {} : { cause: failure.cause }),
1593
- diagnostic: failure.diagnostic,
1594
- ...(failure.identity === undefined ? {} : { identity: failure.identity }),
1595
- };
1596
- }
1597
-
1598
- const RUNNER_FAILURE_POLICY = {
1599
- "engine-detour-known-first": {
1600
- read: readEngineDetourInfrastructureFailure,
1601
- rank: "known-first",
1602
- },
1603
- "engine-detour-record-first": {
1604
- read: readEngineDetourInfrastructureFailure,
1605
- rank: "record-first",
1606
- },
1607
- } as const;
1608
-
1609
- /**
1610
- * Runner-failure rank for one seat. The composition-root `runnerFailure`
1611
- * leaf is the only seat difference; absent means the runner knownFailure stands.
1612
- */
1613
- export function seatKnownFailureResolver(
1614
- role: PackagedRole,
1615
- ): ((input: {
1616
- result: { knownFailure?: RoleTurnKnownFailure };
1617
- sessionFile: string;
1618
- }) => Promise<RoleTurnKnownFailure | undefined>) | undefined {
1619
- const record = packagedRoleMetadata(role);
1620
- if (record === undefined || !("runnerFailure" in record)) return undefined;
1621
- const policy = RUNNER_FAILURE_POLICY[record.runnerFailure];
1622
- return async ({ result, sessionFile }) => {
1623
- const infrastructureFailure = await policy.read(sessionFile);
1624
- if (policy.rank === "record-first") {
1625
- return infrastructureFailure === undefined
1626
- ? result.knownFailure
1627
- : knownFailureFromControlled(infrastructureFailure);
1628
- }
1629
- return result.knownFailure ?? (
1630
- infrastructureFailure === undefined ? undefined : knownFailureFromControlled(infrastructureFailure)
1631
- );
1632
- };
1633
- }
1634
-
1635
- function auditToolNameForRole(
1636
- role: (typeof AUDITOR_SOUL_ROLES)[number],
1637
- ): string {
1638
- const name = packagedAuditToolName(role);
1639
- if (name === undefined) {
1640
- throw new Error(`audit tool is not declared for ${role}`);
1641
- }
1642
- return name;
1643
- }
1644
-
1645
- type BoundRoleToolCall = {
1646
- callIndex: number;
1647
- candidate: unknown;
1648
- };
1649
-
1650
- function boundRoleToolCallForResult(
1651
- entries: readonly SessionEntry[],
1652
- resultIndex: number,
1653
- message: SessionMessage,
1654
- outputToolName: string,
1655
- ): BoundRoleToolCall | undefined {
1656
- const callId = message.toolCallId;
1657
- if (typeof callId !== "string" || callId.trim() === "") return undefined;
1658
-
1659
- const calls: BoundRoleToolCall[] = [];
1660
- let resultCount = 0;
1661
- let matchingResultIndex = -1;
1662
- for (let index = 0; index < entries.length; index += 1) {
1663
- const candidateMessage = entries[index]?.message;
1664
- if (
1665
- candidateMessage?.role === "assistant" &&
1666
- Array.isArray(candidateMessage.content)
1667
- ) {
1668
- for (const part of candidateMessage.content) {
1669
- if (!isRecord(part) || part.type !== "toolCall" || part.id !== callId) {
1670
- continue;
1671
- }
1672
- if (part.name !== outputToolName) return undefined;
1673
- calls.push({ callIndex: index, candidate: part.arguments });
1674
- }
1675
- }
1676
- if (
1677
- candidateMessage?.role === "toolResult" &&
1678
- candidateMessage.toolCallId === callId
1679
- ) {
1680
- resultCount += 1;
1681
- if (candidateMessage.toolName !== outputToolName) return undefined;
1682
- matchingResultIndex = index;
1683
- }
1684
- }
1685
-
1686
- // A binding is an event-bound one-to-one relation, not a reverse lookup of
1687
- // whichever result happens to be last in the session.
1688
- return calls.length === 1 && resultCount === 1 && matchingResultIndex === resultIndex
1689
- && calls[0]!.callIndex < resultIndex
1690
- ? calls[0]
1691
- : undefined;
1692
- }
1693
-
1694
- type BoundRetainedAuditResponse = {
1695
- candidate: unknown;
1696
- };
1697
-
1698
- type BoundAuditEscalation = {
1699
- decision: Extract<ComplianceDecision, { status: "escalate" }>;
1700
- details: Record<string, unknown>;
1023
+ cwd?: string;
1024
+ /** Parent session principal on durable child session headers. */
1025
+ parentSession?: string;
1701
1026
  };
1702
1027
 
1703
- function sameAuditValue(left: unknown, right: unknown): boolean {
1704
- if (Object.is(left, right)) return true;
1705
- if (Array.isArray(left) && Array.isArray(right)) {
1706
- return left.length === right.length && left.every((value, index) =>
1707
- sameAuditValue(value, right[index]),
1708
- );
1709
- }
1710
- if (isRecord(left) && isRecord(right)) {
1711
- const leftKeys = Object.keys(left);
1712
- const rightKeys = Object.keys(right);
1713
- return leftKeys.length === rightKeys.length &&
1714
- leftKeys.every((key) => Object.hasOwn(right, key) && sameAuditValue(left[key], right[key]));
1715
- }
1716
- return false;
1717
- }
1718
-
1719
- /** Snapshot the exact enumerable string face that final Terminal projection uses. */
1720
- function snapshotAuditDetails(details: Record<string, unknown>): Record<string, unknown> {
1721
- const snapshot: Record<string, unknown> = Object.create(null);
1722
- for (const key of Object.keys(details)) {
1723
- Object.defineProperty(snapshot, key, {
1724
- value: details[key],
1725
- enumerable: true,
1726
- configurable: true,
1727
- writable: true,
1728
- });
1729
- }
1730
- return snapshot;
1731
- }
1732
-
1733
1028
  /**
1734
- * Bind the public escalation face to the one retained response that sits inside
1735
- * the same role output call/result interval. A `kind` field alone is never a
1736
- * terminal identity; the retained response must be this seat's real escalate
1737
- * decision and its projected audit-owned fields must agree with it.
1029
+ * Read the exact bound Pi session file principal.
1030
+ * Does not scan the session directory for "latest" — resume identity is the file.
1031
+ * A malformed line keeps the parser's own error. Callers that already hold a
1032
+ * terminal note it; they do not relabel it as a host session cause.
1738
1033
  */
1739
- // Historical pre-#1057 session artifacts may still contain this old projection;
1740
- // no current submission path writes it, but resume/settlement must read it.
1741
- function boundAuditEscalationForResult(
1742
- entries: readonly SessionEntry[],
1743
- resultIndex: number,
1744
- message: SessionMessage,
1745
- role: (typeof AUDITOR_SOUL_ROLES)[number],
1746
- outputToolName: string,
1747
- ): BoundAuditEscalation | undefined {
1748
- const roleCall = boundRoleToolCallForResult(
1749
- entries,
1750
- resultIndex,
1751
- message,
1752
- outputToolName,
1753
- );
1754
- if (roleCall === undefined) return undefined;
1755
- const retained = boundRetainedAuditResponse(
1756
- entries,
1757
- roleCall.callIndex,
1758
- resultIndex,
1759
- auditToolNameForRole(role),
1760
- );
1761
- if (retained === undefined) return undefined;
1762
- try {
1763
- const decision = readComplianceCandidate(retained.candidate);
1764
- if (decision.status !== "escalate") return undefined;
1765
- const details = message.details;
1766
- if (!isAuditEscalationResult(details) || !isRecord(details)) return undefined;
1767
-
1768
- // Read the public face exactly once. Besides making key enumeration and
1769
- // getters fail closed, this prevents a stateful accessor from authenticating
1770
- // one value and yielding another during final Terminal projection.
1771
- const projectedDetails = snapshotAuditDetails(details);
1772
- const hasDecisionConflicts = Object.hasOwn(decision, "conflicts");
1773
- const hasDetailsConflicts = Object.hasOwn(projectedDetails, "conflicts");
1774
- if (hasDecisionConflicts !== hasDetailsConflicts) return undefined;
1775
- if (hasDecisionConflicts && !sameAuditValue(projectedDetails.conflicts, decision.conflicts)) return undefined;
1776
- const hasDecisionGate = Object.hasOwn(decision, "decisionGate");
1777
- const hasDetailsGate = Object.hasOwn(projectedDetails, "auditDecisionGate");
1778
- if (hasDecisionGate !== hasDetailsGate) return undefined;
1779
- if (hasDecisionGate && !sameAuditValue(projectedDetails.auditDecisionGate, decision.decisionGate)) return undefined;
1780
- return { decision, details: projectedDetails };
1781
- } catch {
1782
- // Retained/public own-key enumeration, property reads, recursive equality,
1783
- // and projection are all untrusted session evidence.
1784
- return undefined;
1785
- }
1786
- }
1787
-
1788
- function isUnboundAuditEscalationFace(details: unknown): boolean {
1789
- try {
1790
- if (isAuditEscalationResult(details)) return true;
1791
- } catch {
1792
- // Hostile access is not authentic escalation evidence.
1793
- }
1794
- if (!isRecord(details)) return false;
1795
- const kind = safelyRead(details, "kind");
1796
- return kind.readable && kind.value === "audit_escalation";
1797
- }
1798
-
1799
- function boundRetainedAuditResponse(
1800
- entries: readonly SessionEntry[],
1801
- callIndex: number,
1802
- resultIndex: number,
1803
- auditToolName: string,
1804
- ): BoundRetainedAuditResponse | undefined {
1805
- const matches: BoundRetainedAuditResponse[] = [];
1806
- for (let index = callIndex + 1; index < resultIndex; index += 1) {
1807
- const entry = entries[index];
1808
- if (entry?.type !== "custom" || entry.customType !== COMPLIANCE_RESPONSE_ENTRY_TYPE) {
1809
- continue;
1810
- }
1811
- if (!isRecord(entry.data) || !isRecord(entry.data.response)) continue;
1812
- const response = entry.data.response;
1813
- if (!Array.isArray(response.content)) continue;
1814
- const calls = response.content.filter(
1815
- (part): part is Record<string, unknown> =>
1816
- isRecord(part) && part.type === "toolCall",
1817
- );
1818
- if (calls.length !== 1 || calls[0]?.name !== auditToolName) continue;
1819
- matches.push({ candidate: calls[0]?.arguments });
1034
+ export async function readBoundSessionEntries(
1035
+ sessionFile: string,
1036
+ ): Promise<SessionEntry[]> {
1037
+ const text = await readFile(sessionFile, "utf8");
1038
+ const entries: SessionEntry[] = [];
1039
+ for (const line of text.trim().split("\n").filter(Boolean)) {
1040
+ entries.push(JSON.parse(line) as SessionEntry);
1820
1041
  }
1821
- // Unique seat-bound match binds even when multi-turn investigation retained
1822
- // intermediate non-decision responses in the same call/result interval.
1823
- return matches.length === 1 ? matches[0] : undefined;
1042
+ return entries;
1824
1043
  }
1825
1044
 
1826
1045
  /**
@@ -1842,57 +1061,19 @@ type AttemptHistorySource = {
1842
1061
  readonly sessionFile: string;
1843
1062
  };
1844
1063
 
1845
- /**
1846
- * Append one attempt's complete result to the run's session principal.
1847
- * Append failure throws — callers must not overwrite a pointer artifact when
1848
- * the history entry backing the overwrite did not land (fail closed).
1849
- */
1064
+ /** Append the complete attempt to the package ledger before overwriting pointer artifacts (#419). */
1850
1065
  export async function appendRunAttemptHistory(
1851
1066
  source: AttemptHistorySource,
1852
1067
  outcome: AttemptHistoryOutcome,
1853
1068
  ): Promise<void> {
1854
- const entries = await readBoundSessionEntries(source.sessionFile);
1855
- let parentId: string | null = null;
1856
- let priorEntries = 0;
1857
- for (const entry of entries) {
1858
- if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
1859
- if (
1860
- entry.type === "custom" &&
1861
- entry.customType === ATTEMPT_HISTORY_ENTRY_TYPE
1862
- ) {
1863
- priorEntries += 1;
1864
- }
1865
- }
1866
- const timestamp = new Date().toISOString();
1867
- const attemptData = {
1868
- sequence: priorEntries + 1,
1869
- role: source.role,
1870
- runId: source.runId,
1871
- recordedAt: timestamp,
1872
- outcome,
1873
- };
1874
- const line = `${JSON.stringify({
1875
- type: "custom",
1876
- customType: ATTEMPT_HISTORY_ENTRY_TYPE,
1877
- data: attemptData,
1878
- id: randomUUID(),
1879
- parentId,
1880
- timestamp,
1881
- })}\n`;
1882
- await appendFile(source.sessionFile, line, "utf8");
1883
- try {
1884
- sitianReport({
1885
- level: "event",
1886
- kind: "attempt-history",
1887
- subject: { runId: source.runId },
1888
- sessionParent: source.sessionFile,
1889
- payload: {
1890
- type: ATTEMPT_HISTORY_ENTRY_TYPE,
1891
- ...attemptData,
1892
- },
1893
- source: "settlement",
1894
- });
1895
- } catch {}
1069
+ sitianReport({
1070
+ level: "event",
1071
+ kind: "attempt-history",
1072
+ subject: { runId: source.runId },
1073
+ sessionParent: source.sessionFile,
1074
+ payload: { type: ATTEMPT_HISTORY_ENTRY_TYPE, role: source.role, runId: source.runId, outcome },
1075
+ source: "settlement",
1076
+ });
1896
1077
  }
1897
1078
 
1898
1079
  function parseNavigatorAttendanceDetails(
@@ -2033,10 +1214,9 @@ export async function extractGateFactFromSessionDirectory(
2033
1214
  /**
2034
1215
  * Attach optional gate projection onto a settled Terminal base.
2035
1216
  * Shared by every settle path so auditor-roles is scanned once here only.
2036
- * `runId` is not required — resumable failures omit it by contract.
2037
- * Gate read damage propagates with its real identity (never washed to no-gate
2038
- * or swallowed); callers that already hold a controlled failure still surface the
2039
- * JSONL/session cause rather than pretend the gate was absent.
1217
+ * `runId` is optional only for a batch projection that has no parent run.
1218
+ * Gate read damage is a package note beside the host terminal. It does not
1219
+ * replace that terminal and it does not omit the gate without a trace.
2040
1220
  */
2041
1221
  /**
2042
1222
  * #537: project this-invocation ak_engine_detour usage onto decisiveFacts.
@@ -2047,7 +1227,7 @@ export async function extractGateFactFromSessionDirectory(
2047
1227
  * therefore cannot replace an already-formed roleOutcome (no_receipt / failure).
2048
1228
  */
2049
1229
  async function attachEngineDetourToolUsage<
2050
- T extends { roleOutcome: TerminalRoleOutcome; resume?: TerminalResume },
1230
+ T extends { roleOutcome: TerminalRoleOutcome },
2051
1231
  >(
2052
1232
  base: T,
2053
1233
  sessionDirectory: string,
@@ -2055,13 +1235,26 @@ async function attachEngineDetourToolUsage<
2055
1235
  readonly runDirectory?: string;
2056
1236
  readonly courtAttemptId?: string;
2057
1237
  readonly invocationScopeId?: string;
1238
+ readonly notePackageFault?: SettlementCourtScope["notePackageFault"];
2058
1239
  } = {},
2059
1240
  ): Promise<T> {
2060
1241
  const runDirectory =
2061
1242
  typeof gateContext.runDirectory === "string" && gateContext.runDirectory.length > 0
2062
1243
  ? gateContext.runDirectory
2063
1244
  : runDirectoryFromSessionDirectory(sessionDirectory);
2064
- const engineMounted = await readInvocationEngineMounted(runDirectory);
1245
+ let engineMounted = false;
1246
+ try {
1247
+ engineMounted = await readInvocationEngineMounted(runDirectory);
1248
+ } catch (error) {
1249
+ await noteSettlementFault(
1250
+ runDirectory,
1251
+ gateContext.notePackageFault === undefined
1252
+ ? undefined
1253
+ : { notePackageFault: gateContext.notePackageFault },
1254
+ `engine mount read failed beside host terminal: ${describeErrorIdentity(error)}`,
1255
+ );
1256
+ return base;
1257
+ }
2065
1258
  if (!engineMounted) return base;
2066
1259
 
2067
1260
  // Public-invocation scope from the shared Host envelope (settlement scope), never
@@ -2073,22 +1266,27 @@ async function attachEngineDetourToolUsage<
2073
1266
  : undefined;
2074
1267
 
2075
1268
  const sessionFile = sessionFileFromSessionDirectory(sessionDirectory);
2076
- const usage = await readEngineDetourToolUsage({
2077
- sessionParent: sessionFile,
2078
- engineMounted: true,
2079
- ...(invocationScopeId === undefined ? {} : { invocationScopeId }),
2080
- cwd: runDirectory,
2081
- });
2082
- const projected =
2083
- usage === undefined
2084
- ? undefined
2085
- : projectEngineDetourToolUsageForPublicTerminal(usage, {
2086
- // Resumable Terminal: run ID only in resume.command — relative openable path.
2087
- discloseRecordFile: base.resume === undefined,
2088
- });
1269
+ let usage: Awaited<ReturnType<typeof readEngineDetourToolUsage>>;
1270
+ try {
1271
+ usage = await readEngineDetourToolUsage({
1272
+ sessionParent: sessionFile,
1273
+ engineMounted: true,
1274
+ ...(invocationScopeId === undefined ? {} : { invocationScopeId }),
1275
+ cwd: runDirectory,
1276
+ });
1277
+ } catch (error) {
1278
+ await noteSettlementFault(
1279
+ runDirectory,
1280
+ gateContext.notePackageFault === undefined
1281
+ ? undefined
1282
+ : { notePackageFault: gateContext.notePackageFault },
1283
+ `engine detour read failed beside host terminal: ${describeErrorIdentity(error)}`,
1284
+ );
1285
+ return base;
1286
+ }
2089
1287
  return {
2090
1288
  ...base,
2091
- roleOutcome: withEngineDetourToolUsageFact(base.roleOutcome, projected),
1289
+ roleOutcome: withEngineDetourToolUsageFact(base.roleOutcome, usage),
2092
1290
  };
2093
1291
  }
2094
1292
 
@@ -2109,6 +1307,7 @@ function detourGateContext(
2109
1307
  ...(scope?.invocationScopeId === undefined || scope.invocationScopeId.length === 0
2110
1308
  ? {}
2111
1309
  : { invocationScopeId: scope.invocationScopeId }),
1310
+ ...(scope?.notePackageFault === undefined ? {} : { notePackageFault: scope.notePackageFault }),
2112
1311
  };
2113
1312
  }
2114
1313
 
@@ -2117,7 +1316,6 @@ async function withOptionalGateProjection<
2117
1316
  roleOutcome: TerminalRoleOutcome;
2118
1317
  navigator: TerminalNavigatorFact;
2119
1318
  artifacts: readonly TerminalArtifactRef[];
2120
- resume?: TerminalResume;
2121
1319
  },
2122
1320
  >(
2123
1321
  base: T,
@@ -2127,6 +1325,7 @@ async function withOptionalGateProjection<
2127
1325
  readonly parentSessionFile?: string;
2128
1326
  readonly courtAttemptId?: string;
2129
1327
  readonly invocationScopeId?: string;
1328
+ readonly notePackageFault?: SettlementCourtScope["notePackageFault"];
2130
1329
  } = {},
2131
1330
  ): Promise<T & { gate?: TerminalGateFact }> {
2132
1331
  // A gate transport failure is already represented by typed evidence and has no
@@ -2142,9 +1341,19 @@ async function withOptionalGateProjection<
2142
1341
 
2143
1342
  let next: T & { gate?: TerminalGateFact } = base;
2144
1343
  if (!skipGate) {
2145
- // Defaults live solely in extractGateFactFromSessionDirectory — do not re-derive.
2146
- const gate = await extractGateFactFromSessionDirectory(sessionDirectory, gateContext);
2147
- if (gate !== undefined) next = { ...base, gate };
1344
+ try {
1345
+ const gate = await extractGateFactFromSessionDirectory(sessionDirectory, gateContext);
1346
+ if (gate !== undefined) next = { ...base, gate };
1347
+ } catch (error) {
1348
+ const runDirectory = gateContext.runDirectory ?? runDirectoryFromSessionDirectory(sessionDirectory);
1349
+ await noteSettlementFault(
1350
+ runDirectory,
1351
+ gateContext.notePackageFault === undefined
1352
+ ? undefined
1353
+ : { notePackageFault: gateContext.notePackageFault },
1354
+ `gate read failed beside host terminal: ${describeErrorIdentity(error)}`,
1355
+ );
1356
+ }
2148
1357
  }
2149
1358
 
2150
1359
  return attachEngineDetourToolUsage(next, sessionDirectory, gateContext);
@@ -2207,6 +1416,8 @@ function extractNavigatorAttendanceFact(
2207
1416
  */
2208
1417
  async function extractNavigatorFactFromAdmittedSession(
2209
1418
  sessionFile: string,
1419
+ runDirectory: string,
1420
+ scope: SettlementCourtScope | undefined,
2210
1421
  ): Promise<TerminalNavigatorFact> {
2211
1422
  try {
2212
1423
  const entries = await readBoundSessionEntries(sessionFile);
@@ -2219,6 +1430,11 @@ async function extractNavigatorFactFromAdmittedSession(
2219
1430
  reason: "Navigator attendance is missing from the session",
2220
1431
  };
2221
1432
  }
1433
+ await noteSettlementFault(
1434
+ runDirectory,
1435
+ scope,
1436
+ `navigator session read failed beside host terminal: ${sessionFile}: ${describeErrorIdentity(error)}`,
1437
+ );
2222
1438
  return {
2223
1439
  disposition: "unavailable",
2224
1440
  source: "unknown",
@@ -2308,31 +1524,26 @@ function acceptedArtifactAttachmentRefs(
2308
1524
  * Seat-specific structured fields stay in callers; do not fork this flow.
2309
1525
  */
2310
1526
  async function publishAcceptedTerminalArtifacts(
2311
- admitted: {
2312
- readonly role: TerminalRoleName;
2313
- readonly runId: string;
2314
- readonly runDirectory: string;
2315
- },
1527
+ admitted: AdmittedRoleInvocation,
2316
1528
  roleOutcome: TerminalRoleOutcome,
2317
1529
  coordinates: DurablePrincipalCoordinates,
1530
+ recordAttemptHistory: boolean,
2318
1531
  bodies: {
2319
1532
  readonly report: Record<string, unknown>;
2320
1533
  readonly evidence: Record<string, unknown>;
2321
1534
  },
2322
1535
  ): Promise<TerminalArtifactRef[]> {
2323
- // #419: history first — report/evidence stay last-write-wins views only
2324
- // because every attempt's complete result has already been appended.
2325
- await appendRunAttemptHistory(
2326
- {
2327
- role: admitted.role,
2328
- runId: admitted.runId,
2329
- sessionFile: coordinates.sessionFile,
2330
- },
2331
- roleOutcome,
2332
- );
1536
+ // #419: a dispatched turn appends before rewriting last-write-wins views;
1537
+ // a later projection may refresh views but must not invent another attempt.
1538
+ if (recordAttemptHistory) {
1539
+ await appendRunAttemptHistory(
1540
+ { role: admitted.role, runId: admitted.runId, sessionFile: coordinates.sessionFile },
1541
+ roleOutcome,
1542
+ );
1543
+ }
2333
1544
  const artifactsDir = await ensureTerminalArtifactFace(admitted.runDirectory);
2334
- const reportPath = join(artifactsDir, "report.json");
2335
- const evidencePath = join(artifactsDir, "evidence.json");
1545
+ const reportPath = join(artifactsDir, RUN_TERMINAL_REPORT_FILE);
1546
+ const evidencePath = join(artifactsDir, RUN_TERMINAL_EVIDENCE_FILE);
2336
1547
  await writeFile(
2337
1548
  reportPath,
2338
1549
  `${JSON.stringify(bodies.report, null, 2)}\n`,
@@ -2349,140 +1560,78 @@ async function publishAcceptedTerminalArtifacts(
2349
1560
  ];
2350
1561
  }
2351
1562
 
2352
- /** Publish one accepted seat's report/evidence face from the shared leaf table. */
2353
- export async function publishSeatAcceptedArtifacts(
2354
- admitted: AdmittedRoleInvocation,
2355
- roleOutcome: TerminalRoleOutcome,
2356
- coordinates: DurablePrincipalCoordinates,
2357
- entries: readonly SessionEntry[] = [],
2358
- ): Promise<TerminalArtifactRef[]> {
2359
- return publishDeclaredSeatArtifacts(admitted, roleOutcome, coordinates, entries);
2360
- }
1563
+ type LawfulSessionRead =
1564
+ | { readonly kind: "entries"; readonly entries: SessionEntry[] }
1565
+ | { readonly kind: "absent" }
1566
+ | { readonly kind: "fault"; readonly error: unknown };
2361
1567
 
2362
1568
  /**
2363
- * Read session entries for lawful settlement. Missing path → undefined (absence).
2364
- * Malformed JSONL / other read failures throw with knownCause=session.
1569
+ * Read session entries for lawful settlement.
1570
+ * Missing path is absence. A present file that cannot be read is a fault,
1571
+ * not a host cause and not an empty transcript.
2365
1572
  */
2366
1573
  async function readLawfulSettlementEntries(
2367
1574
  sessionFile: string,
2368
- ): Promise<SessionEntry[] | undefined> {
1575
+ ): Promise<LawfulSessionRead> {
2369
1576
  try {
2370
- return await readBoundSessionEntries(sessionFile);
1577
+ return { kind: "entries", entries: await readBoundSessionEntries(sessionFile) };
2371
1578
  } catch (error) {
2372
- // Missing path is absence of a lawful outcome; callers classify via session inspect.
2373
- if (isEnoent(error)) return undefined;
2374
- // Malformed JSONL and other read failures keep typed session identity.
2375
- throw error instanceof Error &&
2376
- (error as { knownCause?: unknown }).knownCause === "session"
2377
- ? error
2378
- : sessionReadFailure(error, "session unreadable");
1579
+ if (isEnoent(error)) return { kind: "absent" };
1580
+ return { kind: "fault", error };
2379
1581
  }
2380
1582
  }
2381
1583
 
2382
- /**
2383
- * Sealed-ledger seats that publish on a lawful outcome and do not scan a
2384
- * residual tool or attach run-scoped submission history.
2385
- * Absence stays undefined. Session-read and publication errors keep their identity.
2386
- * Outcome is read before publication so a later write error cannot erase it.
2387
- */
2388
- async function settleSealedLedgerTerminal(
2389
- admitted: AdmittedRoleInvocation,
2390
- authority: DurablePrincipalAuthority,
2391
- scope: SettlementCourtScope | undefined,
2392
- publish: (
2393
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }>,
2394
- coordinates: DurablePrincipalCoordinates,
2395
- entries: readonly SessionEntry[],
2396
- ) => Promise<TerminalArtifactRef[]>,
2397
- accept?: (
2398
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }>,
2399
- ) => boolean,
2400
- ): Promise<TerminalResult | undefined> {
2401
- const roleOutcome = await sealedLedgerOutcome(admitted, admitted.role, scope);
2402
- if (roleOutcome === undefined) return undefined;
2403
- if (accept !== undefined && !accept(roleOutcome)) return undefined;
2404
- const coordinates = coordinatesFromAdmitted(authority, admitted);
2405
- const entries = await readLawfulSettlementEntries(coordinates.sessionFile) ?? [];
2406
- const artifacts = await publish(roleOutcome, coordinates, entries);
2407
- return withOptionalGateProjection(
2408
- {
2409
- roleOutcome,
2410
- navigator: extractNavigatorFact(entries),
2411
- artifacts,
2412
- runId: admitted.runId,
2413
- },
2414
- coordinates.sessionDirectory,
2415
- detourGateContext(admitted, scope),
2416
- );
1584
+ function entriesOf(read: LawfulSessionRead): SessionEntry[] {
1585
+ return read.kind === "entries" ? read.entries : [];
2417
1586
  }
2418
1587
 
2419
- /**
2420
- * Sealed accepted outcome publishes. Any other ledger face falls through to
2421
- * the latest bound errored tool result in the scan window, which settles as
2422
- * output failure. Both faces attach run-scoped submissions. Absence of both
2423
- * stays undefined. Session is read first so a read failure keeps its identity
2424
- * even when the ledger has no accepted row.
2425
- */
2426
- async function settleSealedAcceptedOrToolResidual(
1588
+ /** Sealed seats present the ledger. A session tool error does not invent a host failure. */
1589
+ async function settleSealedSeat(
2427
1590
  admitted: AdmittedRoleInvocation,
2428
1591
  authority: DurablePrincipalAuthority,
2429
1592
  scope: SettlementCourtScope | undefined,
2430
- role: TerminalRoleName,
2431
- residualToolName: string,
2432
- scan: "current-attempt" | "session",
2433
- publish: (
2434
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" }>,
2435
- coordinates: DurablePrincipalCoordinates,
2436
- entries: readonly SessionEntry[],
2437
- ) => Promise<TerminalArtifactRef[]>,
1593
+ options: {
1594
+ acceptedOnly: boolean;
1595
+ },
2438
1596
  ): Promise<TerminalResult | undefined> {
2439
1597
  const coordinates = coordinatesFromAdmitted(authority, admitted);
2440
- const { sessionDirectory, sessionFile } = coordinates;
2441
- const entries = await readLawfulSettlementEntries(sessionFile) ?? [];
2442
- const roleOutcome = await sealedLedgerOutcome(admitted, role, scope);
2443
- if (roleOutcome?.role !== role || roleOutcome.kind !== "accepted") {
2444
- const scanStart = scan === "current-attempt" ? currentAttemptStartIndex(entries) : 0;
2445
- for (let index = entries.length - 1; index >= scanStart; index -= 1) {
2446
- const message = entries[index]?.message;
2447
- if (message?.role !== "toolResult") continue;
2448
- const residual = boundErroredToolCandidate(entries, index, message, residualToolName);
2449
- if (residual === undefined) continue;
2450
- const candidate = residual.candidate;
2451
- const details = isRecord(candidate) ? candidate : { candidate };
2452
- const failed = await settleFailureTerminalResult(
2453
- admitted,
2454
- {
2455
- cause: "output",
2456
- diagnostic: residual.diagnostic,
2457
- details,
2458
- },
2459
- authority,
2460
- scope ?? {},
2461
- );
2462
- return attachRecordedSubmissions(admitted, failed, scope);
2463
- }
1598
+ const roleOutcome = await sealedLedgerOutcome(admitted, admitted.role, scope);
1599
+ if (roleOutcome === undefined || (options.acceptedOnly && roleOutcome.kind !== "accepted")) {
2464
1600
  return undefined;
2465
1601
  }
2466
- const artifacts = await publish(roleOutcome, coordinates, entries);
2467
- return attachRecordedSubmissions(
2468
- admitted,
2469
- await withOptionalGateProjection(
2470
- {
2471
- roleOutcome,
2472
- navigator: extractNavigatorFact(entries),
2473
- artifacts,
2474
- runId: admitted.runId,
2475
- },
2476
- sessionDirectory,
2477
- detourGateContext(admitted, scope),
2478
- ),
2479
- scope,
2480
- );
1602
+ const read = await readLawfulSettlementEntries(coordinates.sessionFile);
1603
+ if (read.kind === "fault") {
1604
+ await noteSettlementFault(
1605
+ admitted.runDirectory,
1606
+ scope,
1607
+ `session read failed beside lawful terminal: ${describeErrorIdentity(read.error)}`,
1608
+ );
1609
+ }
1610
+ return finishLawfulSeat(admitted, coordinates, entriesOf(read), roleOutcome, scope);
1611
+ }
1612
+
1613
+ async function finishLawfulSeat(
1614
+ admitted: AdmittedRoleInvocation,
1615
+ coordinates: DurablePrincipalCoordinates,
1616
+ entries: readonly SessionEntry[],
1617
+ roleOutcome: TerminalRoleOutcome,
1618
+ scope: SettlementCourtScope | undefined,
1619
+ ): Promise<TerminalResult> {
1620
+ const artifacts = scope?.previewOnly === true
1621
+ ? []
1622
+ : await publishDeclaredSeatArtifacts(admitted, roleOutcome, coordinates, entries, scope?.recordAttemptHistory === true);
1623
+ const terminal = await withOptionalGateProjection({
1624
+ roleOutcome,
1625
+ navigator: extractNavigatorFact(entries),
1626
+ artifacts,
1627
+ runId: admitted.runId,
1628
+ }, coordinates.sessionDirectory, detourGateContext(admitted, scope));
1629
+ return attachRecordedSubmissions(admitted, terminal, scope);
2481
1630
  }
2482
1631
 
2483
1632
  /**
2484
1633
  * One settlement for every registered seat. The registry `settlement` leaf
2485
- * picks sealed ledger, sealed-or-residual, or the accepted-tool scan.
1634
+ * picks the sealed ledger or the accepted-tool ledger.
2486
1635
  * Seat evidence stays on the artifact face.
2487
1636
  */
2488
1637
  async function settleSeat(
@@ -2497,59 +1646,9 @@ async function settleSeat(
2497
1646
  if (record.settlement === "accepted") {
2498
1647
  return trySettleAcceptedSeatTerminalResult(admitted, authority, scope);
2499
1648
  }
2500
- const publish = (
2501
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }>,
2502
- coordinates: DurablePrincipalCoordinates,
2503
- entries: readonly SessionEntry[],
2504
- ) => publishDeclaredSeatArtifacts(admitted, roleOutcome, coordinates, entries);
2505
- if (record.settlement === "residual") {
2506
- return settleSealedAcceptedOrToolResidual(
2507
- admitted,
2508
- authority,
2509
- scope,
2510
- admitted.role,
2511
- record.residualTool,
2512
- record.residualScan,
2513
- publish,
2514
- );
2515
- }
2516
- const accept = "sealedAcceptedOnly" in record && record.sealedAcceptedOnly === true
2517
- ? (
2518
- roleOutcome: Extract<TerminalRoleOutcome, { kind: "accepted" | "audit_escalation" }>,
2519
- ) => roleOutcome.role === admitted.role && roleOutcome.kind === "accepted"
2520
- : undefined;
2521
- return settleSealedLedgerTerminal(admitted, authority, scope, publish, accept);
2522
- }
2523
-
2524
- /**
2525
- * Try to settle any registered seat. Undefined only for genuine absence.
2526
- * Session malformation and publication exceptions keep their typed identity.
2527
- */
2528
- export async function trySettleSeatTerminalResult(
2529
- admitted: AdmittedRoleInvocation,
2530
- authority: DurablePrincipalAuthority,
2531
- scope?: SettlementCourtScope,
2532
- ): Promise<TerminalResult | undefined> {
2533
- return settleSeat(admitted, authority, scope);
2534
- }
2535
-
2536
- /**
2537
- * Settle any registered seat. Throws when no lawful outcome is present.
2538
- * Session-read and publication failures retain their typed identity.
2539
- */
2540
- export async function settleSeatTerminalResult(
2541
- admitted: AdmittedRoleInvocation,
2542
- authority: DurablePrincipalAuthority,
2543
- scope?: SettlementCourtScope,
2544
- ): Promise<TerminalResult> {
2545
- const settled = await settleSeat(admitted, authority, scope);
2546
- if (settled === undefined) {
2547
- const label = admitted.role.charAt(0).toUpperCase() + admitted.role.slice(1);
2548
- throw new Error(
2549
- `${label} Role run completed without a lawful typed terminal result`,
2550
- );
2551
- }
2552
- return settled;
1649
+ return settleSealedSeat(admitted, authority, scope, {
1650
+ acceptedOnly: "sealedAcceptedOnly" in record && record.sealedAcceptedOnly === true,
1651
+ });
2553
1652
  }
2554
1653
 
2555
1654
  /**
@@ -2631,12 +1730,13 @@ async function publishDeclaredSeatArtifacts(
2631
1730
  roleOutcome: TerminalRoleOutcome,
2632
1731
  coordinates: DurablePrincipalCoordinates,
2633
1732
  entries: readonly SessionEntry[],
1733
+ recordAttemptHistory: boolean,
2634
1734
  ): Promise<TerminalArtifactRef[]> {
2635
1735
  const face = seatArtifactFace(admitted.role);
2636
1736
  const phase = face.reportPhase === true
2637
1737
  ? { phase: readAdmittedPath(admitted, "phase") }
2638
1738
  : {};
2639
- return publishAcceptedTerminalArtifacts(admitted, roleOutcome, coordinates, {
1739
+ return publishAcceptedTerminalArtifacts(admitted, roleOutcome, coordinates, recordAttemptHistory, {
2640
1740
  report: {
2641
1741
  role: admitted.role,
2642
1742
  runId: admitted.runId,
@@ -2656,16 +1756,6 @@ async function publishDeclaredSeatArtifacts(
2656
1756
  });
2657
1757
  }
2658
1758
 
2659
- /**
2660
- * Shared accepted-settlement skeleton for seats that scan residual tool
2661
- * candidates then project sealed ledger outcome (#502 DRY).
2662
- * #757: sealed receipts pass through full decisiveFacts — one path, no per-seat projector.
2663
- */
2664
- type SeatAcceptedSettlementSpec = {
2665
- readonly role: TerminalRoleName;
2666
- readonly toolName: string;
2667
- };
2668
-
2669
1759
  /** Latest top-level user message index; 0 when the session has none (initial attempt). */
2670
1760
  function currentAttemptStartIndex(entries: readonly SessionEntry[]): number {
2671
1761
  for (let i = entries.length - 1; i >= 0; i -= 1) {
@@ -2677,93 +1767,8 @@ function currentAttemptStartIndex(entries: readonly SessionEntry[]): number {
2677
1767
  return 0;
2678
1768
  }
2679
1769
 
2680
- async function settleLawfulSeatAcceptedTerminalResult(
2681
- admitted: AdmittedRoleInvocation,
2682
- authority: DurablePrincipalAuthority,
2683
- spec: SeatAcceptedSettlementSpec,
2684
- scope?: SettlementCourtScope,
2685
- ): Promise<TerminalResult | undefined> {
2686
- const coordinates = coordinatesFromAdmitted(authority, admitted);
2687
- const { sessionDirectory, sessionFile } = coordinates;
2688
- const entries = await readLawfulSettlementEntries(sessionFile) ?? [];
2689
- const submissions = await recordedSubmissionPayloads(admitted, scope);
2690
- // #843: collector shape (ledger closed first) plus current user-turn freshness.
2691
- // Only the ledger-owned closure establishes this turn's success. A non-error
2692
- // toolResult may be a candidate whose nested gate continued the conversation.
2693
- // A current-attempt
2694
- // residual without that success is this turn's own failure and must not be
2695
- // masked by run-scoped stale acceptance (bare resume without courtAttemptId).
2696
- // Same-turn accept-then-bounce keeps the success marker: terminal stays
2697
- // accepted; rejection facts remain on payloads/gate (not latest-wins flip).
2698
- const scanStart = currentAttemptStartIndex(entries);
2699
- let thisAttemptHasSeatSuccess = false;
2700
- let residual: BoundErroredToolCandidate | undefined;
2701
- for (let index = entries.length - 1; index >= scanStart; index -= 1) {
2702
- const entry = entries[index];
2703
- if (entry?.type === "custom" && entry.customType === "ak-role-submission-closure"
2704
- && isRecord(entry.data) && entry.data.toolName === spec.toolName
2705
- && classifyPackagedRoleTerminalResult(entry.data).kind === "accepted") {
2706
- thisAttemptHasSeatSuccess = true;
2707
- }
2708
- const message = entries[index]?.message;
2709
- if (message?.role !== "toolResult") continue;
2710
- if (message.toolName !== spec.toolName) continue;
2711
- if (message.isError === false) continue;
2712
- if (residual === undefined) {
2713
- residual = boundErroredToolCandidate(
2714
- entries,
2715
- index,
2716
- message,
2717
- spec.toolName,
2718
- );
2719
- }
2720
- }
2721
- const roleOutcome = await sealedLedgerOutcome(admitted, spec.role as TerminalRoleName, scope);
2722
- if (roleOutcome !== undefined && (
2723
- thisAttemptHasSeatSuccess || residual === undefined || (scope?.courtAttemptId !== undefined && scope.courtAttemptId.length > 0)
2724
- )) {
2725
- const navigator = extractNavigatorFact(entries);
2726
- const artifacts = await publishDeclaredSeatArtifacts(
2727
- admitted,
2728
- roleOutcome,
2729
- coordinates,
2730
- entries,
2731
- );
2732
- return withSubmissions(
2733
- await withOptionalGateProjection(
2734
- {
2735
- roleOutcome,
2736
- navigator,
2737
- artifacts,
2738
- runId: admitted.runId,
2739
- },
2740
- sessionDirectory,
2741
- detourGateContext(admitted, scope),
2742
- ),
2743
- submissions,
2744
- );
2745
- }
2746
- if (residual !== undefined) {
2747
- const details = isRecord(residual.candidate)
2748
- ? residual.candidate
2749
- : { candidate: residual.candidate };
2750
- const failed = await settleFailureTerminalResult(
2751
- admitted,
2752
- {
2753
- cause: "output",
2754
- diagnostic: residual.diagnostic,
2755
- details,
2756
- },
2757
- authority,
2758
- scope ?? {},
2759
- );
2760
- return withSubmissions(failed, submissions);
2761
- }
2762
- return undefined;
2763
- }
2764
-
2765
- /** Accepted-tool seats: one scan, tool name from the composition-root record. */
2766
- export async function trySettleAcceptedSeatTerminalResult(
1770
+ /** Accepted-tool seats retain declaration checks and optional officer projection. */
1771
+ async function trySettleAcceptedSeatTerminalResult(
2767
1772
  admitted: AdmittedRoleInvocation,
2768
1773
  authority: DurablePrincipalAuthority,
2769
1774
  scope?: SettlementCourtScope,
@@ -2772,10 +1777,7 @@ export async function trySettleAcceptedSeatTerminalResult(
2772
1777
  if (toolName === undefined) {
2773
1778
  throw new Error(`accepted-seat settlement is not declared for ${admitted.role}`);
2774
1779
  }
2775
- const settled = await settleLawfulSeatAcceptedTerminalResult(admitted, authority, {
2776
- role: admitted.role,
2777
- toolName,
2778
- }, scope);
1780
+ const settled = await settleSealedSeat(admitted, authority, scope, { acceptedOnly: false });
2779
1781
  const record = packagedRoleMetadata(admitted.role);
2780
1782
  if (
2781
1783
  settled === undefined
@@ -2785,14 +1787,14 @@ export async function trySettleAcceptedSeatTerminalResult(
2785
1787
  ) {
2786
1788
  return settled;
2787
1789
  }
2788
- return applySecretariatCountersignTerminal(admitted, authority, settled);
1790
+ return applySecretariatCountersignTerminal(admitted, authority, settled, scope);
2789
1791
  }
2790
1792
 
2791
1793
  /** One settlement dispatch. The registry `settlement` leaf selects the path. */
2792
1794
  export async function trySettlePublicSeat(
2793
1795
  admitted: AdmittedRoleInvocation,
2794
1796
  authority: DurablePrincipalAuthority,
2795
- scope: { readonly courtAttemptId?: string } | undefined,
1797
+ scope: SettlementCourtScope | undefined,
2796
1798
  ): Promise<TerminalResult | undefined> {
2797
1799
  return settleSeat(admitted, authority, scope);
2798
1800
  }
@@ -2881,9 +1883,22 @@ async function applySecretariatCountersignTerminal(
2881
1883
  admitted: AdmittedRoleInvocation,
2882
1884
  authority: DurablePrincipalAuthority,
2883
1885
  settled: TerminalResult,
1886
+ scope?: SettlementCourtScope,
2884
1887
  ): Promise<TerminalResult> {
2885
1888
  const coordinates = coordinatesFromAdmitted(authority, admitted);
2886
- const entries = await readLawfulSettlementEntries(coordinates.sessionFile) ?? [];
1889
+ let entries: SessionEntry[] = [];
1890
+ try {
1891
+ entries = await readBoundSessionEntries(coordinates.sessionFile);
1892
+ } catch (error) {
1893
+ if (!isEnoent(error)) {
1894
+ await noteSettlementFault(
1895
+ admitted.runDirectory,
1896
+ scope,
1897
+ `secretariat officer read failed beside lawful terminal: ${describeErrorIdentity(error)}`,
1898
+ );
1899
+ }
1900
+ return settled;
1901
+ }
2887
1902
  const officer = countersignTerminalFromEntries(entries);
2888
1903
  if (officer === undefined) return settled;
2889
1904
 
@@ -2926,15 +1941,25 @@ export async function attachPostAuditProjection(
2926
1941
  admitted: AdmittedRoleInvocation,
2927
1942
  authority: DurablePrincipalAuthority,
2928
1943
  terminal: TerminalResult,
1944
+ scope?: SettlementCourtScope,
2929
1945
  ): Promise<TerminalResult> {
2930
1946
  const { sessionDirectory, sessionFile } = coordinatesFromAdmitted(authority, admitted);
2931
- const gate = await extractGateFactFromSessionDirectory(sessionDirectory, {
2932
- runDirectory: admitted.runDirectory,
2933
- parentSessionFile: sessionFile,
2934
- });
1947
+ let gate: TerminalGateFact | undefined;
1948
+ try {
1949
+ gate = await extractGateFactFromSessionDirectory(sessionDirectory, {
1950
+ runDirectory: admitted.runDirectory,
1951
+ parentSessionFile: sessionFile,
1952
+ });
1953
+ } catch (error) {
1954
+ await noteSettlementFault(
1955
+ admitted.runDirectory,
1956
+ scope,
1957
+ `gate read failed beside host terminal: ${describeErrorIdentity(error)}`,
1958
+ );
1959
+ }
2935
1960
  const withGate = gate === undefined ? terminal : { ...terminal, gate };
2936
1961
  if (admitted.role !== "secretariat") return withGate;
2937
- return applySecretariatCountersignTerminal(admitted, authority, withGate);
1962
+ return applySecretariatCountersignTerminal(admitted, authority, withGate, scope);
2938
1963
  }
2939
1964
 
2940
1965
  /** One failed attempt to place a durable failure artifact (path is private layout). */
@@ -2952,17 +1977,10 @@ function publicationAttemptFromError(
2952
1977
  error: unknown,
2953
1978
  ): PublicationAttempt {
2954
1979
  if (error instanceof Error) {
2955
- const identity: { name?: string; code?: string | number } = {
2956
- name: error.name,
2957
- };
2958
- const code = (error as { code?: unknown }).code;
2959
- if (typeof code === "string" || typeof code === "number") {
2960
- identity.code = code;
2961
- }
2962
1980
  return {
2963
1981
  path,
2964
1982
  diagnostic: error.message || error.name || "write failed",
2965
- identity,
1983
+ identity: thrownIdentity(error),
2966
1984
  };
2967
1985
  }
2968
1986
  return { path, diagnostic: String(error) };
@@ -3019,7 +2037,7 @@ async function writeFailureJsonRetainingCause(
3019
2037
  const candidates: string[] = [
3020
2038
  ...preferredCandidates,
3021
2039
  // One unique name per fallback dir — collisions on fixed names cannot exhaust this.
3022
- ...uniqueFallbackDirs.map((dir) => join(dir, `${stem}.${randomUUID()}.json`)),
2040
+ ...uniqueFallbackDirs.map((dir) => join(dir, stem === UNIQUE_ERROR_FALLBACK_STEM ? uniqueErrorFallbackName() : `${stem}.${randomUUID()}.json`)),
3023
2041
  ];
3024
2042
  for (let i = 0; i < candidates.length; i += 1) {
3025
2043
  const path = candidates[i]!;
@@ -3060,6 +2078,7 @@ export async function publishFailureArtifacts(
3060
2078
  failure: ControlledFailure,
3061
2079
  authority: DurablePrincipalAuthority,
3062
2080
  onErrorPublished?: (path: string) => void,
2081
+ recordAttemptHistory = false,
3063
2082
  ): Promise<TerminalArtifactRef[]> {
3064
2083
  const { sessionDirectory, sessionFile } = coordinatesFromAdmitted(authority, admitted);
3065
2084
  const { baseDir, attempt: baseAttempt } = await resolveFailureArtifactsBase(
@@ -3067,33 +2086,26 @@ export async function publishFailureArtifacts(
3067
2086
  );
3068
2087
  const priorIssues: PublicationAttempt[] =
3069
2088
  baseAttempt === undefined ? [] : [baseAttempt];
3070
- // #953: failure face must not leave a prior success report as the durable view.
3071
- // Clear failure must not strand the original controlled failure outside
3072
- // settlement — same as history failure below, it rides publicationIssues
3073
- // and unique fallback still places the durable error (locked artifacts/ +
3074
- // prior face must not abort publishFailureArtifacts).
3075
- try {
3076
- await clearOppositeTerminalArtifactFace(admitted.runDirectory);
3077
- } catch (error) {
3078
- priorIssues.push(
3079
- publicationAttemptFromError(
3080
- roleRunArtifactsDirectory(admitted.runDirectory),
3081
- error,
3082
- ),
3083
- );
2089
+ // #419: a dispatched failure joins history before fixed-name views change.
2090
+ // Re-projecting an existing failure does not append. History write failure
2091
+ // rides publicationIssues rather than stranding the controlled failure.
2092
+ if (recordAttemptHistory) {
2093
+ try {
2094
+ await appendRunAttemptHistory({ role: admitted.role, runId: admitted.runId, sessionFile }, {
2095
+ kind: "failure", role: admitted.role, ...failure,
2096
+ });
2097
+ } catch (error) {
2098
+ priorIssues.push(publicationAttemptFromError(sessionFile, error));
2099
+ }
3084
2100
  }
3085
- // #419: each attempt's complete failure result joins the appended history
3086
- // before any fixed-name artifact view is rewritten. History failure must not
3087
- // strand the original controlled failure outside settlement — it rides
3088
- // publicationIssues instead of aborting durability.
2101
+ // #953: clear the prior success face, but a failure here must not strand
2102
+ // the original controlled cause; preserve the issue beside the fallback.
3089
2103
  try {
3090
- await appendRunAttemptHistory({ role: admitted.role, runId: admitted.runId, sessionFile }, {
3091
- kind: "failure",
3092
- role: admitted.role,
3093
- ...failure,
3094
- });
2104
+ await clearOppositeTerminalArtifactFace(admitted.runDirectory);
3095
2105
  } catch (error) {
3096
- priorIssues.push(publicationAttemptFromError(sessionFile, error));
2106
+ priorIssues.push(publicationAttemptFromError(
2107
+ roleRunArtifactsDirectory(admitted.runDirectory), error,
2108
+ ));
3097
2109
  }
3098
2110
 
3099
2111
  // Prefer conventional names; unique fallback dirs keep colliding fixed paths
@@ -3106,23 +2118,23 @@ export async function publishFailureArtifacts(
3106
2118
  );
3107
2119
  const errorCandidates = underArtifacts
3108
2120
  ? [
3109
- join(baseDir, "error.json"),
3110
- join(baseDir, "error.settlement.json"),
3111
- join(admitted.runDirectory, "error.settlement.json"),
2121
+ join(baseDir, RUN_TERMINAL_ERROR_FILE),
2122
+ join(baseDir, RUN_TERMINAL_ERROR_SETTLEMENT_FILE),
2123
+ join(admitted.runDirectory, RUN_TERMINAL_ERROR_SETTLEMENT_FILE),
3112
2124
  ]
3113
2125
  : [
3114
- join(baseDir, "error.settlement.json"),
3115
- join(baseDir, "error.json"),
2126
+ join(baseDir, RUN_TERMINAL_ERROR_SETTLEMENT_FILE),
2127
+ join(baseDir, RUN_TERMINAL_ERROR_FILE),
3116
2128
  ];
3117
2129
  const evidenceCandidates = underArtifacts
3118
2130
  ? [
3119
- join(baseDir, "evidence.json"),
2131
+ join(baseDir, RUN_TERMINAL_EVIDENCE_FILE),
3120
2132
  join(baseDir, "evidence.settlement.json"),
3121
2133
  join(admitted.runDirectory, "evidence.settlement.json"),
3122
2134
  ]
3123
2135
  : [
3124
2136
  join(baseDir, "evidence.settlement.json"),
3125
- join(baseDir, "evidence.json"),
2137
+ join(baseDir, RUN_TERMINAL_EVIDENCE_FILE),
3126
2138
  ];
3127
2139
 
3128
2140
  const errorPayloadBase: Record<string, unknown> = {
@@ -3133,12 +2145,15 @@ export async function publishFailureArtifacts(
3133
2145
  diagnostic: failure.diagnostic,
3134
2146
  ...(failure.identity === undefined ? {} : { identity: failure.identity }),
3135
2147
  ...(failure.details === undefined ? {} : { details: failure.details }),
2148
+ // This package's own facts, kept beside the host's report.
2149
+ ...(failure.packageFact === undefined ? {} : { packageFact: failure.packageFact }),
2150
+ ...(failure.stderr === undefined ? {} : { stderr: failure.stderr }),
3136
2151
  };
3137
2152
 
3138
2153
  const errorWrite = await writeFailureJsonRetainingCause(
3139
2154
  errorCandidates,
3140
2155
  uniqueFallbackDirs,
3141
- "error",
2156
+ UNIQUE_ERROR_FALLBACK_STEM,
3142
2157
  errorPayloadBase,
3143
2158
  priorIssues,
3144
2159
  );
@@ -3149,12 +2164,7 @@ export async function publishFailureArtifacts(
3149
2164
  sessionDirectory: sessionDirectory,
3150
2165
  sessionFile: sessionFile,
3151
2166
  admittedRequestPath: admitted.admittedRequestPath,
3152
- attachments: admitted.attachments.map((a) => ({
3153
- provenancePath: a.provenancePath,
3154
- frozenPath: a.frozenPath,
3155
- sha256: a.sha256,
3156
- byteLength: a.byteLength,
3157
- })),
2167
+ attachments: acceptedArtifactAttachmentRefs(admitted.attachments),
3158
2168
  ...(failure.cause === undefined ? {} : { failureCause: failure.cause }),
3159
2169
  };
3160
2170
  const evidenceWrite = await writeFailureJsonRetainingCause(
@@ -3181,68 +2191,35 @@ export async function settleFailureTerminalResult(
3181
2191
  failure: ControlledFailure,
3182
2192
  authority: DurablePrincipalAuthority,
3183
2193
  options: SettlementCourtScope & {
3184
- readonly resume?: TerminalResume;
3185
2194
  readonly onErrorPublished?: (path: string) => void;
3186
2195
  } = {},
3187
2196
  ): Promise<TerminalResult> {
3188
2197
  const coordinates = coordinatesFromAdmitted(authority, admitted);
3189
2198
  const { sessionDirectory, sessionFile } = coordinates;
3190
- // #288 is lawful only when the lifecycle owner persisted an exhausted,
3191
- // current-attempt fact. Transcript reconstruction must not turn arbitrary output
3192
- // failures (or bytes retained from a prior resume attempt) into exit zero.
3193
- if (failure.cause === "output") {
3194
- const entries = await readBoundSessionEntries(sessionFile).catch(() => undefined);
3195
- if (entries !== undefined) {
3196
- let attemptStart = 0;
3197
- for (let index = entries.length - 1; index >= 0; index -= 1) {
3198
- if (entries[index]?.type === "message" && entries[index]?.message?.role === "user") { attemptStart = index; break; }
3199
- }
3200
- const lifecycleEntry = entries.slice(attemptStart).reverse().find((entry: SessionEntry) =>
3201
- entry.customType === NO_RECEIPT_LIFECYCLE_ENTRY_TYPE || entry.message?.customType === NO_RECEIPT_LIFECYCLE_ENTRY_TYPE);
3202
- const raw = lifecycleEntry?.data ?? lifecycleEntry?.message?.details;
3203
- if (raw !== undefined) {
3204
- // Catch only covers lifecycle-byte parse. Clear / navigator / gate I/O
3205
- // after a valid parse must keep their real failure identity (#953).
3206
- let facts: NoReceiptLifecycleFacts | undefined;
3207
- try {
3208
- facts = parseNoReceiptLifecycleFacts(raw);
3209
- } catch {
3210
- /* malformed lifecycle bytes remain the existing nonzero output failure */
3211
- }
3212
- if (
3213
- facts !== undefined &&
3214
- facts.runPointer === admitted.runDirectory &&
3215
- facts.attemptPointer === `current:${admitted.runDirectory}`
3216
- ) {
3217
- let decisiveFacts: NoReceiptLifecycleFacts & Record<string, unknown> = facts;
3218
- // #478: no_receipt is still a public Terminal — project accepted gate facts.
3219
- // #953: empty public terminal face — clear every reader-adoptable prior face.
3220
- await clearOppositeTerminalArtifactFace(admitted.runDirectory);
3221
- return withOptionalGateProjection(
3222
- {
3223
- roleOutcome: {
3224
- kind: "no_receipt",
3225
- role: admitted.role,
3226
- status: "no-accepted-receipt",
3227
- ...facts,
3228
- decisiveFacts,
3229
- },
3230
- navigator: await extractNavigatorFactFromAdmittedSession(sessionFile),
3231
- artifacts: [],
3232
- runId: admitted.runId,
3233
- },
3234
- sessionDirectory,
3235
- detourGateContext(admitted, options),
3236
- );
3237
- }
3238
- }
2199
+ // Exact-session attendance only — never infer no-advice from caller omission.
2200
+ const navigator = await extractNavigatorFactFromAdmittedSession(
2201
+ sessionFile,
2202
+ admitted.runDirectory,
2203
+ options,
2204
+ );
2205
+ let artifacts: TerminalArtifactRef[] = [];
2206
+ if (options.previewOnly !== true) {
2207
+ try {
2208
+ artifacts = await publishFailureArtifacts(
2209
+ admitted,
2210
+ failure,
2211
+ authority,
2212
+ options.onErrorPublished,
2213
+ options.recordAttemptHistory === true,
2214
+ );
2215
+ } catch (error) {
2216
+ await noteSettlementFault(
2217
+ admitted.runDirectory,
2218
+ options,
2219
+ `failure artifact publication failed beside host terminal: ${describeErrorIdentity(error)}`,
2220
+ );
3239
2221
  }
3240
2222
  }
3241
- // Exact-session attendance only — never infer no-advice from caller omission.
3242
- const navigator = await extractNavigatorFactFromAdmittedSession(sessionFile);
3243
- // Private durable artifacts retain the original diagnostic identity (including run ID).
3244
- const artifacts = await publishFailureArtifacts(admitted, failure, authority, options.onErrorPublished);
3245
- const errorPath = artifacts.find((artifact) => artifact.kind === "error")?.path;
3246
2223
  const decisiveFacts: Record<string, unknown> = {
3247
2224
  ...(failure.cause === undefined ? {} : { cause: failure.cause }),
3248
2225
  diagnostic: failure.diagnostic,
@@ -3256,30 +2233,11 @@ export async function settleFailureTerminalResult(
3256
2233
  if (failure.details !== undefined) {
3257
2234
  decisiveFacts.secondaryEvidence = failure.details;
3258
2235
  }
3259
- // Resumable failures: durable artifacts still land under the run directory, but
3260
- // the public Terminal must not re-disclose the run ID via top-level runId,
3261
- // path components, or untrusted free text — only resume.command may carry it
3262
- // (AC2 / #108).
3263
- if (options.resume !== undefined) {
3264
- const roleOutcome: TerminalRoleOutcome = {
3265
- kind: "failure",
3266
- role: admitted.role,
3267
- ...(failure.cause === undefined ? {} : { cause: failure.cause }),
3268
- diagnostic: failure.diagnostic,
3269
- decisiveFacts,
3270
- };
3271
- const terminal = await withOptionalGateProjection(
3272
- {
3273
- roleOutcome,
3274
- navigator,
3275
- artifacts: [],
3276
- resume: options.resume,
3277
- },
3278
- sessionDirectory,
3279
- detourGateContext(admitted, options),
3280
- );
3281
- if (errorPath !== undefined) privateFailureErrorPaths.set(terminal, errorPath);
3282
- return terminal;
2236
+ if (failure.packageFact !== undefined) {
2237
+ decisiveFacts.packageFact = failure.packageFact;
2238
+ }
2239
+ if (failure.stderr !== undefined) {
2240
+ decisiveFacts.stderr = failure.stderr;
3283
2241
  }
3284
2242
  const roleOutcome: TerminalRoleOutcome = {
3285
2243
  kind: "failure",
@@ -3288,31 +2246,13 @@ export async function settleFailureTerminalResult(
3288
2246
  diagnostic: failure.diagnostic,
3289
2247
  decisiveFacts,
3290
2248
  };
3291
- // #478: ordinary controlled failure still surfaces accepted gate facts.
3292
2249
  return withOptionalGateProjection(
3293
- {
3294
- roleOutcome,
3295
- navigator,
3296
- artifacts,
3297
- runId: admitted.runId,
3298
- },
2250
+ { roleOutcome, navigator, artifacts, runId: admitted.runId },
3299
2251
  sessionDirectory,
3300
2252
  detourGateContext(admitted, options),
3301
2253
  );
3302
2254
  }
3303
2255
 
3304
- /** Judge-named alias retained for #107 call sites. */
3305
- export async function settleJudgeFailureTerminalResult(
3306
- admitted: AdmittedJudgeInvocation,
3307
- failure: ControlledFailure,
3308
- authority: DurablePrincipalAuthority,
3309
- options: SettlementCourtScope & {
3310
- readonly resume?: TerminalResume;
3311
- } = {},
3312
- ): Promise<TerminalResult> {
3313
- return settleFailureTerminalResult(admitted, failure, authority, options);
3314
- }
3315
-
3316
2256
  /**
3317
2257
  * Emit one complete failure Terminal on stdout and one concise stderr diagnostic.
3318
2258
  * Artifacts are already durable on the TerminalResult.
@@ -3327,9 +2267,11 @@ export function presentFailureTerminal(
3327
2267
  io.stdout(formatTerminalResult(terminal));
3328
2268
  if (terminal.roleOutcome.kind === "failure") {
3329
2269
  if (io.omitFailureStderrDiagnostic) return;
2270
+ const hostStderr = terminal.roleOutcome.decisiveFacts.stderr;
3330
2271
  io.stderr(formatFailureStderrDiagnostic({
3331
2272
  ...(terminal.roleOutcome.cause === undefined ? {} : { cause: terminal.roleOutcome.cause }),
3332
2273
  diagnostic: terminal.roleOutcome.diagnostic,
2274
+ ...(typeof hostStderr === "string" && hostStderr.length > 0 ? { stderr: hostStderr } : {}),
3333
2275
  }));
3334
2276
  return;
3335
2277
  }