ai-foreman 1.7.28 → 2.0.0

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 (144) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/claude.d.ts +23 -12
  3. package/dist/adapters/claude.js +228 -24
  4. package/dist/adapters/codex.d.ts +17 -7
  5. package/dist/adapters/codex.js +195 -17
  6. package/dist/adapters/recovering.d.ts +2 -0
  7. package/dist/adapters/recovering.js +5 -1
  8. package/dist/adapters/structured.d.ts +71 -0
  9. package/dist/adapters/structured.js +213 -0
  10. package/dist/adapters/types.d.ts +86 -9
  11. package/dist/agentRun.d.ts +2 -0
  12. package/dist/agentRun.js +2 -1
  13. package/dist/boundedTurnEvidence.d.ts +18 -0
  14. package/dist/boundedTurnEvidence.js +76 -0
  15. package/dist/branch/currentGuard.d.ts +2 -0
  16. package/dist/branch/currentGuard.js +2 -0
  17. package/dist/branch/planner.js +1 -4
  18. package/dist/branch/runner.d.ts +2 -1
  19. package/dist/branch/runner.js +10 -7
  20. package/dist/buildAdmission.js +4 -0
  21. package/dist/buildApproval.d.ts +30 -0
  22. package/dist/buildApproval.js +68 -0
  23. package/dist/buildAssignment.d.ts +1 -0
  24. package/dist/buildAssignment.js +82 -23
  25. package/dist/buildInterventions.d.ts +2 -2
  26. package/dist/buildInterventions.js +11 -4
  27. package/dist/buildLaunchGate.js +3 -2
  28. package/dist/builderGuidanceFollowup.js +17 -6
  29. package/dist/cli/manager.js +22 -20
  30. package/dist/cli/start.js +32 -24
  31. package/dist/cli/tickets.js +40 -24
  32. package/dist/continuity.d.ts +10 -14
  33. package/dist/continuity.js +198 -179
  34. package/dist/continuityOrigin.d.ts +13 -0
  35. package/dist/continuityOrigin.js +91 -0
  36. package/dist/foreman.d.ts +20 -5
  37. package/dist/foreman.js +57 -187
  38. package/dist/graph/boundary.d.ts +1 -1
  39. package/dist/graph/boundary.js +1 -1
  40. package/dist/graph/host.js +8 -19
  41. package/dist/graph/manager.d.ts +1 -1
  42. package/dist/graph/manager.js +1 -1
  43. package/dist/graph/preparation.d.ts +38 -0
  44. package/dist/graph/preparation.js +7 -6
  45. package/dist/graph/semantic.d.ts +1 -0
  46. package/dist/graph/semantic.js +4 -1
  47. package/dist/graph/session.js +7 -2
  48. package/dist/graph/turn.d.ts +2 -3
  49. package/dist/graph/turn.js +28 -40
  50. package/dist/handoffProtocol.js +2 -2
  51. package/dist/handoffs.d.ts +3 -1
  52. package/dist/handoffs.js +90 -59
  53. package/dist/hostGraphApplicability.d.ts +46 -0
  54. package/dist/hostGraphApplicability.js +97 -0
  55. package/dist/hostQaAugmentation.d.ts +18 -0
  56. package/dist/hostQaAugmentation.js +81 -0
  57. package/dist/hostQaComparison.d.ts +21 -2
  58. package/dist/hostQaComparison.js +43 -28
  59. package/dist/hostSourcePreservation.d.ts +38 -0
  60. package/dist/hostSourcePreservation.js +72 -0
  61. package/dist/interviews.d.ts +7 -0
  62. package/dist/interviews.js +18 -1
  63. package/dist/legacyCommunicationDisplay.d.ts +2 -0
  64. package/dist/legacyCommunicationDisplay.js +2 -0
  65. package/dist/managerPacket.js +3 -3
  66. package/dist/markers.d.ts +4 -3
  67. package/dist/markers.js +4 -20
  68. package/dist/probeIpc.d.ts +14 -0
  69. package/dist/probeIpc.js +29 -0
  70. package/dist/qaBuildGate.d.ts +21 -0
  71. package/dist/qaBuildGate.js +48 -12
  72. package/dist/qaBuilderCoverage.js +22 -13
  73. package/dist/qaBuilderEvidence.d.ts +16 -1
  74. package/dist/qaBuilderEvidence.js +48 -8
  75. package/dist/qaContractCoverage.d.ts +2 -2
  76. package/dist/qaContractCoverage.js +11 -7
  77. package/dist/qaContractDelivery.js +34 -20
  78. package/dist/qaCoverageAssembly.d.ts +22 -0
  79. package/dist/qaCoverageAssembly.js +16 -0
  80. package/dist/qaDeliveryJournal.d.ts +2 -0
  81. package/dist/qaDeliveryJournal.js +19 -0
  82. package/dist/qaEffectiveConfig.d.ts +1 -0
  83. package/dist/qaEffectiveConfig.js +6 -1
  84. package/dist/qaFailureDelivery.js +97 -85
  85. package/dist/qaHostTransformation.d.ts +26 -0
  86. package/dist/qaHostTransformation.js +100 -0
  87. package/dist/qaPreparation.d.ts +8 -20
  88. package/dist/qaPreparation.js +133 -229
  89. package/dist/qaPreparationInputs.js +19 -0
  90. package/dist/qaPreparationStore.js +34 -6
  91. package/dist/qaPrerequisites.d.ts +2 -1
  92. package/dist/qaPrerequisites.js +21 -1
  93. package/dist/qaProtocolV2.d.ts +15 -0
  94. package/dist/qaRecovery.js +21 -4
  95. package/dist/qaReview.js +232 -394
  96. package/dist/qaReviewEvidence.d.ts +7 -3
  97. package/dist/qaReviewEvidence.js +15 -4
  98. package/dist/qaRuntime.js +1 -1
  99. package/dist/qaSnapshot.js +71 -21
  100. package/dist/qaStructuredResponse.d.ts +10 -0
  101. package/dist/qaStructuredResponse.js +57 -0
  102. package/dist/qaTimeline.js +5 -3
  103. package/dist/qaToolRecovery.d.ts +12 -0
  104. package/dist/qaToolRecovery.js +44 -0
  105. package/dist/qaVerificationContract.d.ts +3 -2
  106. package/dist/qaVerificationContract.js +58 -34
  107. package/dist/readinessGate.js +5 -4
  108. package/dist/runtimeReadiness.js +23 -15
  109. package/dist/statusReporter.d.ts +2 -0
  110. package/dist/statusReporter.js +2 -0
  111. package/dist/structuredCutover.d.ts +31 -0
  112. package/dist/structuredCutover.js +510 -0
  113. package/dist/structuredCutoverFiles.d.ts +10 -0
  114. package/dist/structuredCutoverFiles.js +181 -0
  115. package/dist/structuredDispatchAuthority.d.ts +22 -0
  116. package/dist/structuredDispatchAuthority.js +110 -0
  117. package/dist/structuredDispatchRecord.d.ts +29 -0
  118. package/dist/structuredDispatchRecord.js +36 -0
  119. package/dist/structuredLiveBudget.d.ts +2 -0
  120. package/dist/structuredLiveBudget.js +23 -0
  121. package/dist/structuredOperation.d.ts +18 -0
  122. package/dist/structuredOperation.js +257 -0
  123. package/dist/supervisedStart.d.ts +1 -1
  124. package/dist/supervisedStart.js +78 -31
  125. package/dist/supervisor.d.ts +18 -2
  126. package/dist/supervisor.js +74 -17
  127. package/dist/ticketPlanning.d.ts +1 -373
  128. package/dist/ticketPlanning.js +2 -26
  129. package/dist/ticketPopulation.d.ts +0 -3
  130. package/dist/ticketPopulation.js +4 -21
  131. package/dist/ticketPopulationPageAuthority.d.ts +13 -0
  132. package/dist/ticketPopulationPageAuthority.js +30 -0
  133. package/dist/ticketPopulationPaging.d.ts +27 -0
  134. package/dist/ticketPopulationPaging.js +105 -0
  135. package/dist/tickets/renderMarkdown.d.ts +14 -0
  136. package/dist/tickets/renderMarkdown.js +39 -5
  137. package/dist/tickets/ticketLoader.js +10 -0
  138. package/dist/tickets/ticketSchema.d.ts +107 -0
  139. package/dist/tickets/ticketSchema.js +4 -1
  140. package/dist/workflowDb.d.ts +100 -2
  141. package/dist/workflowDb.js +781 -35
  142. package/dist/workflowReader.d.ts +1 -0
  143. package/dist/workflowReader.js +9 -0
  144. package/package.json +15 -7
