@akagilnc/pi-workflow-roles 0.1.5375 → 0.1.5406

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 (79) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/dist/acp-host/production-host.js +6381 -5684
  4. package/dist/archivist-record-pointer.js +38 -0
  5. package/dist/collector-tool-schemas.js +18 -0
  6. package/dist/compliance-transport.js +5 -0
  7. package/dist/diarist-contracts.js +26 -0
  8. package/dist/doctor-auditor.js +1 -0
  9. package/dist/engine-detour-usage.js +6 -6
  10. package/dist/filed-submission.js +28 -0
  11. package/dist/gatekeeper-role.js +6 -10
  12. package/dist/gleaner-left-contracts.js +16 -0
  13. package/dist/headless-host/production-host.js +1644 -947
  14. package/dist/judge-role.js +21 -16
  15. package/dist/migrate-book-topology.js +452 -81
  16. package/dist/navigator-attendance.js +10 -9
  17. package/dist/navigator-invocation-identity.js +18 -0
  18. package/dist/package-contracts/reviewer-output.js +16 -0
  19. package/dist/package-contracts/worker-output.js +18 -2
  20. package/dist/pi/role-turn-host.js +9 -0
  21. package/dist/public-cli/cli.js +1 -0
  22. package/dist/public-cli/instruction-seat-run.js +258 -55
  23. package/dist/public-cli/main.js +1209 -346
  24. package/dist/public-cli/post-admission.js +427 -244
  25. package/dist/public-cli/settlement.js +111 -48
  26. package/dist/public-cli/terminal.js +12 -1
  27. package/dist/public-cli/turn-request.js +3 -0
  28. package/dist/receipt-delivery-policy.js +152 -17
  29. package/dist/role-submission-declarations.js +131 -0
  30. package/dist/secretariat-contracts.js +23 -1
  31. package/dist/submission-errors.js +11 -1
  32. package/dist/submission-gate.js +32 -12
  33. package/package.json +1 -1
  34. package/src/archivist-record-pointer.ts +48 -0
  35. package/src/auditor-role.ts +2 -8
  36. package/src/collector-role.ts +5 -28
  37. package/src/compliance-transport.ts +10 -2
  38. package/src/countersign-role.ts +2 -7
  39. package/src/diarist-contracts.ts +37 -0
  40. package/src/diarist-role.ts +4 -44
  41. package/src/doctor-auditor.ts +3 -0
  42. package/src/doctor-role.ts +16 -11
  43. package/src/engine-detour-usage.ts +7 -6
  44. package/src/external-host-turn-loop.ts +24 -12
  45. package/src/filed-submission.ts +44 -0
  46. package/src/gatekeeper-role.ts +8 -16
  47. package/src/gleaner-left-contracts.ts +26 -1
  48. package/src/gleaner-left-role.ts +4 -32
  49. package/src/host-contracts.ts +7 -0
  50. package/src/inspector-role.ts +2 -7
  51. package/src/judge-role.ts +31 -24
  52. package/src/merger-role.ts +5 -8
  53. package/src/navigator-attendance.ts +17 -12
  54. package/src/navigator-invocation-identity.ts +31 -0
  55. package/src/navigator-role.ts +2 -8
  56. package/src/notary-role.ts +5 -20
  57. package/src/package-contracts/reviewer-output.ts +25 -0
  58. package/src/package-contracts/worker-output.ts +43 -5
  59. package/src/pi/adapter.ts +33 -8
  60. package/src/pi/role-turn-host.ts +7 -0
  61. package/src/public-cli/cli.ts +1 -0
  62. package/src/public-cli/instruction-seat-run.ts +292 -61
  63. package/src/public-cli/post-admission.ts +556 -324
  64. package/src/public-cli/settlement.ts +112 -50
  65. package/src/public-cli/terminal.ts +16 -4
  66. package/src/public-cli/turn-request.ts +10 -1
  67. package/src/receipt-delivery-policy.ts +209 -17
  68. package/src/reviewer-role.ts +7 -35
  69. package/src/role-envelope.ts +5 -1
  70. package/src/role-runtime-dependencies.ts +1 -0
  71. package/src/role-runtime.ts +72 -56
  72. package/src/role-submission-declarations.ts +143 -0
  73. package/src/secretariat-contracts.ts +35 -0
  74. package/src/secretariat-role.ts +4 -40
  75. package/src/submission-correctable-error.ts +5 -2
  76. package/src/submission-errors.ts +12 -1
  77. package/src/submission-gate.ts +41 -12
  78. package/src/worker-role.ts +81 -86
  79. package/src/worker-submission-gates.ts +32 -7
