@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
@@ -8,6 +8,7 @@
8
8
  import type { NoReceiptLifecycleFacts } from "../receipt-delivery-policy.ts";
9
9
  import type { ControlledFailureCause } from "../host-contracts.ts";
10
10
  import type { PackagedRole } from "../packaged-role-registry.ts";
11
+ import { serializeThrownValue } from "../serialize-thrown-value.ts";
11
12
 
12
13
  export type { ControlledFailureCause } from "../host-contracts.ts";
13
14
 
@@ -25,11 +26,11 @@ export type TerminalArtifactRef = {
25
26
  /** Public callable roles that currently produce Terminal outcomes. */
26
27
  export type TerminalRoleName = PackagedRole;
27
28
 
28
- export type NoReceiptTerminalOutcome = NoReceiptLifecycleFacts & {
29
+ export type NoReceiptTerminalOutcome = Partial<NoReceiptLifecycleFacts> & {
29
30
  kind: "no_receipt";
30
31
  role: TerminalRoleName;
31
32
  status: "no-accepted-receipt";
32
- decisiveFacts: NoReceiptLifecycleFacts & Readonly<Record<string, unknown>>;
33
+ decisiveFacts: Partial<NoReceiptLifecycleFacts> & Readonly<Record<string, unknown>>;
33
34
  };
34
35
 
35
36
  export type TerminalRoleOutcome =
@@ -56,7 +57,7 @@ export type TerminalRoleOutcome =
56
57
  role: TerminalRoleName;
57
58
  /**
58
59
  * Typed cause class when a typed fact confirms it.
59
- * Omitted when unknown — original diagnostic + error artifact carry the fact (#881).
60
+ * Omitted when unknown — the original diagnostic retains the fact (#881).
60
61
  * Never a fabricated "unrecognized" label.
61
62
  */
62
63
  cause?: ControlledFailureCause;
@@ -102,12 +103,6 @@ export type TerminalNavigatorFact =
102
103
  advisoryDiagnostic?: string;
103
104
  };
104
105
 
105
- /** Present only when a controlled failure is v1-resumable (typed HTTP 429). */
106
- export type TerminalResume = {
107
- /** Complete public command; run ID is revealed only here. */
108
- readonly command: string;
109
- };
110
-
111
106
  /** Current English gate seat faces projected on Terminal (#478). */
112
107
  export type TerminalGateSeat = "gatekeeper" | "inspector" | "notary";
113
108
 
@@ -147,38 +142,21 @@ export type TerminalGateFact = {
147
142
  };
148
143
 
149
144
  /**
150
- * One admitted Role run's typed Terminal aggregate.
151
- * Resumable failures carry `resume` and must not re-disclose the run ID via
152
- * top-level `runId` or public artifact path components — only `resume.command`.
153
- * #416: autoResumeCount (0..2) is a call-local observation of how many in-place
154
- * auto-resumes occurred during this single LLM call; it is not persisted to
155
- * run-state.json and does not participate in limit decisions.
156
- */
157
- /**
158
- * Current-result payloads on a terminal — accepted/audit role result, or a
159
- * rare intentional failure face. Run history is TerminalResult.submissions
160
- * (#836 / #953), not this helper.
145
+ * Parent-facing current reply: a court's own accepted/audit payloads only.
146
+ * Run-scoped history stays on TerminalResult.submissions (#879 / #836), and a
147
+ * failure face never answers as this court's reply (#953).
161
148
  */
162
- export function roleResultPayloads(outcome: TerminalRoleOutcome): readonly unknown[] {
163
- if (outcome.kind === "accepted" || outcome.kind === "audit_escalation") return outcome.payloads ?? [];
164
- if (outcome.kind === "failure") return outcome.payloads ?? [];
165
- return [];
149
+ export function currentReplyRows(terminal: TerminalResult | undefined): readonly unknown[] {
150
+ const outcome = terminal?.roleOutcome;
151
+ return outcome?.kind === "accepted" || outcome?.kind === "audit_escalation"
152
+ ? outcome.payloads ?? []
153
+ : [];
166
154
  }
167
155
 
168
156
  /**
169
- * Prefer non-empty current-result payloads; an empty array must not shadow
170
- * top-level submissions (#953 / #836). Callers that need failure history must
171
- * read TerminalResult.submissions directly — do not coalesce into failure.payloads.
157
+ * One admitted Role run's typed Terminal aggregate.
158
+ * autoResumeCount is call-local (#416).
172
159
  */
173
- export function coalesceSubmissionRows(
174
- payloads: readonly unknown[] | undefined,
175
- submissions: readonly unknown[] | undefined,
176
- ): readonly unknown[] {
177
- if (payloads !== undefined && payloads.length > 0) return payloads;
178
- if (submissions !== undefined && submissions.length > 0) return submissions;
179
- return payloads ?? submissions ?? [];
180
- }
181
-
182
160
  export type TerminalResult = {
183
161
  roleOutcome: TerminalRoleOutcome;
184
162
  /** Default Reviewer parent: original child Terminals, keyed by frozen axis. */
@@ -207,22 +185,14 @@ export type TerminalResult = {
207
185
  /** Call-local auto-resume observation (0..2) for this single LLM call; read-only, not persisted. */
208
186
  autoResumeCount?: number;
209
187
  } & (
210
- | {
211
- /** Resumable failure: run ID appears only inside resume.command. */
212
- resume: TerminalResume;
213
- runId?: undefined;
214
- batch?: undefined;
215
- }
216
188
  | {
217
189
  runId: string;
218
- resume?: undefined;
219
190
  batch?: undefined;
220
191
  }
221
192
  | {
222
193
  /** Deterministic public batch projection; no parent Role run exists. */
223
194
  batch: "reviewer";
224
195
  runId?: undefined;
225
- resume?: undefined;
226
196
  }
227
197
  );
228
198
 
@@ -263,6 +233,19 @@ export function formatTerminalResult(result: TerminalResult): string {
263
233
  `diagnostic\t${encodeTerminalField(result.roleOutcome.diagnostic)}`,
264
234
  );
265
235
  }
236
+ // Accepted volumes do not dump decisiveFacts unless a failed attempt is
237
+ // recorded. The unsettled-direction fact still has to be visible on its own.
238
+ // audit_escalation already dumps every decisiveFact below.
239
+ if (
240
+ result.roleOutcome.kind === "accepted"
241
+ && result.roleOutcome.decisiveFacts?.directionUnsettled === true
242
+ ) {
243
+ lines.push("fact\tdirectionUnsettled\ttrue");
244
+ const subsequent = result.roleOutcome.decisiveFacts.subsequentAudit;
245
+ if (typeof subsequent === "string") {
246
+ lines.push(`fact\tsubsequentAudit\t${encodeTerminalField(subsequent)}`);
247
+ }
248
+ }
266
249
  if (result.roleOutcome.kind === "failure" || result.roleOutcome.kind === "no_receipt" || result.roleOutcome.kind === "audit_escalation" || result.roleOutcome.decisiveFacts?.failedAttempts !== undefined) {
267
250
  const facts = result.roleOutcome.kind === "accepted"
268
251
  ? { failedAttempts: result.roleOutcome.decisiveFacts?.failedAttempts }
@@ -270,7 +253,7 @@ export function formatTerminalResult(result: TerminalResult): string {
270
253
  for (const [key, value] of Object.entries(facts)) {
271
254
  if (value === undefined) continue;
272
255
  const rendered =
273
- typeof value === "string" ? value : JSON.stringify(value);
256
+ typeof value === "string" ? value : serializeThrownValue(value);
274
257
  lines.push(`fact\t${encodeTerminalField(key)}\t${encodeTerminalField(rendered)}`);
275
258
  }
276
259
  }
@@ -315,10 +298,7 @@ export function formatTerminalResult(result: TerminalResult): string {
315
298
  }
316
299
  }
317
300
  }
318
- if (result.resume !== undefined) {
319
- // Resumable failure: run ID is revealed only inside the complete resume command.
320
- lines.push(`resume\t${encodeTerminalField(result.resume.command)}`);
321
- } else if (result.runId !== undefined) {
301
+ if (result.runId !== undefined) {
322
302
  lines.push(`run\t${encodeTerminalField(result.runId)}`);
323
303
  }
324
304
  if (result.autoResumeCount !== undefined) {
@@ -326,27 +306,19 @@ export function formatTerminalResult(result: TerminalResult): string {
326
306
  }
327
307
  // Role-result block: original payloads, newest first for humans (#961).
328
308
  // Typed payloads/submissions stay ledger order; only this presentation reverses.
329
- // #953: failure history is the top-level submissions carrier (#836) — present
330
- // as recorded-submission, never as this-turn submission/receipt.
331
- if (result.roleOutcome.kind === "failure") {
332
- const recorded = result.submissions ?? [];
333
- for (let i = recorded.length - 1; i >= 0; i -= 1) {
334
- const payload = recorded[i]!;
335
- const rendered =
336
- typeof payload === "string" ? payload : JSON.stringify(payload);
337
- lines.push(`recorded-submission\t${encodeTerminalField(rendered)}`);
338
- }
339
- } else {
340
- const payloads =
341
- result.roleOutcome.kind === "accepted" ||
342
- result.roleOutcome.kind === "audit_escalation"
343
- ? coalesceSubmissionRows(result.roleOutcome.payloads, result.submissions)
344
- : result.submissions ?? [];
345
- for (let i = payloads.length - 1; i >= 0; i -= 1) {
346
- const payload = payloads[i]!;
347
- const rendered =
348
- typeof payload === "string" ? payload : JSON.stringify(payload);
349
- lines.push(`submission\t${encodeTerminalField(rendered)}`);
309
+ // Present the current result and the run history on distinct faces (#879 / #836).
310
+ // Neither a prior court's submission nor a failed turn's history is this reply.
311
+ const current = result.roleOutcome.kind === "failure"
312
+ ? result.roleOutcome.payloads ?? []
313
+ : currentReplyRows(result);
314
+ for (const [label, rows] of [
315
+ ["submission", current],
316
+ ["recorded-submission", result.submissions ?? []],
317
+ ] as const) {
318
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
319
+ const payload = rows[i]!;
320
+ const rendered = typeof payload === "string" ? payload : JSON.stringify(payload);
321
+ lines.push(`${label}\t${encodeTerminalField(rendered)}`);
350
322
  }
351
323
  }
352
324
  return `${lines.join("\n")}\n`;
@@ -70,7 +70,13 @@ export type RoleTurnRequestProjectionOptions = {
70
70
  /** #537 public-invocation scope (one ak-role call). */
71
71
  invocationScopeId?: string;
72
72
  /** Station child role run (#840): omit automatic navigator attendance. */
73
- stationChild?: boolean;
73
+ readonly stationChild?: boolean;
74
+ /**
75
+ * #1132: the one effective delivery-request ceiling (#422 single resolve by
76
+ * the caller). Projected so the host adapter's own re-ask loop and the AK
77
+ * execution seam count with the same configured number.
78
+ */
79
+ readonly deliveryRequestLimit?: number;
74
80
  };
75
81
 
76
82
  export type AdmittedTurnInvocation = {
@@ -118,6 +124,9 @@ export function projectRoleTurnRequest(
118
124
  ? {}
119
125
  : { invocationScopeId: options.invocationScopeId }),
120
126
  ...(options.stationChild === undefined ? {} : { stationChild: options.stationChild }),
127
+ ...(options.deliveryRequestLimit === undefined
128
+ ? {}
129
+ : { deliveryRequestLimit: options.deliveryRequestLimit }),
121
130
  };
122
131
  }
123
132
 
@@ -27,6 +27,7 @@ import type { TerminalResult } from "./public-cli/terminal.ts";
27
27
  import type { HostSelectionFailure, NamedRoleTurnHostAdapter } from "./public-cli/role-turn-host-resolution.ts";
28
28
 
29
29
  import { isRecord, errorText } from "./unknown-value.ts";
30
+ import { serializeThrownValue } from "./serialize-thrown-value.ts";
30
31
 
31
32
  /** Env published by the parent activation so nested summons never re-derive root. */
32
33
  export const AK_ROLE_PACKAGE_ROOT_ENV = "AK_ROLE_PACKAGE_ROOT" as const;
@@ -588,15 +589,9 @@ export async function summonParallelReviewerLenses(options: {
588
589
  readonly completeness: PublicSummonResult;
589
590
  readonly correctness: PublicSummonResult;
590
591
  }> {
591
- const describeFailure = (error: unknown): string => {
592
- if (error instanceof AggregateError) {
593
- return [error.message, ...error.errors.map(describeFailure)].join("\n");
594
- }
595
- return errorText(error);
596
- };
597
592
  const failedResult = (error: unknown): PublicSummonResult => ({
598
593
  exitCode: 1,
599
- stderr: describeFailure(error),
594
+ stderr: serializeThrownValue(error),
600
595
  });
601
596
  const dualFailure = (error: unknown) => {
602
597
  const failure = failedResult(error);
@@ -720,7 +715,7 @@ export async function summonParallelReviewerLenses(options: {
720
715
  ].join("\n");
721
716
  }
722
717
  } catch (error) {
723
- sealDiagnostic = `Reviewer target final seal failed:\n${describeFailure(error)}`;
718
+ sealDiagnostic = `Reviewer target final seal failed:\n${serializeThrownValue(error)}`;
724
719
  }
725
720
  if (sealDiagnostic !== undefined) {
726
721
  const withSealFailure = (result: PublicSummonResult): PublicSummonResult => ({
@@ -1,14 +1,69 @@
1
+ /**
2
+ * Shared accepted-receipt delivery budget for role, auditor, and Navigator sessions (#288).
3
+ *
4
+ * #1132: the ceiling is the one configured auto-resume value (public-cli.json
5
+ * top-level `autoResumeLimit`). This module owns no number of its own — the one
6
+ * package default stays with AUTO_RESUME_LIMIT (#422), and `deliveryTurns`
7
+ * records the delivery requests actually issued, never the budget
8
+ * (零次即记零,不把额度或其他续跑次数冒充催交次数).
9
+ */
10
+ import { AUTO_RESUME_LIMIT } from "./public-cli/run-lifecycle.ts";
11
+ import { parseAutoResumeLimit } from "./public-cli/config.ts";
1
12
  import { isRecord } from "./unknown-value.ts";
2
- /** Shared accepted-receipt delivery budget for role, auditor, and Navigator sessions (#288). */
3
- export const RECEIPT_DELIVERY_TURN_LIMIT = 2 as const;
4
13
 
5
14
  export const NO_RECEIPT_LIFECYCLE_ENTRY_TYPE = "ak-no-receipt-lifecycle" as const;
6
15
 
16
+ /** One in-process催交 send, tagged with the public call that issued it. */
17
+ export const RECEIPT_DELIVERY_REQUEST_ENTRY = "ak-receipt-delivery-request" as const;
18
+
19
+ /** A rejection observed by the output-tool hook, bound before any delivery turn. */
20
+ export const RECEIPT_REJECTION_ENTRY = "ak-receipt-rejection" as const;
21
+
22
+ /**
23
+ * Facts of one public call. A call with an invocation scope does not share
24
+ * `current:<runDirectory>` with the previous call on the same run.
25
+ */
26
+ export function receiptAttemptPointer(runPointer: string, invocationScopeId?: string): string {
27
+ const scope = invocationScopeId?.trim() ?? "";
28
+ if (scope.length > 0) return `invocation:${scope}`;
29
+ return `current:${runPointer}`;
30
+ }
31
+
32
+ /** Child-process transport for the effective ceiling (#1132). */
33
+ export const RECEIPT_DELIVERY_LIMIT_ENV = "AK_ROLE_RECEIPT_DELIVERY_LIMIT" as const;
34
+
35
+ /**
36
+ * The effective receipt-delivery ceiling: the caller's already-resolved
37
+ * configured value, else the single package default. Domain-validated at this
38
+ * one seam (#422) so NaN/negative/fractional/Infinity never silently disable
39
+ * or bypass the comparison downstream.
40
+ */
41
+ export function deliveryLimitFromConfig(value: number | undefined): number {
42
+ return parseAutoResumeLimit(value ?? AUTO_RESUME_LIMIT);
43
+ }
44
+
45
+ /** Effective ceiling for an in-child role runtime reading its own env (#1132). */
46
+ export function deliveryLimitFromEnv(env: NodeJS.ProcessEnv): number {
47
+ const raw = env[RECEIPT_DELIVERY_LIMIT_ENV];
48
+ if (raw === undefined || raw.trim() === "") return deliveryLimitFromConfig(undefined);
49
+ const parsed = Number(raw);
50
+ if (!Number.isInteger(parsed)) return deliveryLimitFromConfig(undefined);
51
+ return deliveryLimitFromConfig(parsed);
52
+ }
53
+
54
+ function deliveryTurnsFact(value: unknown): number {
55
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
56
+ throw new TypeError("deliveryTurns must be a non-negative integer");
57
+ }
58
+ return value;
59
+ }
60
+
7
61
  /** The sole schema shared by lifecycle owners and Terminal projections. */
8
62
  export type NoReceiptLifecycleFacts = {
9
63
  terminalToolCalled: boolean;
10
64
  rejectedReceipts: readonly { reason: string; diagnosticAvailable: boolean }[];
11
- deliveryTurns: typeof RECEIPT_DELIVERY_TURN_LIMIT;
65
+ /** Delivery requests actually issued on this run (#1132 — not a budget). */
66
+ deliveryTurns: number;
12
67
  sessionCompletion: "settled-without-accepted-receipt";
13
68
  runPointer: string;
14
69
  attemptPointer: string;
@@ -19,7 +74,6 @@ export type NoReceiptLifecycleFacts = {
19
74
  export function parseNoReceiptLifecycleFacts(input: unknown): NoReceiptLifecycleFacts {
20
75
  if (!isRecord(input)
21
76
  || typeof input.terminalToolCalled !== "boolean"
22
- || input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT
23
77
  || input.sessionCompletion !== "settled-without-accepted-receipt"
24
78
  || input.acceptedReceipt !== false
25
79
  || typeof input.runPointer !== "string" || input.runPointer.trim() === ""
@@ -29,36 +83,33 @@ export function parseNoReceiptLifecycleFacts(input: unknown): NoReceiptLifecycle
29
83
  && typeof item.reason === "string")) {
30
84
  throw new TypeError("malformed no-receipt lifecycle facts");
31
85
  }
32
- return {
86
+ return noReceiptLifecycleFacts({
33
87
  terminalToolCalled: input.terminalToolCalled,
34
- rejectedReceipts: input.rejectedReceipts.map((item) => ({
35
- reason: item.reason as string,
36
- diagnosticAvailable: (item.reason as string).trim() !== "",
37
- })),
38
- deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
39
- sessionCompletion: "settled-without-accepted-receipt",
88
+ rejectedReceipts: input.rejectedReceipts as { reason: string }[],
89
+ deliveryTurns: deliveryTurnsFact(input.deliveryTurns),
40
90
  runPointer: input.runPointer,
41
91
  attemptPointer: input.attemptPointer,
42
- acceptedReceipt: false,
43
- };
92
+ });
44
93
  }
45
94
 
95
+ /**
96
+ * #1132: exhaustion is the caller's decision — the receipt-delivery loop only
97
+ * settles after its budget is actually spent — so this records the issued
98
+ * count verbatim instead of demanding it equal a package constant.
99
+ */
46
100
  export function noReceiptLifecycleFacts(
47
101
  input: Omit<NoReceiptLifecycleFacts, "rejectedReceipts" | "deliveryTurns" | "sessionCompletion" | "acceptedReceipt"> & {
48
102
  rejectedReceipts: readonly { reason: string }[];
49
103
  deliveryTurns: number;
50
104
  },
51
105
  ): NoReceiptLifecycleFacts {
52
- if (input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT) {
53
- throw new TypeError("no-receipt lifecycle requires an exhausted delivery budget");
54
- }
55
106
  return {
56
107
  terminalToolCalled: input.terminalToolCalled,
57
108
  rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({
58
109
  reason,
59
110
  diagnosticAvailable: reason.trim() !== "",
60
111
  })),
61
- deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
112
+ deliveryTurns: deliveryTurnsFact(input.deliveryTurns),
62
113
  sessionCompletion: "settled-without-accepted-receipt",
63
114
  runPointer: input.runPointer,
64
115
  attemptPointer: input.attemptPointer,
@@ -66,39 +117,70 @@ export function noReceiptLifecycleFacts(
66
117
  };
67
118
  }
68
119
 
69
- export function createReceiptDeliveryPolicy() {
120
+ export function createReceiptDeliveryPolicy(limit?: number) {
121
+ const deliveryLimit = deliveryLimitFromConfig(limit);
70
122
  let accepted = false;
123
+ let closed = false;
71
124
  let terminalToolCalled = false;
125
+ /** Requests actually sent. Rejections and a nested close do not invent sends. */
72
126
  let deliveryTurns = 0;
127
+ /** Stop counter. Separate from the issued-count fact (#1132). */
128
+ let budgetSpent = 0;
73
129
  const rejectedReceipts: { reason: string; diagnosticAvailable: boolean }[] = [];
130
+ const spend = (): void => {
131
+ if (budgetSpent < deliveryLimit) budgetSpent += 1;
132
+ };
74
133
  return {
134
+ /** The effective ceiling this budget spends (#1132 — never re-read). */
135
+ limit: deliveryLimit,
75
136
  recordAccepted() {
76
137
  accepted = true;
77
138
  terminalToolCalled = true;
78
139
  },
79
140
  /** Infrastructure owns terminality and must never trigger receipt催交. */
80
141
  stopForInfrastructure() { accepted = true; },
142
+ /**
143
+ * Record the rejection this turn already produced.
144
+ * The fact does not spend a budget. The seam that sends the next prompt
145
+ * owns that count.
146
+ */
81
147
  recordRejected(reason: string) {
82
148
  terminalToolCalled = true;
83
149
  rejectedReceipts.push({ reason, diagnosticAvailable: reason.trim() !== "" });
84
- deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
85
150
  },
86
151
  recordDeliveryRequest() {
87
- deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
152
+ deliveryTurns += 1;
153
+ spend();
88
154
  },
89
155
  /**
90
- * A nested session settled without an accepted receipt after spending its own
91
- * delivery budget. Adopt its facts and close this budget: another prompt would
92
- * open an independent session, not a delivery request on the settled one.
156
+ * Stop asking. The issued count stays whatever was actually sent (#1132 —
157
+ * do not fill the remainder up to the ceiling).
158
+ */
159
+ closeBudget() { closed = true; },
160
+ /**
161
+ * A nested session settled without an accepted receipt. Close so this layer
162
+ * does not open another prompt on that session. The issued count stays the
163
+ * larger of the two seams: a nested zero must not wipe prompts this layer
164
+ * already sent, and adding the two counts would bill one send twice.
93
165
  */
94
- recordNestedNoReceipt(facts: NoReceiptLifecycleFacts) {
95
- terminalToolCalled = terminalToolCalled || facts.terminalToolCalled;
96
- rejectedReceipts.push(...facts.rejectedReceipts);
97
- deliveryTurns = RECEIPT_DELIVERY_TURN_LIMIT;
166
+ recordNestedNoReceipt(facts: Partial<NoReceiptLifecycleFacts>) {
167
+ if (typeof facts.terminalToolCalled === "boolean") {
168
+ terminalToolCalled = terminalToolCalled || facts.terminalToolCalled;
169
+ }
170
+ if (facts.rejectedReceipts !== undefined) {
171
+ rejectedReceipts.push(...facts.rejectedReceipts);
172
+ }
173
+ if (facts.deliveryTurns !== undefined) deliveryTurns = Math.max(deliveryTurns, facts.deliveryTurns);
174
+ closed = true;
98
175
  },
99
176
  nextAction(): "accepted" | "request-delivery" | "no-receipt" {
100
177
  if (accepted) return "accepted";
101
- return deliveryTurns < RECEIPT_DELIVERY_TURN_LIMIT ? "request-delivery" : "no-receipt";
178
+ if (closed) return "no-receipt";
179
+ return budgetSpent < deliveryLimit ? "request-delivery" : "no-receipt";
180
+ },
181
+ /** Delivery requests actually issued so far (#1132). */
182
+ issuedDeliveryRequests(): number {
183
+ return deliveryTurns;
102
184
  },
103
185
  /** Current typed delivery facts. No prose. */
104
186
  deliveryState() {
@@ -112,5 +194,114 @@ export function createReceiptDeliveryPolicy() {
112
194
  facts(binding: { runPointer: string; attemptPointer: string }): NoReceiptLifecycleFacts {
113
195
  return noReceiptLifecycleFacts({ terminalToolCalled, rejectedReceipts: [...rejectedReceipts], deliveryTurns, ...binding });
114
196
  },
197
+ /**
198
+ * Continue a public call after the in-memory policy was rebuilt.
199
+ * Sends already issued spend the same budget; copied rejections keep
200
+ * the fact and do not spend again.
201
+ */
202
+ continueIssued(prior: {
203
+ readonly deliveryTurns: number;
204
+ readonly rejectedReceipts: readonly { reason: string }[];
205
+ readonly terminalToolCalled?: boolean;
206
+ }) {
207
+ for (const receipt of prior.rejectedReceipts) {
208
+ this.recordRejected(receipt.reason);
209
+ }
210
+ if (prior.terminalToolCalled === true) terminalToolCalled = true;
211
+ for (let index = 0; index < prior.deliveryTurns; index += 1) {
212
+ this.recordDeliveryRequest();
213
+ }
214
+ },
215
+ };
216
+ }
217
+
218
+ function entryRecord(entry: unknown): Record<string, unknown> | undefined {
219
+ return isRecord(entry) ? entry : undefined;
220
+ }
221
+
222
+ /** Sends, observed rejections, and the latest lifecycle fact for this public call. */
223
+ export type ReceiptContinuation = {
224
+ readonly deliveryTurns: number;
225
+ readonly rejectedReceipts: readonly { reason: string }[];
226
+ readonly terminalToolCalled: boolean;
227
+ };
228
+
229
+ function customTypeOf(record: Record<string, unknown>, message: Record<string, unknown> | undefined): unknown {
230
+ return record.customType ?? message?.customType;
231
+ }
232
+
233
+ function dataOf(record: Record<string, unknown>, message: Record<string, unknown> | undefined): unknown {
234
+ return record.data ?? message?.details;
235
+ }
236
+
237
+ /** Scope id carried by a delivery request or an invocation-scoped lifecycle fact. */
238
+ function scopeTag(record: Record<string, unknown>): string | undefined {
239
+ const message = isRecord(record.message) ? record.message : undefined;
240
+ const customType = customTypeOf(record, message);
241
+ const data = dataOf(record, message);
242
+ if (
243
+ (customType === RECEIPT_DELIVERY_REQUEST_ENTRY || customType === RECEIPT_REJECTION_ENTRY)
244
+ && isRecord(data)
245
+ && typeof data.invocationScopeId === "string"
246
+ && data.invocationScopeId.trim() !== ""
247
+ ) {
248
+ return data.invocationScopeId;
249
+ }
250
+ if (customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE) return undefined;
251
+ try {
252
+ const facts = parseNoReceiptLifecycleFacts(data);
253
+ const prefix = "invocation:";
254
+ if (!facts.attemptPointer.startsWith(prefix)) return undefined;
255
+ const scope = facts.attemptPointer.slice(prefix.length);
256
+ return scope.trim() === "" ? undefined : scope;
257
+ } catch {
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ /** Restore only facts explicitly bound to this public call, never inferred from dialogue. */
263
+ export function priorReceiptContinuation(
264
+ entries: Iterable<unknown>,
265
+ invocationScopeId: string,
266
+ ): ReceiptContinuation {
267
+ const pointer = receiptAttemptPointer("", invocationScopeId);
268
+ let entryTurns = 0;
269
+ let factTurns = 0;
270
+ let rejectedReceipts: { reason: string }[] = [];
271
+ let snapshotTerminal = false;
272
+ for (const entry of entries) {
273
+ const record = entryRecord(entry);
274
+ if (record === undefined) continue;
275
+ const tagged = scopeTag(record);
276
+ if (tagged !== undefined) {
277
+ const message = isRecord(record.message) ? record.message : undefined;
278
+ const customType = customTypeOf(record, message);
279
+ if (tagged !== invocationScopeId) continue;
280
+ if (customType === RECEIPT_DELIVERY_REQUEST_ENTRY) entryTurns += 1;
281
+ if (customType === RECEIPT_REJECTION_ENTRY) {
282
+ const data = dataOf(record, message);
283
+ if (isRecord(data) && typeof data.reason === "string") rejectedReceipts.push({ reason: data.reason });
284
+ }
285
+ if (customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE) continue;
286
+ try {
287
+ const facts = parseNoReceiptLifecycleFacts(dataOf(record, message));
288
+ if (facts.attemptPointer !== pointer) continue;
289
+ factTurns = Math.max(factTurns, facts.deliveryTurns);
290
+ // Snapshots include all preceding observations. Replay later events,
291
+ // rather than treating their free-text reasons as deduplication keys.
292
+ if (facts.rejectedReceipts.length >= rejectedReceipts.length) {
293
+ rejectedReceipts = facts.rejectedReceipts.map(({ reason }) => ({ reason }));
294
+ }
295
+ snapshotTerminal ||= facts.terminalToolCalled;
296
+ } catch {
297
+ // A malformed historical entry is not this call's count.
298
+ }
299
+ continue;
300
+ }
301
+ }
302
+ return {
303
+ deliveryTurns: Math.max(entryTurns, factTurns),
304
+ rejectedReceipts,
305
+ terminalToolCalled: snapshotTerminal || rejectedReceipts.length > 0,
115
306
  };
116
307
  }
@@ -19,6 +19,7 @@ import type {
19
19
  RoleTurnRequest,
20
20
  } from "./host-contracts.ts";
21
21
  import { packagedRoleOutputTool } from "./packaged-role-registry.ts";
22
+ import { deliveryLimitFromConfig } from "./receipt-delivery-policy.ts";
22
23
  import {
23
24
  createRoleRuntimeExtension,
24
25
  type RoleRuntimeDependencies,
@@ -34,6 +35,8 @@ import {
34
35
  extractInfrastructureFailureEvidence,
35
36
  } from "./navigator-invocation-identity.ts";
36
37
  import { serializeThrownValue } from "./serialize-thrown-value.ts";
38
+ import { retainPackageFault } from "./public-cli/settlement.ts";
39
+ import { describeErrorIdentity } from "./public-cli/run-lifecycle.ts";
37
40
 
38
41
  import { isRecord, errorText } from "./unknown-value.ts";
39
42
 
@@ -250,7 +253,10 @@ export async function prepareRoleEnvelope(options: {
250
253
  startKeepalive() {},
251
254
  stopKeepalive() {},
252
255
  };
253
- createRoleRuntimeExtension(options.dependencies)(envelope);
256
+ createRoleRuntimeExtension(
257
+ options.dependencies,
258
+ deliveryLimitFromConfig(options.request.deliveryRequestLimit),
259
+ )(envelope);
254
260
 
255
261
  const token = randomUUID();
256
262
  const server = createServer((socket) => serveSocket(socket));
@@ -553,14 +559,14 @@ export async function prepareRoleEnvelope(options: {
553
559
  const dispose = async (): Promise<void> => {
554
560
  if (disposed) return;
555
561
  disposed = true;
556
- const cleanupFailures: unknown[] = [];
562
+ const requiredFailures: unknown[] = [];
557
563
  // session_shutdown may still append package durable entries (navigator
558
564
  // attendance when agent_settled was skipped). Drain only after those
559
565
  // handlers — single terminal judgment via settleDurableWrites (#959).
560
566
  try {
561
567
  await emit("session_shutdown", {});
562
568
  } catch (error) {
563
- cleanupFailures.push(error);
569
+ requiredFailures.push(error);
564
570
  }
565
571
  try {
566
572
  // Always drain post-shutdown writes. Only throw when the host has not
@@ -568,7 +574,7 @@ export async function prepareRoleEnvelope(options: {
568
574
  const alreadyHanded = durableFailureHandedToCloseRound;
569
575
  const durableFailure = await settleDurableWrites();
570
576
  if (durableFailure !== undefined && !alreadyHanded) {
571
- cleanupFailures.push(
577
+ requiredFailures.push(
572
578
  Object.assign(
573
579
  new Error(durableFailure.failure.diagnostic ?? "durable session entry flush failed"),
574
580
  { code: durableFailure.failure.identity?.code ?? "durable-session-write-failed" },
@@ -576,7 +582,7 @@ export async function prepareRoleEnvelope(options: {
576
582
  );
577
583
  }
578
584
  } catch (error) {
579
- cleanupFailures.push(error);
585
+ requiredFailures.push(error);
580
586
  }
581
587
  try {
582
588
  const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections;
@@ -585,12 +591,17 @@ export async function prepareRoleEnvelope(options: {
585
591
  server.close((error) => (error ? reject(error) : resolve()));
586
592
  });
587
593
  } catch (error) {
588
- cleanupFailures.push(error);
594
+ // Closing the relay is ordinary cleanup, not required envelope work.
595
+ await retainPackageFault({
596
+ runDirectory: request.runDirectory,
597
+ diagnostic: `relay close failed beside envelope shutdown: ${describeErrorIdentity(error)}`,
598
+ error,
599
+ });
589
600
  }
590
- if (cleanupFailures.length === 1) throw cleanupFailures[0];
591
- if (cleanupFailures.length > 1) {
592
- throw new AggregateError(cleanupFailures, "ACP envelope dispose cleanup failures", {
593
- cause: cleanupFailures[0],
601
+ if (requiredFailures.length === 1) throw requiredFailures[0];
602
+ if (requiredFailures.length > 1) {
603
+ throw new AggregateError(requiredFailures, "Required envelope shutdown failures", {
604
+ cause: requiredFailures[0],
594
605
  });
595
606
  }
596
607
  };
@@ -64,6 +64,7 @@ export function createRoleRuntimeDependencies(packageRoot: string): RoleRuntimeD
64
64
  authority: options.authority,
65
65
  invocationId: options.invocationId,
66
66
  createSession: navigatorSessionFactory,
67
+ ...(options.deliveryRequestLimit === undefined ? {} : { deliveryRequestLimit: options.deliveryRequestLimit }),
67
68
  ...(options.contextError === undefined ? {} : { contextError: options.contextError }),
68
69
  onEvent: options.onEvent,
69
70
  }),