@@ -0,0 +1,257 @@
1
+ import { sealTicketPopulationPage } from "./ticketPopulationPageAuthority.js";
2
+ import { boundedUtf8 } from "./boundedTurnEvidence.js";
3
+ import { randomUUID } from "node:crypto";
4
+ import { assertIntermediateResultV2, historicProviderWireContract, providerWireContract, renderProviderWireInstruction, selectedDispatchTupleV2, structuredDigest, validateStructuredCandidate } from "rafi-spec";
5
+ import { WorkflowDb } from "./workflowDb.js";
6
+ import { withSealedDispatch, currentFreshRecoveryValidation } from "./structuredDispatchAuthority.js";
7
+ import { sealedDispatchRecord, verifyPersistedDispatch } from "./structuredDispatchRecord.js";
8
+ export function operationInstruction(contract) {
9
+ return `Rafi operation ${contract.operation}, protocol 2, schema ${contract.schemaVersion}. Submit only the native JSON selected by the provider wire contract appended by Rafi. Do not emit prose delimiters, status lines, host identifiers, host defaults, or fields outside that selected schema. Keep provider-authored summaries and evidence concise. Human progress is presentation only.`;
10
+ }
11
+ /**
12
+ * Actionable host message for a failed structured turn. The response text of an
13
+ * otherwise valid turn is evidence, not the error; prefer retained host
14
+ * failures, then the native rejection, then provider diagnostics.
15
+ */
16
+ export function structuredTurnErrorMessage(turn, label = "Structured provider turn") {
17
+ if (turn.continuityErrors?.length)
18
+ return `${label} failed continuity postprocessing: ${turn.continuityErrors.join("; ")}`;
19
+ if (turn.structured?.state === "rejected")
20
+ return `${label} was rejected (${turn.structured.reason}): ${turn.structured.detail}`;
21
+ if (turn.failure)
22
+ return `${label} failed (${turn.failure.category}, ${turn.failure.phase}): ${boundedUtf8(turn.failure.diagnostics, 2000).toString()}`;
23
+ return turn.text || `${label} failed without diagnostics`;
24
+ }
25
+ export function operationResponse(turn, contract) {
26
+ if (turn.isError || turn.failure)
27
+ throw new Error(turn.structured?.state === "rejected" ? turn.structured.detail : turn.text || "Provider execution did not complete");
28
+ if (!turn.selectedDispatch)
29
+ throw new Error("Structured provider turn has no sealed selected dispatch tuple");
30
+ const descriptor = historicProviderWireContract(contract, turn.selectedDispatch);
31
+ const response = validateStructuredCandidate(contract, turn.structured, descriptor, turn.selectedDispatch);
32
+ if (response.result.kind.endsWith("-request"))
33
+ assertIntermediateResultV2(response.result);
34
+ return response;
35
+ }
36
+ export function operationStatus(turn, contract, ticket) {
37
+ try {
38
+ const { result } = operationResponse(turn, contract);
39
+ if (result.kind === "completed") {
40
+ return { kind: "done", summary: result.summary, ticket };
41
+ }
42
+ if (result.kind === "blocked")
43
+ return { kind: "blocked", reason: result.reason, next: result.nextAction, ticket, ...(result.branchDependency ? { branchDependency: result.branchDependency } : {}) };
44
+ if (result.kind === "needs-input")
45
+ return { kind: "needs_input", question: result.question, choices: result.choices, ticket };
46
+ if (result.kind === "source-request")
47
+ return { kind: "needs_input", question: "Additional source material is required. Supply or authorize the requested source content.", choices: [], ticket };
48
+ return { kind: "unknown", error: `Unconsumed intermediate result: ${result.kind}` };
49
+ }
50
+ catch (error) {
51
+ return { kind: "unknown", error: boundedUtf8(String(error), 4000).toString() };
52
+ }
53
+ }
54
+ export function operationPayload(turn, contract) {
55
+ const response = operationResponse(turn, contract);
56
+ if (response.result.kind !== "completed")
57
+ throw new Error(`Operation ${contract.operation} returned ${response.result.kind}`);
58
+ return response.result.payload;
59
+ }
60
+ export function nativePolicy(output, policy = {}) {
61
+ return { ...policy, output, logicalActionId: policy.logicalActionId ?? randomUUID(), dispatchId: policy.dispatchId ?? randomUUID() };
62
+ }
63
+ /** Reuses the workflow journal. A receipt is retained before domain interpretation. */
64
+ export async function journalStructuredDispatch(root, adapter, instruction, policy, dispatch) {
65
+ if (!policy.output)
66
+ return dispatch(instruction, policy);
67
+ if (!adapter.prepareSession)
68
+ throw new Error("Structured dispatch requires an adapter that prepares a scoped provider session");
69
+ const selected = nativePolicy(policy.output, policy);
70
+ // Decorator-owned requirements (such as mandatory continuity) become part of
71
+ // the sealed provider instruction; they are never appended after sealing.
72
+ const requirement = adapter.structuredDispatchRequirement?.();
73
+ const providerInstruction = requirement ? `${instruction}\n\n${requirement.instruction}` : instruction;
74
+ const key = selected.dispatchId;
75
+ const runId = selected.journalRunId ?? `operation:${selected.logicalActionId}`;
76
+ const db = new WorkflowDb(root);
77
+ try {
78
+ db.enableStructuredCommunication();
79
+ db.ensureRun(runId);
80
+ if (adapter.postConfirmedDispatch)
81
+ for (const pendingId of db.pendingStructuredPostprocessing(runId)) {
82
+ if (pendingId === selected.parentDispatchId)
83
+ continue;
84
+ const pending = db.operation(pendingId);
85
+ if (pending?.status !== "confirmed")
86
+ throw new Error("Postprocessing origin is not confirmed");
87
+ const tuple = db.structuredOwnerDispatch(pendingId);
88
+ // Rehydrate and publish locally; this path never calls raw transport.
89
+ await adapter.postConfirmedDispatch({ dispatchId: pendingId, runId, tupleDigest: tuple.tupleDigest });
90
+ db.completeStructuredPostprocessing(pendingId);
91
+ db.finishStructuredContinuation(pendingId);
92
+ }
93
+ db.assertStructuredDispatchAdmission(runId, key, selected.parentDispatchId, policy.output.operation);
94
+ const prior = db.planOperation({ runId, idempotencyKey: key, kind: "provider-dispatch", intent: {
95
+ protocolVersion: 2, state: "preparing", logicalActionId: selected.logicalActionId,
96
+ operation: policy.output.operation, schemaVersion: policy.output.schemaVersion, schemaDigest: policy.output.schemaDigest,
97
+ instructionDigest: structuredDigest(providerInstruction), purpose: selected.purpose ?? "implementation",
98
+ } });
99
+ if (prior.status !== "planned")
100
+ throw new Error("Existing structured dispatch requires reconciliation; it cannot be replayed");
101
+ // Session bootstrap can itself contact the provider. The non-authoritative
102
+ // preparation record above is durable before that boundary is crossed.
103
+ const prepared = await adapter.prepareSession();
104
+ if (!prepared.sessionId || !Number.isSafeInteger(prepared.generation) || prepared.generation < 0)
105
+ throw new Error("Adapter did not prepare a valid provider session for structured dispatch");
106
+ const wireContract = providerWireContract(policy.output, adapter.agent, "default", { requireRegistered: true });
107
+ if (policy.wireContract && policy.wireContract.descriptorDigest !== wireContract.descriptorDigest)
108
+ throw new Error("New dispatch may select only the static active descriptor");
109
+ if (wireContract.provider !== adapter.agent || wireContract.canonical.schemaDigest !== policy.output.schemaDigest)
110
+ throw new Error("Structured dispatch wire contract does not match its adapter/output contract");
111
+ if (policy.renderedWireContractDigest && policy.renderedWireContractDigest !== wireContract.descriptorDigest)
112
+ throw new Error("Structured dispatch guidance does not match its selected wire contract");
113
+ const renderedInstruction = renderProviderWireInstruction(providerInstruction, wireContract);
114
+ const sealedSelection = nativePolicy(policy.output, { ...selected, wireContract, renderedWireContractDigest: wireContract.descriptorDigest });
115
+ const before = adapter.sessionRef?.();
116
+ if (!before || before.sessionId !== prepared.sessionId || before.generation !== prepared.generation)
117
+ throw new Error("Prepared provider session is not the adapter's active scoped session");
118
+ const tuple = selectedDispatchTupleV2({
119
+ operation: policy.output.operation, schemaVersion: policy.output.schemaVersion, schemaDigest: policy.output.schemaDigest,
120
+ provider: adapter.agent, profile: wireContract.profile, variant: wireContract.variant, descriptorDigest: wireContract.descriptorDigest,
121
+ wireSchemaDigest: wireContract.wireSchemaDigest, guidanceDigest: wireContract.guidanceDigest,
122
+ normalizerId: wireContract.normalizerId, normalizerVersion: wireContract.normalizerVersion,
123
+ maximumSchemaBytes: wireContract.maximumSchemaBytes, maximumResponseBytes: wireContract.maximumResponseBytes,
124
+ maximumPageBytes: wireContract.maximumPageBytes ?? null, instructionDigest: structuredDigest(renderedInstruction),
125
+ actionId: sealedSelection.logicalActionId, dispatchId: sealedSelection.dispatchId, sessionId: before.sessionId, generation: before.generation,
126
+ });
127
+ const sealedPolicy = Object.freeze({ ...sealedSelection, selectedDispatch: tuple });
128
+ const compactionBefore = adapter.contractCompactionSequence?.() ?? 0;
129
+ const intent = { protocolVersion: 2, tuple, sealedDispatch: sealedDispatchRecord(tuple), logicalActionId: selected.logicalActionId, parentDispatchId: selected.parentDispatchId ?? null,
130
+ // The durable role is derived from the scoped provider session, never a
131
+ // caller-controlled string on TurnRequest.
132
+ role: before.role, sessionRef: before, operation: policy.output.operation,
133
+ schemaVersion: policy.output.schemaVersion, schemaDigest: policy.output.schemaDigest, instructionDigest: structuredDigest(renderedInstruction),
134
+ purpose: selected.purpose ?? "implementation", responseOnly: selected.responseOnly === true,
135
+ freshRecoveryValidation: currentFreshRecoveryValidation(root, runId),
136
+ wire: { profile: wireContract.profile, variant: wireContract.variant, wireVersion: wireContract.wireVersion,
137
+ descriptorDigest: wireContract.descriptorDigest, wireSchemaDigest: wireContract.wireSchemaDigest,
138
+ maximumSchemaBytes: wireContract.maximumSchemaBytes, maximumResponseBytes: wireContract.maximumResponseBytes,
139
+ maximumPageBytes: wireContract.maximumPageBytes ?? null, normalizerId: wireContract.normalizerId,
140
+ normalizerVersion: wireContract.normalizerVersion, guidanceId: wireContract.guidanceId } };
141
+ const priorRecord = db.operation(key);
142
+ if (priorRecord.status !== "planned")
143
+ throw new Error("Existing structured dispatch requires reconciliation; it cannot be replayed");
144
+ db.atomic(() => {
145
+ db.linkStructuredHandoffDispatch(key, selected.parentDispatchId, policy.output.operation);
146
+ db.sealPlannedOperationIntent(key, intent);
147
+ db.sealStructuredOwnerDispatch(key, tuple, renderedInstruction);
148
+ sealTicketPopulationPage(db, root, tuple);
149
+ db.updateOperation(key, "in_progress");
150
+ });
151
+ let turn;
152
+ try {
153
+ turn = await withSealedDispatch(root, tuple, dispatchCapability => dispatch(renderedInstruction, { ...sealedPolicy, journalRunId: runId, dispatchCapability }), before);
154
+ }
155
+ catch (error) {
156
+ if (db.operation(key)?.status === "in_progress")
157
+ db.updateOperation(key, "uncertain", { error: boundedUtf8(String(error), 4000).toString() });
158
+ throw error;
159
+ }
160
+ const after = adapter.sessionRef?.();
161
+ const sameScope = before && after && before.provider === after.provider && before.role === after.role &&
162
+ before.stream === after.stream && before.cwd === after.cwd && before.configRoot === after.configRoot &&
163
+ before.workspaceIdentity === after.workspaceIdentity && before.ticketId === after.ticketId && before.deliveryUnitId === after.deliveryUnitId;
164
+ const observedCompaction = sameScope && (adapter.contractCompactionSequence?.() ?? 0) > compactionBefore && after.generation >= before.generation;
165
+ if (turn.structured?.state === "candidate" && (turn.structured.profile !== wireContract.profile || turn.structured.wireContractDigest !== wireContract.descriptorDigest ||
166
+ turn.structured.wireSchemaDigest !== wireContract.wireSchemaDigest || turn.structured.wireVariant !== wireContract.variant || turn.structured.dispatchId !== key || turn.structured.sessionId !== adapter.sessionId() ||
167
+ before && (!sameScope || !observedCompaction && (before.sessionId !== after.sessionId || before.generation !== after.generation)))) {
168
+ turn = { ...turn, selectedDispatch: tuple, isError: true, structured: { state: "rejected", reason: "invalid-contract", detail: "Dispatch/session/generation receipt mismatch", dispatchId: key } };
169
+ }
170
+ // Exact raw provider material is retained once in the evidence store. The
171
+ // journal only carries the digest/reference and validation fact, so it
172
+ // cannot become a second, differently-redacted copy of the provider turn.
173
+ turn = { ...turn, selectedDispatch: tuple };
174
+ let receiptValidated = false;
175
+ if (turn.structured?.state === "rejected")
176
+ turn = { ...turn, isError: true };
177
+ else if (!turn.isError && !turn.failure) {
178
+ try {
179
+ const response = verifyPersistedDispatch(policy.output, db.structuredOwnerDispatch(key), turn.structured, tuple);
180
+ // A response that cannot satisfy host requirements is rejected before
181
+ // confirmation instead of failing in postprocessing after it.
182
+ requirement?.validate(response);
183
+ receiptValidated = true;
184
+ }
185
+ catch (error) {
186
+ turn = { ...turn, selectedDispatch: tuple, isError: true, structured: { state: "rejected", reason: "invalid-contract", detail: boundedUtf8(String(error), 4000).toString(), dispatchId: key } };
187
+ }
188
+ }
189
+ const retained = { version: 2, tupleDigest: tuple.digest, hostTurnId: turn.turnId, receipt: turn.structured ?? null };
190
+ if (turn.structured?.state === "candidate") {
191
+ const { value, ...receipt } = turn.structured;
192
+ const bytes = Buffer.from(receipt.representation === "json-text" ? value : JSON.stringify(value));
193
+ if (bytes.length > wireContract.maximumResponseBytes)
194
+ throw new Error("Native receipt exceeds selected evidence budget");
195
+ retained.valueDigest = db.putEvidence("handoff", bytes);
196
+ retained.receipt = receipt;
197
+ }
198
+ if (turn.structured?.state === "rejected") {
199
+ const { observedValue, ...receipt } = turn.structured;
200
+ retained.receipt = { ...receipt, detail: boundedUtf8(receipt.detail, 4000).toString() };
201
+ if (observedValue) {
202
+ const bytes = Buffer.from(observedValue.representation === "json-text" ? String(observedValue.value) : JSON.stringify(observedValue.value));
203
+ const bounded = boundedUtf8(bytes.toString(), wireContract.maximumResponseBytes);
204
+ retained.rejectedValue = { representation: observedValue.representation, digest: observedValue.digest,
205
+ evidenceDigest: db.putEvidence("handoff", bounded), originalBytes: bytes.length, retainedBytes: bounded.length };
206
+ }
207
+ }
208
+ const raw = Buffer.from(turn.rawResponse ?? turn.text);
209
+ const retainedRaw = boundedUtf8(turn.rawResponse ?? turn.text, wireContract.maximumResponseBytes);
210
+ const rawDigest = db.putEvidence("handoff", retainedRaw);
211
+ const responseDigest = db.putEvidence("handoff", Buffer.from(JSON.stringify(retained)));
212
+ db.recordCommunicationEvidenceRefs(key, [responseDigest, rawDigest, ...(retained.valueDigest ? [retained.valueDigest] : []), ...(retained.rejectedValue ? [retained.rejectedValue.evidenceDigest] : [])]);
213
+ const status = turn.failure?.dispatchState === "not-sent" ? "failed" : turn.failure?.dispatchState === "unknown" || !receiptValidated ? "uncertain" : "confirmed";
214
+ turn = { ...turn, transportEvidence: { rawDigest, responseDigest, rawBytes: raw.length, retainedRawBytes: retainedRaw.length,
215
+ valueDigest: retained.valueDigest, rejectedValueDigest: retained.rejectedValue?.evidenceDigest } };
216
+ db.atomic(() => {
217
+ if (status === "confirmed" && adapter.postConfirmedDispatch)
218
+ db.requireStructuredPostprocessing(key, { instruction, policy: continuationPolicy(selected, key) });
219
+ db.updateOperation(key, status, { result: { responseDigest, rawDigest, rawBytes: raw.length, retainedRawBytes: retainedRaw.length, validation: receiptValidated ? "accepted" : turn.structured?.state ?? "missing-receipt", wireContractDigest: wireContract.descriptorDigest } });
220
+ });
221
+ if (status === "confirmed" && adapter.postConfirmedDispatch) {
222
+ try {
223
+ const processed = await adapter.postConfirmedDispatch({ dispatchId: key, runId, tupleDigest: tuple.digest });
224
+ db.completeStructuredPostprocessing(key);
225
+ db.finishStructuredContinuation(key);
226
+ if (processed)
227
+ return processed.selectedDispatch?.dispatchId === key
228
+ ? { ...turn, continuity: processed.continuity, continuityErrors: processed.continuityErrors, hostInstruction: processed.hostInstruction, isError: processed.isError }
229
+ : processed;
230
+ }
231
+ catch (error) {
232
+ db.failStructuredPostprocessing(key, boundedUtf8(String(error), 4000).toString());
233
+ return { ...turn, isError: true, continuityErrors: [boundedUtf8(String(error), 4000).toString()] };
234
+ }
235
+ }
236
+ if (status === "confirmed")
237
+ db.finishStructuredContinuation(key);
238
+ return turn;
239
+ }
240
+ catch (error) {
241
+ // A failed bootstrap stays an explicit, non-resumable preparation record.
242
+ const planned = db.operation(key);
243
+ if (planned?.status === "planned")
244
+ db.updateOperation(key, "failed", { error: boundedUtf8(String(error), 4000).toString() });
245
+ else if (planned?.status === "in_progress")
246
+ db.updateOperation(key, "uncertain", { error: boundedUtf8(String(error), 4000).toString() });
247
+ throw error;
248
+ }
249
+ finally {
250
+ db.close();
251
+ }
252
+ }
253
+ /** Only unsealed host request fields survive a handoff or a restart. */
254
+ export function continuationPolicy(policy, parentDispatchId) {
255
+ return { logicalActionId: policy.logicalActionId, journalRunId: policy.journalRunId, purpose: policy.purpose,
256
+ responseOnly: policy.responseOnly, handback: policy.handback, parentDispatchId };
257
+ }
@@ -1,5 +1,5 @@
1
1
  import { WorkflowDb } from "./workflowDb.js";