@@ -92,9 +92,10 @@ import {
92
92
  } from "../navigator-invocation-identity.ts";
93
93
  import {
94
94
  NO_RECEIPT_LIFECYCLE_ENTRY_TYPE,
95
- RECEIPT_DELIVERY_TURN_LIMIT,
96
95
  noReceiptLifecycleFacts,
97
96
  parseNoReceiptLifecycleFacts,
97
+ priorReceiptContinuation,
98
+ receiptAttemptPointer,
98
99
  type NoReceiptLifecycleFacts,
99
100
  } from "../receipt-delivery-policy.ts";
100
101
  import type {
@@ -290,17 +291,101 @@ export async function attachRecordedSubmissions<T extends TerminalResult>(
290
291
  * Does not invent an output failure for an empty ledger.
291
292
  * #953: empty public terminal face — clear every reader-adoptable prior face.
292
293
  */
294
+ /**
295
+ * Lifecycle fact the in-process runtime already wrote for this run and attempt.
296
+ * Absent, malformed, or a previous attempt's bytes are not a count.
297
+ */
298
+ function lifecycleFactsFromEntry(entry: SessionEntry): unknown {
299
+ const customType = entry.customType ?? entry.message?.customType;
300
+ if (customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE) return undefined;
301
+ return entry.data ?? entry.message?.details;
302
+ }
303
+
304
+ async function readCurrentAttemptNoReceiptFacts(
305
+ admitted: AdmittedRoleInvocation,
306
+ authority: DurablePrincipalAuthority,
307
+ scope?: SettlementCourtScope,
308
+ ): Promise<NoReceiptLifecycleFacts | undefined> {
309
+ const { sessionFile } = coordinatesFromAdmitted(authority, admitted);
310
+ const entries = await readBoundSessionEntries(sessionFile).catch(() => undefined);
311
+ if (entries === undefined) return undefined;
312
+ const scopeId = scope?.invocationScopeId?.trim() ?? "";
313
+ if (scopeId.length > 0) {
314
+ const pointer = receiptAttemptPointer(admitted.runDirectory, scopeId);
315
+ let selected: NoReceiptLifecycleFacts | undefined;
316
+ for (const entry of entries) {
317
+ const raw = lifecycleFactsFromEntry(entry);
318
+ if (raw === undefined) continue;
319
+ try {
320
+ const facts = parseNoReceiptLifecycleFacts(raw);
321
+ if (facts.runPointer !== admitted.runDirectory) continue;
322
+ if (facts.attemptPointer !== pointer) continue;
323
+ if (selected === undefined || facts.deliveryTurns >= selected.deliveryTurns) {
324
+ selected = facts;
325
+ }
326
+ } catch {
327
+ // A malformed historical entry is not this call's count.
328
+ }
329
+ }
330
+ return selected;
331
+ }
332
+ let attemptStart = 0;
333
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
334
+ if (entries[index]?.type === "message" && entries[index]?.message?.role === "user") {
335
+ attemptStart = index;
336
+ break;
337
+ }
338
+ }
339
+ const lifecycleEntry = entries.slice(attemptStart).reverse().find((entry: SessionEntry) =>
340
+ lifecycleFactsFromEntry(entry) !== undefined);
341
+ const raw = lifecycleEntry === undefined ? undefined : lifecycleFactsFromEntry(lifecycleEntry);
342
+ if (raw === undefined) return undefined;
343
+ let facts: NoReceiptLifecycleFacts;
344
+ try {
345
+ facts = parseNoReceiptLifecycleFacts(raw);
346
+ } catch {
347
+ return undefined;
348
+ }
349
+ if (facts.runPointer !== admitted.runDirectory) return undefined;
350
+ if (facts.attemptPointer !== receiptAttemptPointer(admitted.runDirectory)) return undefined;
351
+ return facts;
352
+ }
353
+
293
354
  export async function settleHostEndedNoReceipt(
294
355
  admitted: AdmittedRoleInvocation,
295
356
  authority: DurablePrincipalAuthority,
296
357
  scope?: SettlementCourtScope,
358
+ /**
359
+ * #1132: delivery requests this run actually issued. Zero stays zero when
360
+ * nothing was sent and this attempt has no lifecycle fact. A fact already
361
+ * written for this run and attempt supplies the count when it is larger.
362
+ * The budget is never the count, and another loop's resumes are not催交.
363
+ */
364
+ issuedDeliveryRequests = 0,
297
365
  ): Promise<TerminalResult> {
366
+ const scopeId = scope?.invocationScopeId?.trim() ?? "";
367
+ const persisted = scopeId.length > 0
368
+ ? undefined
369
+ : await readCurrentAttemptNoReceiptFacts(admitted, authority, scope);
370
+ let terminalToolCalled = persisted?.terminalToolCalled ?? false;
371
+ let rejectedReceipts: readonly { reason: string }[] = persisted?.rejectedReceipts ?? [];
372
+ let recordedTurns = persisted?.deliveryTurns ?? 0;
373
+ if (scopeId.length > 0) {
374
+ const { sessionFile } = coordinatesFromAdmitted(authority, admitted);
375
+ const entries = await readBoundSessionEntries(sessionFile).catch(() => undefined);
376
+ if (entries !== undefined) {
377
+ const prior = priorReceiptContinuation(entries, scopeId);
378
+ terminalToolCalled = prior.terminalToolCalled;
379
+ rejectedReceipts = prior.rejectedReceipts;
380
+ recordedTurns = prior.deliveryTurns;
381
+ }
382
+ }
298
383
  const facts = noReceiptLifecycleFacts({
299
- terminalToolCalled: false,
300
- rejectedReceipts: [],
301
- deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
384
+ terminalToolCalled,
385
+ rejectedReceipts,
386
+ deliveryTurns: Math.max(issuedDeliveryRequests, recordedTurns),
302
387
  runPointer: admitted.runDirectory,
303
- attemptPointer: `current:${admitted.runDirectory}`,
388
+ attemptPointer: receiptAttemptPointer(admitted.runDirectory, scope?.invocationScopeId),
304
389
  });
305
390
  const coordinates = coordinatesFromAdmitted(authority, admitted);
306
391
  await clearOppositeTerminalArtifactFace(admitted.runDirectory);
@@ -3191,51 +3276,28 @@ export async function settleFailureTerminalResult(
3191
3276
  // current-attempt fact. Transcript reconstruction must not turn arbitrary output
3192
3277
  // failures (or bytes retained from a prior resume attempt) into exit zero.
3193
3278
  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
- }
3279
+ const facts = await readCurrentAttemptNoReceiptFacts(admitted, authority, options);
3280
+ if (facts !== undefined) {
3281
+ const decisiveFacts: NoReceiptLifecycleFacts & Record<string, unknown> = facts;
3282
+ // #478: no_receipt is still a public Terminal — project accepted gate facts.
3283
+ // #953: empty public terminal face — clear every reader-adoptable prior face.
3284
+ await clearOppositeTerminalArtifactFace(admitted.runDirectory);
3285
+ return withOptionalGateProjection(
3286
+ {
3287
+ roleOutcome: {
3288
+ kind: "no_receipt",
3289
+ role: admitted.role,
3290
+ status: "no-accepted-receipt",
3291
+ ...facts,
3292
+ decisiveFacts,
3293
+ },
3294
+ navigator: await extractNavigatorFactFromAdmittedSession(sessionFile),
3295
+ artifacts: [],
3296
+ runId: admitted.runId,
3297
+ },
3298
+ sessionDirectory,
3299
+ detourGateContext(admitted, options),
3300
+ );
3239
3301
  }
3240
3302
  }
3241
3303
  // Exact-session attendance only — never infer no-advice from caller omission.
@@ -263,6 +263,19 @@ export function formatTerminalResult(result: TerminalResult): string {
263
263
  `diagnostic\t${encodeTerminalField(result.roleOutcome.diagnostic)}`,
264
264
  );
265
265
  }