2
- import type { ResolvedAutonomyPolicy } from "rafi-spec";
2
+ import { type ResolvedAutonomyPolicy } from "rafi-spec";
3
3
  /** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
4
4
  export declare function supervisedRunId(): string | undefined;
5
5
  /** Both ordinary and branch starts enter through this process boundary. */
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { hostname } from "node:os";
7
7
  import { DurableSupervisor } from "./supervisor.js";
8
8
  import { WorkflowDb } from "./workflowDb.js";
9
+ import { parseHostIpc, stopCause } from "rafi-spec";
9
10
  import { processStartIdentity } from "./processIdentity.js";
10
11
  import { cancelOwnedRuntimeProbes } from "./runtimeReadiness.js";
11
12
  /** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
@@ -37,8 +38,9 @@ export async function superviseStart(projectDir, runId, policy, selection) {
37
38
  }
38
39
  maintainBuildAdmission(localBuildAuthority(projectDir));
39
40
  if (supervisedRunId()) {
41
+ let sequence = 0;
40
42
  const heartbeat = setInterval(() => { if (process.connected)
41
- process.send?.({ kind: "rafi-worker-heartbeat", runId }); }, 5_000);
43
+ process.send?.({ version: 1, kind: "rafi-worker-heartbeat", runId, generation: Number(process.env.RAFI_BUILD_WORKER_GENERATION), pid: process.pid, sequence: ++sequence }); }, 5_000);
42
44
  heartbeat.unref();
43
45
  process.once("disconnect", () => {
44
46
  clearInterval(heartbeat);
@@ -51,8 +53,9 @@ export async function superviseStart(projectDir, runId, policy, selection) {
51
53
  const owner = durable.supervisorState(runId);
52
54
  if (owner?.workerGeneration !== Number(process.env.RAFI_BUILD_WORKER_GENERATION) || owner.workerPid !== process.pid)
53
55
  return;
54
- durable.transition(runId, { status: "paused", checkpoint: "orphaned-worker", event: "supervisor_disconnected", payload: { workerPid: process.pid } });
55
- durable.putSupervisorState(runId, { ...owner, status: "stopping" });
56
+ const cause = stopCause({ cause: "worker-disconnect", source: "worker", actor: "worker", runId, supervisorGeneration: owner.generation, workerGeneration: owner.workerGeneration, pid: process.pid, processStart: processStartIdentity(), checkpoint: "orphaned-worker", at: new Date().toISOString(), requested: false, detail: "Worker IPC disconnected before the supervisor acknowledged shutdown" });
57
+ durable.transition(runId, { status: "paused", checkpoint: "orphaned-worker", event: "supervisor_disconnected", payload: { workerPid: process.pid, cause } });
58
+ durable.putSupervisorState(runId, { ...owner, status: "stopping", stopCause: cause });
56
59
  });
57
60
  }
58
61
  catch (error) {
@@ -116,10 +119,15 @@ export async function superviseStart(projectDir, runId, policy, selection) {
116
119
  try {
117
120
  await new Promise((resolve, reject) => {
118
121
  const timer = setTimeout(() => { parent.kill("SIGTERM"); reject(new Error("Detached supervisor startup timed out; use rafi resume to reconcile")); }, 15_000);
119
- parent.once("message", () => {
122
+ let acknowledged = false;
123
+ parent.on("message", value => {
124
+ const registered = parseHostIpc(value);
125
+ if (acknowledged || registered?.kind !== "rafi-launch-registered" || registered.token !== launch.token || registered.pid !== parent.pid)
126
+ return;
127
+ acknowledged = true;
120
128
  try {
121
129
  launchDb.acknowledgeBuildLaunchChild(authority, launch.token, parent.pid);
122
- parent.send("rafi-launch-ack", error => { clearTimeout(timer); if (error)
130
+ parent.send({ version: 1, kind: "rafi-launch-ack", token: launch.token, pid: parent.pid }, error => { clearTimeout(timer); if (error)
123
131
  reject(error);
124
132
  else
125
133
  resolve(); });
@@ -156,9 +164,10 @@ export async function superviseStart(projectDir, runId, policy, selection) {
156
164
  db.ensureRun(runId, "build");
157
165
  db.freezeAutonomyPolicy(runId, policy);
158
166
  let exitCode = 2;
159
- let stopRequestSource;
167
+ let stopRequestCause;
160
168
  const supervisor = new DurableSupervisor({ projectDir, runId, policy, admission: localBuildAuthority(projectDir),
161
169
  checkpoint: () => db.getRun(runId)?.checkpoint ?? "preparing",
170
+ onStopRequested: cause => { stopRequestCause = cause; },
162
171
  spawnWorker: generation => {
163
172
  let authority = localBuildAuthority(projectDir);
164
173
  if (db.buildAdmission()?.token !== authority.token) {
@@ -171,9 +180,12 @@ export async function superviseStart(projectDir, runId, policy, selection) {
171
180
  stdio: ["inherit", "inherit", "inherit", "ipc"], detached: process.platform !== "win32",
172
181
  });
173
182
  const startup = setTimeout(() => child.kill("SIGTERM"), 15_000);
174
- child.on("message", message => {
175
- if (!message || typeof message !== "object" || !("kind" in message) || message.kind !== "rafi-launch-registered")
183
+ let acknowledged = false;
184
+ child.on("message", value => {
185
+ const registered = parseHostIpc(value);
186
+ if (acknowledged || registered?.kind !== "rafi-launch-registered" || registered.token !== launch.token || registered.pid !== child.pid)
176
187
  return;
188
+ acknowledged = true;
177
189
  try {
178
190
  db.acknowledgeBuildLaunchChild(authority, launch.token, child.pid);
179
191
  // Record the incarnation before acknowledging the blocked launch gate.
@@ -182,7 +194,7 @@ export async function superviseStart(projectDir, runId, policy, selection) {
182
194
  checkpoint: db.getRun(runId)?.checkpoint ?? "preparing", event: "worker_started",
183
195
  payload: { workerPid: child.pid, processStart: processStartIdentity(child.pid), host: hostname(), generation },
184
196
  });
185
- child.send("rafi-launch-ack", () => { });
197
+ child.send({ version: 1, kind: "rafi-launch-ack", token: launch.token, pid: child.pid }, () => { });
186
198
  clearTimeout(startup);
187
199
  }
188
200
  catch {
@@ -212,26 +224,31 @@ export async function superviseStart(projectDir, runId, policy, selection) {
212
224
  };
213
225
  const heartbeatTimeout = policy.runtimeDeadlines?.heartbeat_ms ?? 120_000;
214
226
  const watchdog = setInterval(() => { if (Date.now() - lastHeartbeat > heartbeatTimeout) {
215
- stopRequestSource = "heartbeat-timeout";
227
+ stopRequestCause = stopCause({ cause: "heartbeat-timeout", source: "heartbeat", actor: "supervisor", runId, supervisorGeneration: generation, workerGeneration: generation, checkpoint: db.getRun(runId)?.checkpoint ?? "unknown", at: new Date().toISOString(), requested: true, detail: `worker heartbeat exceeded ${heartbeatTimeout}ms` });
216
228
  stop();
217
229
  } }, Math.min(5_000, Math.max(25, heartbeatTimeout / 4)));
218
230
  watchdog.unref();
219
- child.on("message", message => { if (message && typeof message === "object" && "kind" in message && message.kind === "rafi-worker-heartbeat")
220
- lastHeartbeat = Date.now(); });
221
- child.on("message", message => {
222
- if (!message || typeof message !== "object" || !("kind" in message) || message.kind !== "rafi-readiness-child")
223
- return;
224
- const value = message;
225
- if (!Number.isSafeInteger(value.pid) || Number(value.pid) <= 1)
231
+ let heartbeatSequence = 0;
232
+ child.on("message", value => {
233
+ const message = parseHostIpc(value);
234
+ if (acknowledged && message?.kind === "rafi-worker-heartbeat" && message.runId === runId && message.generation === generation && message.pid === child.pid && message.sequence > heartbeatSequence) {
235
+ heartbeatSequence = message.sequence;
236
+ lastHeartbeat = Date.now();
237
+ }
238
+ });
239
+ child.on("message", raw => {
240
+ const value = parseHostIpc(raw);
241
+ if (!acknowledged || value?.kind !== "rafi-readiness-child" || value.runId !== runId || value.generation !== generation || value.workerPid !== child.pid)
226
242
  return;
227
- if (typeof value.tag === "string")
228
- readinessTags.set(Number(value.pid), value.tag);
229
- if (value.active === false) {
230
- readinessChildren.delete(Number(value.pid));
231
- readinessTags.delete(Number(value.pid));
243
+ if (value.active) {
244
+ readinessTags.set(value.pid, value.tag);
245
+ if (value.processStart)
246
+ readinessChildren.set(value.pid, value.processStart);
247
+ }
248
+ else if (readinessTags.get(value.pid) === value.tag) {
249
+ readinessChildren.delete(value.pid);
250
+ readinessTags.delete(value.pid);
232
251
  }
233
- else if (typeof value.processStart === "string")
234
- readinessChildren.set(Number(value.pid), value.processStart);
235
252
  });
236
253
  const result = new Promise(resolve => {
237
254
  child.once("error", error => {
@@ -256,8 +273,15 @@ export async function superviseStart(projectDir, runId, policy, selection) {
256
273
  exitCode = code ?? 2;
257
274
  try {
258
275
  const exitState = db.getRun(runId);
276
+ const exitCause = stopRequestCause ?? stopCause({ cause: "worker-exit", source: "worker", actor: "worker", runId, supervisorGeneration: generation, workerGeneration: generation, pid: child.pid, processStart: child.pid ? processStartIdentity(child.pid) : undefined, checkpoint: exitState?.checkpoint ?? "unknown", at: new Date().toISOString(), requested: false, detail: signal ? `worker exited from ${signal}` : `worker exited with code ${code ?? "unknown"}` });
259
277
  if (exitState)
260
- db.transitionSupervisor(runId, generation, { checkpoint: exitState.checkpoint, event: "worker_exit", payload: { code, signal, stopRequested: stopping, source: stopRequestSource ?? "unknown", at: new Date().toISOString(), generation } });
278
+ db.transitionSupervisor(runId, generation, { checkpoint: exitState.checkpoint, event: "worker_exit", payload: {
279
+ code, signal, stopRequested: stopping, cause: exitCause,
280
+ // Preserve the historical diagnostic field while consumers migrate
281
+ // to the closed StopCause contract.
282
+ source: exitCause.cause === "parent-sigterm" ? "parent-SIGTERM" : exitCause.cause === "user-sigint" ? "user-SIGINT" : exitCause.cause === "heartbeat-timeout" ? "heartbeat-timeout" : exitCause.cause,
283
+ at: new Date().toISOString(), generation,
284
+ } });
261
285
  // Durable records are authoritative even when the worker never sent IPC.
262
286
  const deadline = Date.now() + 5000;
263
287
  const owned = db.readinessProcesses(runId).filter(row => {
@@ -286,14 +310,36 @@ export async function superviseStart(projectDir, runId, policy, selection) {
286
310
  const workerQuiescent = await ownedGroupIsQuiescent(child.pid);
287
311
  const preparationReconciled = canRestartPreparation(db, runId);
288
312
  if (!stopping && readinessQuiescent && workerQuiescent && preparationReconciled) {
289
- db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: "preparation-reconciled", event: "worker_preparation_reconciled", payload: { generation, signal, noImplementationAuthority: true } });
313
+ const preparationCause = stopCause({
314
+ cause: "worker-exit", source: "worker", actor: "worker", runId,
315
+ supervisorGeneration: generation, workerGeneration: generation,
316
+ pid: child.pid, processStart: child.pid ? processStartIdentity(child.pid) : undefined,
317
+ checkpoint: "preparation-reconciled", at: new Date().toISOString(), requested: false,
318
+ detail: "Worker exited before acquiring implementation authority; owned process group is quiescent",
319
+ });
320
+ db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: "preparation-reconciled", event: "worker_preparation_reconciled", payload: { generation, signal, noImplementationAuthority: true, cause: preparationCause } });
290
321
  resolve({ kind: "crashed", detail: "Preparation worker exited before acquiring implementation authority; owned process group is quiescent" });
291
322
  return;
292
323
  }
293
324
  // Process death does not establish that remote side effects stopped.
294
325
  // Never replay an unacknowledged provider action automatically.
295
326
  const uncertain = [...db.unresolvedRoleDispatches(runId, "builder"), ...db.unresolvedRoleDispatches(runId, "qa")];
296
- db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted", event: "worker_reconciliation_required", payload: { generation, signal, uncertainDispatches: uncertain.map(item => item.idempotencyKey), readinessWasActive, readinessQuiescent, workerQuiescent, preparationReconciled, readinessCleanup, readinessOwnershipVerified: [...readinessChildren.values()].every(value => value !== "unavailable") } });
327
+ const reconciliationCause = stopCause({
328
+ ...(stopRequestCause ?? { runId, supervisorGeneration: generation, workerGeneration: generation }),
329
+ cause: "worker-reconciliation-required",
330
+ source: "reconciliation",
331
+ actor: "supervisor",
332
+ runId,
333
+ supervisorGeneration: generation,
334
+ workerGeneration: generation,
335
+ pid: child.pid,
336
+ processStart: child.pid ? processStartIdentity(child.pid) : undefined,
337
+ checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted",
338
+ at: new Date().toISOString(),
339
+ requested: false,
340
+ detail: "Worker stopped before all owned work had a durable terminal outcome",
341
+ });
342
+ db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted", event: "worker_reconciliation_required", payload: { generation, signal, cause: reconciliationCause, uncertainDispatches: uncertain.map(item => item.idempotencyKey), readinessWasActive, readinessQuiescent, workerQuiescent, preparationReconciled, readinessCleanup, readinessOwnershipVerified: [...readinessChildren.values()].every(value => value !== "unavailable") } });
297
343
  resolve({ kind: "waiting_for_human" });
298
344
  }
299
345
  else {
@@ -318,8 +364,8 @@ export async function superviseStart(projectDir, runId, policy, selection) {
318
364
  return { pid: child.pid, result, stop };
319
365
  },
320
366
  });
321
- const stopInt = () => { stopRequestSource = "parent-SIGINT"; supervisor.requestStop(); };
322
- const stopTerm = () => { stopRequestSource = "parent-SIGTERM"; supervisor.requestStop(); };
367
+ const stopInt = () => { supervisor.requestStop("user-sigint"); };
368
+ const stopTerm = () => { supervisor.requestStop("parent-sigterm"); };
323
369
  process.once("SIGINT", stopInt);
324
370
  process.once("SIGTERM", stopTerm);
325
371
  let supervisorCompleted = false;
@@ -331,12 +377,13 @@ export async function superviseStart(projectDir, runId, policy, selection) {
331
377
  const run = db.getRun(runId);
332
378
  if (!state || state.pid !== process.pid || state.status !== "running" || !run || ["completed", "cancelled", "superseded"].includes(run.status))
333
379
  return;
334
- db.putSupervisorState(runId, { ...state, status: "failed", pid: undefined, workerPid: undefined, heartbeatAt: new Date().toISOString() });
380
+ const cause = stopCause({ cause: "supervisor-process-exit", source: "supervisor", actor: "supervisor", runId, supervisorGeneration: state.generation, workerGeneration: state.workerGeneration, pid: process.pid, processStart: state.processStart, checkpoint: run.checkpoint, at: new Date().toISOString(), requested: false, detail: `supervisor exited with code ${code}` });
381
+ db.putSupervisorState(runId, { ...state, status: "failed", pid: undefined, workerPid: undefined, heartbeatAt: new Date().toISOString(), stopCause: cause });
335
382
  db.transitionSupervisor(runId, state.workerGeneration, {
336
383
  status: "paused",
337
384
  checkpoint: "supervisor-process-exit",
338
385
  event: "supervisor_process_exit",
339
- payload: { exitCode: code, lastCheckpoint: run.checkpoint, generation: state.generation },
386
+ payload: { exitCode: code, lastCheckpoint: run.checkpoint, generation: state.generation, cause },
340
387
  });
341
388
  }
342
389
  catch { /* Exit handlers must not mask the original process termination. */ }