266
+ // Accepted volumes do not dump decisiveFacts unless a failed attempt is
267
+ // recorded. The unsettled-direction fact still has to be visible on its own.
268
+ // audit_escalation already dumps every decisiveFact below.
269
+ if (
270
+ result.roleOutcome.kind === "accepted"
271
+ && result.roleOutcome.decisiveFacts?.directionUnsettled === true
272
+ ) {
273
+ lines.push("fact\tdirectionUnsettled\ttrue");
274
+ const subsequent = result.roleOutcome.decisiveFacts.subsequentAudit;
275
+ if (typeof subsequent === "string") {
276
+ lines.push(`fact\tsubsequentAudit\t${encodeTerminalField(subsequent)}`);
277
+ }
278
+ }
266
279
  if (result.roleOutcome.kind === "failure" || result.roleOutcome.kind === "no_receipt" || result.roleOutcome.kind === "audit_escalation" || result.roleOutcome.decisiveFacts?.failedAttempts !== undefined) {
267
280
  const facts = result.roleOutcome.kind === "accepted"
268
281
  ? { failedAttempts: result.roleOutcome.decisiveFacts?.failedAttempts }
@@ -337,11 +350,10 @@ export function formatTerminalResult(result: TerminalResult): string {
337
350
  lines.push(`recorded-submission\t${encodeTerminalField(rendered)}`);
338
351
  }
339
352
  } else {
340
- const payloads =
341
- result.roleOutcome.kind === "accepted" ||
353
+ const payloads = result.roleOutcome.kind === "accepted" ||
342
354
  result.roleOutcome.kind === "audit_escalation"
343
- ? coalesceSubmissionRows(result.roleOutcome.payloads, result.submissions)
344
- : result.submissions ?? [];
355
+ ? coalesceSubmissionRows(result.submissions, result.roleOutcome.payloads)
356
+ : result.submissions ?? [];
345
357
  for (let i = payloads.length - 1; i >= 0; i -= 1) {
346
358
  const payload = payloads[i]!;
347
359
  const rendered =
@@ -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
 
@@ -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() === ""
@@ -35,7 +89,7 @@ export function parseNoReceiptLifecycleFacts(input: unknown): NoReceiptLifecycle
35
89
  reason: item.reason as string,
36
90
  diagnosticAvailable: (item.reason as string).trim() !== "",
37
91
  })),