@@ -1,5 +1,5 @@
1
1
  import type { BuildAdmission } from "./buildAdmission.js";
2
- import type { ResolvedAutonomyPolicy, SupervisorState } from "rafi-spec";
2
+ import { type ResolvedAutonomyPolicy, type StopCause, type StopCauseName, type SupervisorState } from "rafi-spec";
3
3
  export type WorkerOutcome = {
4
4
  kind: "completed";
5
5
  } | {
@@ -18,6 +18,19 @@ export interface SupervisorWorkerHandle {
18
18
  result: Promise<WorkerOutcome>;
19
19
  stop: () => Promise<void> | void;
20
20
  }
21
+ /**
22
+ * Retry only the short synchronous persistence operation. The caller owns
23
+ * the retry budget and remains responsible for deciding whether ownership is
24
+ * safe after the budget is exhausted.
25
+ */
26
+ export declare function persistWithBoundedRetry(persist: () => void, sleep: (milliseconds: number) => Promise<void>, attempts?: number): Promise<{
27
+ ok: true;
28
+ attempts: number;
29
+ } | {
30
+ ok: false;
31
+ attempts: number;
32
+ error: unknown;
33
+ }>;
21
34
  export interface DurableSupervisorOptions {
22
35
  projectDir: string;
23
36
  runId: string;
@@ -27,16 +40,19 @@ export interface DurableSupervisorOptions {
27
40
  checkpoint: () => string;
28
41
  sleep?: (milliseconds: number) => Promise<void>;
29
42
  now?: () => Date;
43
+ onStopRequested?: (cause: StopCause) => void;
44
+ heartbeatIntervalMs?: number;
30
45
  }
31
46
  /** Durable worker restart loop. The supervisor never owns the project's mutation lease. */
32
47
  export declare class DurableSupervisor {
33
48
  private readonly options;
34
49
  private stopRequested;
50
+ private requestedStopCause?;
35
51
  private active?;
36
52
  private readonly sleep;
37
53
  private readonly now;
38
54
  constructor(options: DurableSupervisorOptions);
39
- requestStop(): void;
55
+ requestStop(cause?: StopCauseName): void;
40
56
  run(): Promise<WorkerOutcome>;
41
57
  }
42
58
  export declare function initialSupervisorState(): SupervisorState;