38
- deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
92
+ deliveryTurns: deliveryTurnsFact(input.deliveryTurns),
39
93
  sessionCompletion: "settled-without-accepted-receipt",
40
94
  runPointer: input.runPointer,
41
95
  attemptPointer: input.attemptPointer,
@@ -43,22 +97,24 @@ export function parseNoReceiptLifecycleFacts(input: unknown): NoReceiptLifecycle
43
97
  };
44
98
  }
45
99
 
100
+ /**
101
+ * #1132: exhaustion is the caller's decision — the receipt-delivery loop only
102
+ * settles after its budget is actually spent — so this records the issued
103
+ * count verbatim instead of demanding it equal a package constant.
104
+ */
46
105
  export function noReceiptLifecycleFacts(
47
106
  input: Omit<NoReceiptLifecycleFacts, "rejectedReceipts" | "deliveryTurns" | "sessionCompletion" | "acceptedReceipt"> & {
48
107
  rejectedReceipts: readonly { reason: string }[];
49
108
  deliveryTurns: number;
50
109
  },
51
110
  ): NoReceiptLifecycleFacts {
52
- if (input.deliveryTurns !== RECEIPT_DELIVERY_TURN_LIMIT) {
53
- throw new TypeError("no-receipt lifecycle requires an exhausted delivery budget");
54
- }
55
111
  return {
56
112
  terminalToolCalled: input.terminalToolCalled,
57
113
  rejectedReceipts: input.rejectedReceipts.map(({ reason }) => ({
58
114
  reason,
59
115
  diagnosticAvailable: reason.trim() !== "",
60
116
  })),
61
- deliveryTurns: RECEIPT_DELIVERY_TURN_LIMIT,
117
+ deliveryTurns: deliveryTurnsFact(input.deliveryTurns),
62
118
  sessionCompletion: "settled-without-accepted-receipt",
63
119
  runPointer: input.runPointer,
64
120
  attemptPointer: input.attemptPointer,
@@ -66,39 +122,66 @@ export function noReceiptLifecycleFacts(
66
122
  };
67
123
  }
68
124
 
69
- export function createReceiptDeliveryPolicy() {
125
+ export function createReceiptDeliveryPolicy(limit?: number) {
126
+ const deliveryLimit = deliveryLimitFromConfig(limit);
70
127
  let accepted = false;
128
+ let closed = false;
71
129
  let terminalToolCalled = false;
130
+ /** Requests actually sent. Rejections and a nested close do not invent sends. */
72
131
  let deliveryTurns = 0;
132
+ /** Stop counter. Separate from the issued-count fact (#1132). */
133
+ let budgetSpent = 0;
73
134
  const rejectedReceipts: { reason: string; diagnosticAvailable: boolean }[] = [];
135
+ const spend = (): void => {
136
+ if (budgetSpent < deliveryLimit) budgetSpent += 1;
137
+ };
74
138
  return {
139
+ /** The effective ceiling this budget spends (#1132 — never re-read). */
140
+ limit: deliveryLimit,
75
141
  recordAccepted() {
76
142
  accepted = true;
77
143
  terminalToolCalled = true;
78
144
  },
79
145
  /** Infrastructure owns terminality and must never trigger receipt催交. */
80
146
  stopForInfrastructure() { accepted = true; },
147
+ /**
148
+ * Record the rejection this turn already produced.
149
+ * The fact does not spend a budget. The seam that sends the next prompt
150
+ * owns that count.
151
+ */
81
152
  recordRejected(reason: string) {
82
153
  terminalToolCalled = true;
83
154
  rejectedReceipts.push({ reason, diagnosticAvailable: reason.trim() !== "" });
84
- deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
85
155
  },
86
156
  recordDeliveryRequest() {
87
- deliveryTurns = Math.min(RECEIPT_DELIVERY_TURN_LIMIT, deliveryTurns + 1);
157
+ deliveryTurns += 1;
158
+ spend();
88
159
  },
89
160
  /**
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.
161
+ * Stop asking. The issued count stays whatever was actually sent (#1132 —
162
+ * do not fill the remainder up to the ceiling).
163
+ */
164
+ closeBudget() { closed = true; },
165
+ /**
166
+ * A nested session settled without an accepted receipt. Close so this layer
167
+ * does not open another prompt on that session. The issued count stays the
168
+ * larger of the two seams: a nested zero must not wipe prompts this layer
169
+ * already sent, and adding the two counts would bill one send twice.
93
170
  */
94
171
  recordNestedNoReceipt(facts: NoReceiptLifecycleFacts) {
95
172
  terminalToolCalled = terminalToolCalled || facts.terminalToolCalled;
96
173
  rejectedReceipts.push(...facts.rejectedReceipts);
97
- deliveryTurns = RECEIPT_DELIVERY_TURN_LIMIT;
174
+ deliveryTurns = Math.max(deliveryTurns, facts.deliveryTurns);
175
+ closed = true;
98
176
  },
99
177
  nextAction(): "accepted" | "request-delivery" | "no-receipt" {
100
178
  if (accepted) return "accepted";
101
- return deliveryTurns < RECEIPT_DELIVERY_TURN_LIMIT ? "request-delivery" : "no-receipt";
179
+ if (closed) return "no-receipt";
180
+ return budgetSpent < deliveryLimit ? "request-delivery" : "no-receipt";
181
+ },
182
+ /** Delivery requests actually issued so far (#1132). */
183
+ issuedDeliveryRequests(): number {
184
+ return deliveryTurns;
102
185
  },
103
186
  /** Current typed delivery facts. No prose. */
104
187
  deliveryState() {
@@ -112,5 +195,114 @@ export function createReceiptDeliveryPolicy() {
112
195
  facts(binding: { runPointer: string; attemptPointer: string }): NoReceiptLifecycleFacts {
113
196
  return noReceiptLifecycleFacts({ terminalToolCalled, rejectedReceipts: [...rejectedReceipts], deliveryTurns, ...binding });
114
197
  },
198
+ /**
199
+ * Continue a public call after the in-memory policy was rebuilt.
200
+ * Sends already issued spend the same budget; copied rejections keep
201
+ * the fact and do not spend again.
202
+ */
203
+ continueIssued(prior: {
204
+ readonly deliveryTurns: number;
205
+ readonly rejectedReceipts: readonly { reason: string }[];
206
+ readonly terminalToolCalled?: boolean;
207
+ }) {
208
+ for (const receipt of prior.rejectedReceipts) {
209
+ this.recordRejected(receipt.reason);
210
+ }
211
+ if (prior.terminalToolCalled === true) terminalToolCalled = true;
212
+ for (let index = 0; index < prior.deliveryTurns; index += 1) {
213
+ this.recordDeliveryRequest();
214
+ }
215
+ },
216
+ };
217
+ }
218
+
219
+ function entryRecord(entry: unknown): Record<string, unknown> | undefined {
220
+ return isRecord(entry) ? entry : undefined;
221
+ }
222
+
223
+ /** Sends, observed rejections, and the latest lifecycle fact for this public call. */
224
+ export type ReceiptContinuation = {
225
+ readonly deliveryTurns: number;
226
+ readonly rejectedReceipts: readonly { reason: string }[];
227
+ readonly terminalToolCalled: boolean;
228
+ };
229
+
230
+ function customTypeOf(record: Record<string, unknown>, message: Record<string, unknown> | undefined): unknown {
231
+ return record.customType ?? message?.customType;
232
+ }
233
+
234
+ function dataOf(record: Record<string, unknown>, message: Record<string, unknown> | undefined): unknown {
235
+ return record.data ?? message?.details;
236
+ }
237
+
238
+ /** Scope id carried by a delivery request or an invocation-scoped lifecycle fact. */
239
+ function scopeTag(record: Record<string, unknown>): string | undefined {
240
+ const message = isRecord(record.message) ? record.message : undefined;
241
+ const customType = customTypeOf(record, message);
242
+ const data = dataOf(record, message);
243
+ if (
244
+ (customType === RECEIPT_DELIVERY_REQUEST_ENTRY || customType === RECEIPT_REJECTION_ENTRY)
245
+ && isRecord(data)
246
+ && typeof data.invocationScopeId === "string"
247
+ && data.invocationScopeId.trim() !== ""
248
+ ) {
249
+ return data.invocationScopeId;
250
+ }
251
+ if (customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE) return undefined;
252
+ try {
253
+ const facts = parseNoReceiptLifecycleFacts(data);
254
+ const prefix = "invocation:";
255
+ if (!facts.attemptPointer.startsWith(prefix)) return undefined;
256
+ const scope = facts.attemptPointer.slice(prefix.length);
257
+ return scope.trim() === "" ? undefined : scope;
258
+ } catch {
259
+ return undefined;
260
+ }
261
+ }
262
+
263
+ /** Restore only facts explicitly bound to this public call, never inferred from dialogue. */
264
+ export function priorReceiptContinuation(
265
+ entries: Iterable<unknown>,
266
+ invocationScopeId: string,
267
+ ): ReceiptContinuation {
268
+ const pointer = receiptAttemptPointer("", invocationScopeId);
269
+ let entryTurns = 0;
270
+ let factTurns = 0;
271
+ let rejectedReceipts: { reason: string }[] = [];
272
+ let snapshotTerminal = false;
273
+ for (const entry of entries) {
274
+ const record = entryRecord(entry);
275
+ if (record === undefined) continue;
276
+ const tagged = scopeTag(record);
277
+ if (tagged !== undefined) {
278
+ const message = isRecord(record.message) ? record.message : undefined;
279
+ const customType = customTypeOf(record, message);
280
+ if (tagged !== invocationScopeId) continue;
281
+ if (customType === RECEIPT_DELIVERY_REQUEST_ENTRY) entryTurns += 1;
282
+ if (customType === RECEIPT_REJECTION_ENTRY) {
283
+ const data = dataOf(record, message);
284
+ if (isRecord(data) && typeof data.reason === "string") rejectedReceipts.push({ reason: data.reason });
285
+ }
286
+ if (customType !== NO_RECEIPT_LIFECYCLE_ENTRY_TYPE) continue;
287
+ try {
288
+ const facts = parseNoReceiptLifecycleFacts(dataOf(record, message));
289
+ if (facts.attemptPointer !== pointer) continue;
290
+ factTurns = Math.max(factTurns, facts.deliveryTurns);
291
+ // Snapshots include all preceding observations. Replay later events,
292
+ // rather than treating their free-text reasons as deduplication keys.
293
+ if (facts.rejectedReceipts.length >= rejectedReceipts.length) {
294
+ rejectedReceipts = facts.rejectedReceipts.map(({ reason }) => ({ reason }));
295
+ }
296
+ snapshotTerminal ||= facts.terminalToolCalled;
297
+ } catch {
298
+ // A malformed historical entry is not this call's count.
299
+ }
300
+ continue;
301
+ }
302
+ }
303
+ return {
304
+ deliveryTurns: Math.max(entryTurns, factTurns),
305
+ rejectedReceipts,
306
+ terminalToolCalled: snapshotTerminal || rejectedReceipts.length > 0,
115
307
  };
116
308
  }
@@ -1,12 +1,9 @@
1
- import type { RoleHost, HostContext, HostToolResult } from "./host-contracts.ts";
2
- import { Type } from "typebox";
3
- import { openToolObject } from "./open-tool-schema.ts";
4
- import { withTerminatingOutputDeclarations } from "./package-contracts/terminating-infrastructure.ts";
1
+ import type { RoleHost, HostContext } from "./host-contracts.ts";
5
2
 
6
- import { REVIEWER_OUTPUT_TOOL_NAME, type ReviewerIntent } from "./package-contracts/reviewer-output.ts";
3
+ import { registerFiledSubmissionTool } from "./filed-submission.ts";
4
+ import { roleSubmissionDeclaration } from "./role-submission-declarations.ts";
7
5
 
8
- export { REVIEWER_OUTPUT_TOOL_NAME };
9
- export type { ReviewerIntent };
6
+ export { REVIEWER_OUTPUT_TOOL_NAME, reviewerOutputSchema, type ReviewerIntent } from "./package-contracts/reviewer-output.ts";
10
7
 
11
8
  /** Frozen admitted inputs the behavior layer may consume — no flag surface. */
12
9
  export type ReviewerAdmittedInputs = Readonly<{
@@ -17,25 +14,6 @@ export type ReviewerAdmittedInputs = Readonly<{
17
14
  ticketNumber?: number;
18
15
  }>;
19
16
 
20
- const REVIEWER_AMENDMENTS_DESCRIPTION =
21
- "已运行 lens 的 amendments 承载 candidates、逐条处置与 verdict 的完整报告(非仅 verdict 行);显式单轴时另一轴可省略(completeness / correctness)。hard-stop/usage error 为 refused 时,已产出报告仍照录对应 lens 字段。" as const;
22
- // #1134: `status` alone is the trajectory field (#917 §6 two normal lens verdicts →
23
- // completed; hard-stop / usage error → refused); its words ride the description.
24
- // amendments/diagnostic are LLM/human-read narrative content returned as
25
- // submitted — declaration keeps name + semantic description only, no nested
26
- // object shape, no string type, no required constraint.
27
- const REVIEWER_STATUS_DESCRIPTION =
28
- "completed | refused。两种正常 lens verdict(completeness / correctness)均 completed;hard-stop 与 usage error 为 refused(已产出报告仍照录进所选 lens amendments)。" as const;
29
- const reviewerOutputObject = Type.Object({
30
- status: Type.Unknown({ description: REVIEWER_STATUS_DESCRIPTION }),
31
- amendments: Type.Unknown({ description: REVIEWER_AMENDMENTS_DESCRIPTION }),
32
- diagnostic: Type.Unknown({
33
- description: "拒绝诊断说明;hard-stop/usage error 原因进此字段,已产出报告另照录 amendments",
34
- }),
35
- });
36
- export const reviewerOutputSchema = withTerminatingOutputDeclarations(
37
- openToolObject(reviewerOutputObject),
38
- );
39
17
  export type ReviewerRoleDependencies = {
40
18
  loadSoul(): Promise<string>;
41
19
  };
@@ -70,15 +48,9 @@ export function createReviewerRoleRuntime(
70
48
 
71
49
  if (!registered) {
72
50
  registered = true;
73
- pi.registerTool({ name: REVIEWER_OUTPUT_TOOL_NAME, label: "御史台输出", description: "提交御史台终局回执。", promptSnippet: "提交御史台终局回执", parameters: reviewerOutputSchema,
74
- async execute(_id: string, parameters: unknown): Promise<HostToolResult<unknown>> {
75
- if (!soul) throw new Error("御史台输入未装载");
76
- return {
77
- content: [],
78
- details: parameters,
79
- terminate: true as const,
80
- };
81
- } });
51
+ registerFiledSubmissionTool(pi, roleSubmissionDeclaration("reviewer"), {
52
+ readyError: () => (soul === undefined || soul.length === 0 ? "御史台输入未装载" : undefined),
53
+ });
82
54
  }
83
55
  const activatedSoul = soul;
84
56
  const activatedBase = fixedBaseRevision;
@@ -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,
@@ -250,7 +251,10 @@ export async function prepareRoleEnvelope(options: {
250
251
  startKeepalive() {},
251
252
  stopKeepalive() {},
252
253
  };
253
- createRoleRuntimeExtension(options.dependencies)(envelope);
254
+ createRoleRuntimeExtension(
255
+ options.dependencies,
256
+ deliveryLimitFromConfig(options.request.deliveryRequestLimit),
257
+ )(envelope);
254
258
 
255
259
  const token = randomUUID();
256
260
  const server = createServer((socket) => serveSocket(socket));
@@ -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
  }),