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.
- package/README.md +1 -1
- package/dist/adapters/claude.d.ts +23 -12
- package/dist/adapters/claude.js +228 -24
- package/dist/adapters/codex.d.ts +17 -7
- package/dist/adapters/codex.js +195 -17
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +5 -1
- package/dist/adapters/structured.d.ts +71 -0
- package/dist/adapters/structured.js +213 -0
- package/dist/adapters/types.d.ts +86 -9
- package/dist/agentRun.d.ts +2 -0
- package/dist/agentRun.js +2 -1
- package/dist/boundedTurnEvidence.d.ts +18 -0
- package/dist/boundedTurnEvidence.js +76 -0
- package/dist/branch/currentGuard.d.ts +2 -0
- package/dist/branch/currentGuard.js +2 -0
- package/dist/branch/planner.js +1 -4
- package/dist/branch/runner.d.ts +2 -1
- package/dist/branch/runner.js +10 -7
- package/dist/buildAdmission.js +4 -0
- package/dist/buildApproval.d.ts +30 -0
- package/dist/buildApproval.js +68 -0
- package/dist/buildAssignment.d.ts +1 -0
- package/dist/buildAssignment.js +82 -23
- package/dist/buildInterventions.d.ts +2 -2
- package/dist/buildInterventions.js +11 -4
- package/dist/buildLaunchGate.js +3 -2
- package/dist/builderGuidanceFollowup.js +17 -6
- package/dist/cli/manager.js +22 -20
- package/dist/cli/start.js +32 -24
- package/dist/cli/tickets.js +40 -24
- package/dist/continuity.d.ts +10 -14
- package/dist/continuity.js +198 -179
- package/dist/continuityOrigin.d.ts +13 -0
- package/dist/continuityOrigin.js +91 -0
- package/dist/foreman.d.ts +20 -5
- package/dist/foreman.js +57 -187
- package/dist/graph/boundary.d.ts +1 -1
- package/dist/graph/boundary.js +1 -1
- package/dist/graph/host.js +8 -19
- package/dist/graph/manager.d.ts +1 -1
- package/dist/graph/manager.js +1 -1
- package/dist/graph/preparation.d.ts +38 -0
- package/dist/graph/preparation.js +7 -6
- package/dist/graph/semantic.d.ts +1 -0
- package/dist/graph/semantic.js +4 -1
- package/dist/graph/session.js +7 -2
- package/dist/graph/turn.d.ts +2 -3
- package/dist/graph/turn.js +28 -40
- package/dist/handoffProtocol.js +2 -2
- package/dist/handoffs.d.ts +3 -1
- package/dist/handoffs.js +90 -59
- package/dist/hostGraphApplicability.d.ts +46 -0
- package/dist/hostGraphApplicability.js +97 -0
- package/dist/hostQaAugmentation.d.ts +18 -0
- package/dist/hostQaAugmentation.js +81 -0
- package/dist/hostQaComparison.d.ts +21 -2
- package/dist/hostQaComparison.js +43 -28
- package/dist/hostSourcePreservation.d.ts +38 -0
- package/dist/hostSourcePreservation.js +72 -0
- package/dist/interviews.d.ts +7 -0
- package/dist/interviews.js +18 -1
- package/dist/legacyCommunicationDisplay.d.ts +2 -0
- package/dist/legacyCommunicationDisplay.js +2 -0
- package/dist/managerPacket.js +3 -3
- package/dist/markers.d.ts +4 -3
- package/dist/markers.js +4 -20
- package/dist/probeIpc.d.ts +14 -0
- package/dist/probeIpc.js +29 -0
- package/dist/qaBuildGate.d.ts +21 -0
- package/dist/qaBuildGate.js +48 -12
- package/dist/qaBuilderCoverage.js +22 -13
- package/dist/qaBuilderEvidence.d.ts +16 -1
- package/dist/qaBuilderEvidence.js +48 -8
- package/dist/qaContractCoverage.d.ts +2 -2
- package/dist/qaContractCoverage.js +11 -7
- package/dist/qaContractDelivery.js +34 -20
- package/dist/qaCoverageAssembly.d.ts +22 -0
- package/dist/qaCoverageAssembly.js +16 -0
- package/dist/qaDeliveryJournal.d.ts +2 -0
- package/dist/qaDeliveryJournal.js +19 -0
- package/dist/qaEffectiveConfig.d.ts +1 -0
- package/dist/qaEffectiveConfig.js +6 -1
- package/dist/qaFailureDelivery.js +97 -85
- package/dist/qaHostTransformation.d.ts +26 -0
- package/dist/qaHostTransformation.js +100 -0
- package/dist/qaPreparation.d.ts +8 -20
- package/dist/qaPreparation.js +133 -229
- package/dist/qaPreparationInputs.js +19 -0
- package/dist/qaPreparationStore.js +34 -6
- package/dist/qaPrerequisites.d.ts +2 -1
- package/dist/qaPrerequisites.js +21 -1
- package/dist/qaProtocolV2.d.ts +15 -0
- package/dist/qaRecovery.js +21 -4
- package/dist/qaReview.js +232 -394
- package/dist/qaReviewEvidence.d.ts +7 -3
- package/dist/qaReviewEvidence.js +15 -4
- package/dist/qaRuntime.js +1 -1
- package/dist/qaSnapshot.js +71 -21
- package/dist/qaStructuredResponse.d.ts +10 -0
- package/dist/qaStructuredResponse.js +57 -0
- package/dist/qaTimeline.js +5 -3
- package/dist/qaToolRecovery.d.ts +12 -0
- package/dist/qaToolRecovery.js +44 -0
- package/dist/qaVerificationContract.d.ts +3 -2
- package/dist/qaVerificationContract.js +58 -34
- package/dist/readinessGate.js +5 -4
- package/dist/runtimeReadiness.js +23 -15
- package/dist/statusReporter.d.ts +2 -0
- package/dist/statusReporter.js +2 -0
- package/dist/structuredCutover.d.ts +31 -0
- package/dist/structuredCutover.js +510 -0
- package/dist/structuredCutoverFiles.d.ts +10 -0
- package/dist/structuredCutoverFiles.js +181 -0
- package/dist/structuredDispatchAuthority.d.ts +22 -0
- package/dist/structuredDispatchAuthority.js +110 -0
- package/dist/structuredDispatchRecord.d.ts +29 -0
- package/dist/structuredDispatchRecord.js +36 -0
- package/dist/structuredLiveBudget.d.ts +2 -0
- package/dist/structuredLiveBudget.js +23 -0
- package/dist/structuredOperation.d.ts +18 -0
- package/dist/structuredOperation.js +257 -0
- package/dist/supervisedStart.d.ts +1 -1
- package/dist/supervisedStart.js +78 -31
- package/dist/supervisor.d.ts +18 -2
- package/dist/supervisor.js +74 -17
- package/dist/ticketPlanning.d.ts +1 -373
- package/dist/ticketPlanning.js +2 -26
- package/dist/ticketPopulation.d.ts +0 -3
- package/dist/ticketPopulation.js +4 -21
- package/dist/ticketPopulationPageAuthority.d.ts +13 -0
- package/dist/ticketPopulationPageAuthority.js +30 -0
- package/dist/ticketPopulationPaging.d.ts +27 -0
- package/dist/ticketPopulationPaging.js +105 -0
- package/dist/tickets/renderMarkdown.d.ts +14 -0
- package/dist/tickets/renderMarkdown.js +39 -5
- package/dist/tickets/ticketLoader.js +10 -0
- package/dist/tickets/ticketSchema.d.ts +107 -0
- package/dist/tickets/ticketSchema.js +4 -1
- package/dist/workflowDb.d.ts +100 -2
- package/dist/workflowDb.js +781 -35
- package/dist/workflowReader.d.ts +1 -0
- package/dist/workflowReader.js +9 -0
- package/package.json +15 -7
package/dist/continuity.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { persistedProviderWireContract } from "rafi-spec";
|
|
1
2
|
import { dispatchWithGraphAccess, withGraphSessionAccess } from "./graph/session.js";
|
|
2
3
|
import { localBuildAuthority } from "./buildAdmission.js";
|
|
3
4
|
import { randomUUID, createHash } from "node:crypto";
|
|
4
|
-
import { continuityDeltaV1Schema, zodIssues } from "rafi-spec";
|
|
5
|
+
import { continuityDeltaV1Schema, initializationOutput, zodIssues } from "rafi-spec";
|
|
5
6
|
import { BuilderEventQueue } from "./activity.js";
|
|
6
7
|
import { providerSessionKey } from "./sessionIdentity.js";
|
|
8
|
+
import { nativePolicy, operationInstruction, journalStructuredDispatch, continuationPolicy } from "./structuredOperation.js";
|
|
7
9
|
import { WorkflowDb } from "./workflowDb.js";
|
|
10
|
+
import { assertSealedDispatch, withFreshRecoveryValidation } from "./structuredDispatchAuthority.js";
|
|
11
|
+
import { verifyPersistedDispatch } from "./structuredDispatchRecord.js";
|
|
8
12
|
export const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
|
|
9
13
|
export class ContinuityValidationError extends Error {
|
|
10
14
|
problems;
|
|
@@ -44,6 +48,7 @@ export class SessionUnavailableContinuityError extends Error {
|
|
|
44
48
|
this.guidedRecovery = guidedRecovery;
|
|
45
49
|
}
|
|
46
50
|
}
|
|
51
|
+
export const CONTINUITY_REQUIRED_INSTRUCTION = "Include the required bounded continuity object in the operation response; continuity cannot be null for this session.";
|
|
47
52
|
export function validateContinuityDelta(value) {
|
|
48
53
|
const problems = [];
|
|
49
54
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
@@ -74,20 +79,6 @@ export function validateContinuityDelta(value) {
|
|
|
74
79
|
throw new ContinuityValidationError(problems);
|
|
75
80
|
return output;
|
|
76
81
|
}
|
|
77
|
-
export function parseContinuityDelta(text) {
|
|
78
|
-
const lines = text.split(/\r?\n/);
|
|
79
|
-
const markerIndexes = lines.flatMap((line, index) => line.trimStart().startsWith(CONTINUITY_MARKER) ? [index] : []);
|
|
80
|
-
const cleanText = lines.filter((_line, index) => !markerIndexes.includes(index)).join("\n").trimEnd();
|
|
81
|
-
if (markerIndexes.length !== 1)
|
|
82
|
-
return { cleanText, error: new ContinuityValidationError([markerIndexes.length ? "multiple continuity markers" : "missing continuity marker"]) };
|
|
83
|
-
const raw = lines[markerIndexes[0]].trimStart().slice(CONTINUITY_MARKER.length).trim();
|
|
84
|
-
try {
|
|
85
|
-
return { delta: validateContinuityDelta(JSON.parse(raw)), cleanText };
|
|
86
|
-
}
|
|
87
|
-
catch (error) {
|
|
88
|
-
return { cleanText, error: error instanceof ContinuityValidationError ? error : new ContinuityValidationError([`marker is not valid JSON: ${String(error)}`]) };
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
82
|
export function mergeContinuityDeltas(deltas) {
|
|
92
83
|
const result = baselineContinuityDelta();
|
|
93
84
|
for (const delta of deltas) {
|
|
@@ -136,7 +127,7 @@ export class ContinuityAdapter {
|
|
|
136
127
|
db.ensureRun(options.runId);
|
|
137
128
|
if (!db.continuityHead(options.runId, options.role)) {
|
|
138
129
|
db.appendContinuityEvent({ runId: options.runId, role: "host", kind: "role_baseline", payload: { role: options.role, provider: options.settings.make, model: options.settings.model }, authoritativeStateRevision: this.revision() });
|
|
139
|
-
db.publishContinuityCheckpoint({ runId: options.runId, role: options.role, delta: baselineContinuityDelta(), authoritativeStateRevision: this.revision() });
|
|
130
|
+
db.publishContinuityCheckpoint({ runId: options.runId, role: options.role, delta: baselineContinuityDelta(), hostEventKind: "role-baseline", inputCheckpointDigests: [], authoritativeStateRevision: this.revision() });
|
|
140
131
|
}
|
|
141
132
|
const initialSession = options.adapter.sessionRef?.() ?? options.adapter.sessionId();
|
|
142
133
|
if (initialSession)
|
|
@@ -166,7 +157,7 @@ export class ContinuityAdapter {
|
|
|
166
157
|
}
|
|
167
158
|
this.validatingFreshRecovery = true;
|
|
168
159
|
try {
|
|
169
|
-
const result = await this.sendTurn("Fresh recovery validation only. Do not use tools, change files, implement work, or request a handoff. Acknowledge this new session and
|
|
160
|
+
const result = await withFreshRecoveryValidation(this.options.projectDir, this.options.runId, this.options.role, authorization => this.sendTurn("Fresh recovery validation only. Do not use tools, change files, implement work, or request a handoff. Acknowledge this new session and include the required continuity object. " + operationInstruction(initializationOutput), nativePolicy(initializationOutput, { responseOnly: true, journalRunId: this.options.runId, dispatchId: `fresh-recovery:${authorization.decisionDigest}` })));
|
|
170
161
|
if (result.isError || result.failure || this.recoveryLeasePending)
|
|
171
162
|
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "fresh recovery did not establish a valid continuity checkpoint");
|
|
172
163
|
}
|
|
@@ -179,13 +170,35 @@ export class ContinuityAdapter {
|
|
|
179
170
|
}
|
|
180
171
|
}
|
|
181
172
|
get agent() { return this.adapter.agent; }
|
|
173
|
+
/** Every confirmed dispatch publishes a checkpoint, so continuity is mandatory. */
|
|
174
|
+
structuredDispatchRequirement() {
|
|
175
|
+
return { instruction: CONTINUITY_REQUIRED_INSTRUCTION, validate: response => { validateContinuityDelta(response.continuity); } };
|
|
176
|
+
}
|
|
182
177
|
async sendTurn(instruction, policy) {
|
|
183
|
-
|
|
178
|
+
if (!policy?.output)
|
|
179
|
+
throw new Error("Continuity dispatch requires a native operation contract");
|
|
180
|
+
if (policy.dispatchCapability) {
|
|
181
|
+
assertSealedDispatch(this.options.projectDir, policy.dispatchCapability, policy.selectedDispatch, instruction);
|
|
182
|
+
return this.sendTurnOwned(instruction, policy);
|
|
183
|
+
}
|
|
184
|
+
// The continuity requirement is applied by journalStructuredDispatch through
|
|
185
|
+
// structuredDispatchRequirement(), before sealing, on every dispatch path.
|
|
186
|
+
const hostInstruction = instruction;
|
|
187
|
+
return withGraphSessionAccess(this.options.projectDir, this.adapter, () => policy?.output
|
|
188
|
+
? journalStructuredDispatch(this.options.projectDir, this, instruction, { ...policy, journalRunId: policy.journalRunId ?? this.options.runId }, (text, selected) => this.sendTurnOwned(text, selected))
|
|
189
|
+
: this.sendTurnOwned(instruction, policy)).then(result => {
|
|
190
|
+
if (result.failure?.category === "session-unavailable") {
|
|
191
|
+
this.recordSessionUnavailable(result.failure, hostInstruction);
|
|
192
|
+
throw new SessionUnavailableContinuityError(this.options.runId, this.options.role, result.failure);
|
|
193
|
+
}
|
|
194
|
+
return { ...result, hostInstruction };
|
|
195
|
+
});
|
|
184
196
|
}
|
|
185
197
|
async sendTurnOwned(instruction, policy) {
|
|
186
|
-
const providerInstruction =
|
|
198
|
+
const providerInstruction = instruction;
|
|
187
199
|
const journal = new WorkflowDb(this.options.projectDir);
|
|
188
|
-
const dispatchId = `dispatch:${this.options.runId}:${this.options.role}:${randomUUID()}`;
|
|
200
|
+
const dispatchId = policy?.dispatchId ?? `dispatch:${this.options.runId}:${this.options.role}:${randomUUID()}`;
|
|
201
|
+
const beforeSession = this.adapter.sessionRef?.();
|
|
189
202
|
let original;
|
|
190
203
|
try {
|
|
191
204
|
journal.atomic(() => {
|
|
@@ -204,22 +217,32 @@ export class ContinuityAdapter {
|
|
|
204
217
|
if (!(this.validatingFreshRecovery && this.recoveryLeasePending && policy?.responseOnly) && owner && session && (owner.providerSessionId !== session || (owner.sessionKey && ref && owner.sessionKey !== providerSessionKey(ref)))) {
|
|
205
218
|
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "provider session no longer owns role dispatch authority");
|
|
206
219
|
}
|
|
207
|
-
if (journal.unresolvedRoleDispatches(this.options.runId, this.options.role).length)
|
|
220
|
+
if (journal.unresolvedRoleDispatches(this.options.runId, this.options.role).filter(item => item.idempotencyKey !== dispatchId).length)
|
|
208
221
|
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "a previous dispatched turn has no durable completion; reconcile it before sending more work");
|
|
209
|
-
|
|
210
|
-
|
|
222
|
+
if (!policy?.selectedDispatch)
|
|
223
|
+
journal.planOperation({ runId: this.options.runId, idempotencyKey: dispatchId, kind: "provider-dispatch", intent: { role: this.options.role, sessionRef: this.adapter.sessionRef?.(), instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) } });
|
|
224
|
+
if (!policy?.selectedDispatch)
|
|
225
|
+
journal.updateOperation(dispatchId, "in_progress");
|
|
211
226
|
journal.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "turn_started", payload: { role: this.options.role, dispatchId, instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) }, authoritativeStateRevision: this.revision() });
|
|
212
227
|
});
|
|
213
228
|
try {
|
|
214
229
|
original = await dispatchWithGraphAccess(this.options.projectDir, this.adapter, providerInstruction, policy, (text, p) => this.adapter.sendTurn(text, p));
|
|
215
230
|
}
|
|
216
231
|
catch (error) {
|
|
217
|
-
|
|
232
|
+
if (!policy?.selectedDispatch)
|
|
233
|
+
journal.updateOperation(dispatchId, "uncertain", { error: String(error) });
|
|
218
234
|
throw error;
|
|
219
235
|
}
|
|
220
|
-
|
|
236
|
+
// Preserve the native transport result before interpreting it or publishing
|
|
237
|
+
// a checkpoint. The outer journal remains the sole completion owner.
|
|
238
|
+
// journalStructuredDispatch owns the one retained raw receipt for native
|
|
239
|
+
// turns. Continuity records retain only its durable reference/fact.
|
|
240
|
+
const responseDigest = policy?.selectedDispatch ? undefined : journal.putEvidence("handoff", Buffer.from(JSON.stringify(original)));
|
|
221
241
|
journal.atomic(() => {
|
|
222
|
-
journal.
|
|
242
|
+
journal.appendContinuityEvent({ runId: this.options.runId, role: this.options.role, kind: "provider_response_received",
|
|
243
|
+
payload: { dispatchId, ...(responseDigest ? { responseDigest } : { journalOwnedEvidence: true }) }, authoritativeStateRevision: this.revision() });
|
|
244
|
+
if (!policy?.selectedDispatch)
|
|
245
|
+
journal.updateOperation(dispatchId, original.failure?.dispatchState === "unknown" ? "uncertain" : original.failure?.dispatchState === "not-sent" ? "failed" : "confirmed", { result: { responseDigest, isError: original.isError, failure: original.failure, turnId: original.turnId, sessionRef: this.adapter.sessionRef?.() } });
|
|
223
246
|
if (original.failure?.dispatchState === "not-sent")
|
|
224
247
|
journal.appendContinuityEvent({ runId: this.options.runId, role: this.options.role, kind: "turn_not_sent", payload: { dispatchId, role: this.options.role }, authoritativeStateRevision: this.revision() });
|
|
225
248
|
});
|
|
@@ -227,152 +250,162 @@ export class ContinuityAdapter {
|
|
|
227
250
|
finally {
|
|
228
251
|
journal.close();
|
|
229
252
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
253
|
+
return original;
|
|
254
|
+
}
|
|
255
|
+
async postConfirmedDispatch(reference) {
|
|
256
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
257
|
+
let original;
|
|
258
|
+
let context;
|
|
259
|
+
let response;
|
|
260
|
+
try {
|
|
261
|
+
const operation = db.operation(reference.dispatchId);
|
|
262
|
+
if (operation?.runId !== this.options.runId || reference.runId !== this.options.runId || db.structuredOwnerDispatch(reference.dispatchId).tupleDigest !== reference.tupleDigest)
|
|
263
|
+
throw new Error("Continuity postprocessing belongs to a foreign dispatch");
|
|
264
|
+
original = db.retainedStructuredTurn(reference.dispatchId);
|
|
265
|
+
context = db.structuredProcessingContext(reference.dispatchId);
|
|
266
|
+
const tuple = original.selectedDispatch;
|
|
267
|
+
const contract = persistedProviderWireContract(tuple).canonical;
|
|
268
|
+
response = db.replayStructuredDispatch(reference.dispatchId, contract);
|
|
238
269
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
270
|
+
finally {
|
|
271
|
+
db.close();
|
|
272
|
+
}
|
|
273
|
+
const delta = validateContinuityDelta(response.continuity);
|
|
274
|
+
if (this.recoveryLeasePending && (response.result.kind !== "completed" || !response.result.payload.ready))
|
|
275
|
+
throw new Error("Fresh recovery requires a completed initialization checkpoint");
|
|
276
|
+
if (!context.policy.handback) {
|
|
277
|
+
this.publish(delta, "turn_completed", original);
|
|
278
|
+
}
|
|
279
|
+
if (response.result.kind === "handoff-request") {
|
|
280
|
+
if (context.policy.handback || this.options.durableSingleTurn || this.validatingFreshRecovery)
|
|
281
|
+
throw new Error("This operation cannot transfer its session");
|
|
282
|
+
// Publication is complete before the explicitly linked acceptance stage.
|
|
283
|
+
const completed = new WorkflowDb(this.options.projectDir);
|
|
242
284
|
try {
|
|
243
|
-
|
|
285
|
+
completed.beginStructuredHandoff(reference.dispatchId);
|
|
286
|
+
completed.completeStructuredPostprocessing(reference.dispatchId);
|
|
244
287
|
}
|
|
245
288
|
finally {
|
|
246
|
-
|
|
289
|
+
completed.close();
|
|
247
290
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
this.moveRecoveryLeaseAfterCheckpoint();
|
|
259
|
-
if (this.options.durableSingleTurn || this.validatingFreshRecovery) {
|
|
260
|
-
return { ...original, text: parsed.cleanText, hostInstruction: instruction, providerInstruction: original.providerInstruction ?? providerInstruction,
|
|
261
|
-
rawResponse: original.rawResponse ?? original.text, cleanedResponse: parsed.cleanText };
|
|
291
|
+
const successor = await this.options.handleHandoffRequest?.({ version: 1, reason: response.result.reason, roleState: response.result.roleState, delta, predecessorDispatchId: reference.dispatchId }, this.adapter, context.instruction);
|
|
292
|
+
if (!successor || successor === this.adapter)
|
|
293
|
+
throw new Error("Handoff request did not establish a validated successor");
|
|
294
|
+
const continuation = new WorkflowDb(this.options.projectDir);
|
|
295
|
+
let continuationId;
|
|
296
|
+
try {
|
|
297
|
+
const session = successor.sessionRef?.();
|
|
298
|
+
if (!session)
|
|
299
|
+
throw new Error("Handoff successor has no scoped session");
|
|
300
|
+
continuationId = continuation.planStructuredContinuation(reference.dispatchId, session);
|
|
262
301
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
await this.adoptValidatedSuccessor(successor);
|
|
266
|
-
return this.sendTurn([
|
|
267
|
-
"Continue the frozen action after the accepted Rafi handoff. Do not repeat completed side effects.",
|
|
268
|
-
instruction,
|
|
269
|
-
].join("\n\n"), policy);
|
|
302
|
+
finally {
|
|
303
|
+
continuation.close();
|
|
270
304
|
}
|
|
271
|
-
|
|
272
|
-
|
|
305
|
+
await this.adoptValidatedSuccessor(successor);
|
|
306
|
+
const tuple = original.selectedDispatch;
|
|
307
|
+
const output = persistedProviderWireContract(tuple).canonical;
|
|
308
|
+
return this.sendTurn("Continue the frozen action after the accepted handoff. Reconcile receipts; do not repeat completed side effects.\n" + context.instruction, nativePolicy(output, { ...continuationPolicy(context.policy, reference.dispatchId), dispatchId: continuationId }));
|
|
273
309
|
}
|
|
274
|
-
|
|
275
|
-
|
|
310
|
+
return { ...original, hostInstruction: context.instruction, continuityErrors: [], continuity: delta };
|
|
311
|
+
}
|
|
312
|
+
observeEvents(listener) {
|
|
313
|
+
if (!this.adapter.observeEvents)
|
|
314
|
+
throw new Error("wrapped provider does not expose turn observation");
|
|
315
|
+
return this.adapter.observeEvents(listener);
|
|
316
|
+
}
|
|
317
|
+
acceptHandbackTurn(turn) {
|
|
318
|
+
if (turn.structured) {
|
|
319
|
+
if (turn.isError || turn.failure || turn.structured.state !== "candidate")
|
|
320
|
+
throw new Error("cannot publish an invalid handback checkpoint");
|
|
321
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
322
|
+
let retained;
|
|
276
323
|
try {
|
|
277
|
-
|
|
324
|
+
if (!turn.selectedDispatch || db.operation(turn.selectedDispatch.dispatchId)?.runId !== this.options.runId)
|
|
325
|
+
throw new Error("Foreign handback dispatch");
|
|
326
|
+
retained = db.retainedStructuredTurn(turn.selectedDispatch.dispatchId);
|
|
278
327
|
}
|
|
279
328
|
finally {
|
|
280
|
-
|
|
329
|
+
db.close();
|
|
281
330
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
331
|
+
const tuple = retained.selectedDispatch;
|
|
332
|
+
const owner = new WorkflowDb(this.options.projectDir);
|
|
333
|
+
try {
|
|
334
|
+
const response = verifyPersistedDispatch(persistedProviderWireContract(tuple).canonical, owner.structuredOwnerDispatch(tuple.dispatchId), retained.structured, tuple);
|
|
335
|
+
this.publish(validateContinuityDelta(response.continuity), "handback_turn_completed", retained);
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
owner.close();
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
285
341
|
}
|
|
286
|
-
|
|
342
|
+
throw new Error("Handback acceptance requires a native receipt and validated continuity");
|
|
343
|
+
}
|
|
344
|
+
/** Explicit restart recovery of the already accepted action; never repeats acceptance. */
|
|
345
|
+
async resumePlannedContinuation() {
|
|
346
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
347
|
+
let continuation;
|
|
348
|
+
let context;
|
|
349
|
+
let output;
|
|
287
350
|
try {
|
|
288
|
-
|
|
289
|
-
if (!
|
|
290
|
-
|
|
351
|
+
continuation = db.structuredContinuation(this.options.runId, this.options.role);
|
|
352
|
+
if (!continuation)
|
|
353
|
+
return;
|
|
354
|
+
const operation = db.operation(continuation.continuationId);
|
|
355
|
+
if (operation?.status === "confirmed") {
|
|
356
|
+
if (db.pendingStructuredPostprocessing(this.options.runId).includes(continuation.continuationId)) {
|
|
357
|
+
await this.postConfirmedDispatch({ dispatchId: continuation.continuationId, runId: this.options.runId,
|
|
358
|
+
tupleDigest: db.structuredOwnerDispatch(continuation.continuationId).tupleDigest });
|
|
359
|
+
db.completeStructuredPostprocessing(continuation.continuationId);
|
|
360
|
+
db.finishStructuredContinuation(continuation.continuationId);
|
|
361
|
+
}
|
|
362
|
+
const turn = db.retainedStructuredTurn(continuation.continuationId);
|
|
363
|
+
const descriptor = persistedProviderWireContract(turn.selectedDispatch);
|
|
364
|
+
const response = db.replayStructuredDispatch(continuation.continuationId, descriptor.canonical);
|
|
365
|
+
return { ...turn, continuity: validateContinuityDelta(response.continuity) };
|
|
291
366
|
}
|
|
367
|
+
if (continuation.state !== "continuation-planned" || operation && operation.status !== "planned")
|
|
368
|
+
throw new Error("Continuation may already have been submitted; explicit reconciliation is required");
|
|
369
|
+
const current = this.adapter.sessionRef?.();
|
|
370
|
+
if (!current || providerSessionKey(current) !== providerSessionKey(continuation.successor))
|
|
371
|
+
throw new Error("Reattach the durably accepted successor before continuation recovery");
|
|
372
|
+
context = db.structuredProcessingContext(continuation.predecessorId);
|
|
373
|
+
const predecessor = db.retainedStructuredTurn(continuation.predecessorId);
|
|
374
|
+
output = persistedProviderWireContract(predecessor.selectedDispatch).canonical;
|
|
292
375
|
}
|
|
293
376
|
finally {
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
const repair = await dispatchWithGraphAccess(this.options.projectDir, this.adapter, [
|
|
297
|
-
"Continuity protocol repair only. Do not run tools, repeat work, or change files.",
|
|
298
|
-
`Your prior turn's continuity record was invalid: ${parsed.error?.problems.join("; ")}.`,
|
|
299
|
-
continuityInstruction(),
|
|
300
|
-
"Return only the continuity record.",
|
|
301
|
-
].join("\n"), { ...policy, purpose: "response-repair", responseOnly: true }, (text, p) => this.adapter.sendTurn(text, p));
|
|
302
|
-
const repaired = parseContinuityDelta(repair.text);
|
|
303
|
-
if (repaired.delta && !repair.isError && !repair.failure) {
|
|
304
|
-
this.publish(repaired.delta, "turn_completed_after_repair", original);
|
|
305
|
-
this.moveRecoveryLeaseAfterCheckpoint();
|
|
306
|
-
const successor = await this.options.handleHandoffRequest?.(original.text, this.adapter, instruction);
|
|
307
|
-
if (successor && successor !== this.adapter) {
|
|
308
|
-
await this.adoptValidatedSuccessor(successor);
|
|
309
|
-
return this.sendTurn(["Continue the frozen action after the accepted Rafi handoff. Do not repeat completed side effects.", instruction].join("\n\n"), policy);
|
|
310
|
-
}
|
|
311
|
-
return { ...original, text: parsed.cleanText, hostInstruction: instruction, providerInstruction: original.providerInstruction ?? providerInstruction,
|
|
312
|
-
rawResponse: original.rawResponse ?? original.text, cleanedResponse: parsed.cleanText };
|
|
377
|
+
db.close();
|
|
313
378
|
}
|
|
314
|
-
|
|
379
|
+
return this.sendTurn("Continue the frozen action after the accepted handoff. Reconcile receipts; do not repeat completed side effects.\n" + context.instruction, nativePolicy(output, { ...continuationPolicy(context.policy, continuation.predecessorId), dispatchId: continuation.continuationId }));
|
|
380
|
+
}
|
|
381
|
+
async recoverConfirmedPublications() {
|
|
382
|
+
const db = new WorkflowDb(this.options.projectDir);
|
|
383
|
+
let pending;
|
|
315
384
|
try {
|
|
316
|
-
|
|
317
|
-
invalidDb.setContinuityHeadState(this.options.runId, this.options.role, "invalid");
|
|
385
|
+
pending = db.pendingStructuredPostprocessing(this.options.runId);
|
|
318
386
|
}
|
|
319
387
|
finally {
|
|
320
|
-
|
|
388
|
+
db.close();
|
|
321
389
|
}
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
let
|
|
390
|
+
for (const dispatchId of pending) {
|
|
391
|
+
const current = new WorkflowDb(this.options.projectDir);
|
|
392
|
+
let reference;
|
|
325
393
|
try {
|
|
326
|
-
|
|
327
|
-
reason: "continuity delta and same-session repair were both invalid",
|
|
328
|
-
reconstruction: handoff,
|
|
329
|
-
predecessor: this.adapter,
|
|
330
|
-
});
|
|
394
|
+
reference = { dispatchId, runId: this.options.runId, tupleDigest: current.structuredOwnerDispatch(dispatchId).tupleDigest };
|
|
331
395
|
}
|
|
332
|
-
|
|
333
|
-
|
|
396
|
+
finally {
|
|
397
|
+
current.close();
|
|
334
398
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
399
|
+
await this.postConfirmedDispatch(reference);
|
|
400
|
+
const completed = new WorkflowDb(this.options.projectDir);
|
|
401
|
+
try {
|
|
402
|
+
completed.completeStructuredPostprocessing(dispatchId);
|
|
403
|
+
completed.finishStructuredContinuation(dispatchId);
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
completed.close();
|
|
338
407
|
}
|
|
339
|
-
await this.adoptValidatedSuccessor(successor);
|
|
340
|
-
return { ...original, text: parsed.cleanText, hostInstruction: instruction, providerInstruction,
|
|
341
|
-
rawResponse: original.rawResponse ?? original.text, cleanedResponse: parsed.cleanText };
|
|
342
|
-
}
|
|
343
|
-
if (!this.options.createSuccessor)
|
|
344
|
-
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "continuity repair failed in the original session and no validated successor is configured");
|
|
345
|
-
const successor = await this.options.createSuccessor(handoff);
|
|
346
|
-
const accepted = await dispatchWithGraphAccess(this.options.projectDir, successor, `${handoff}\n\nReply with HANDOFF_ACCEPTED on the first line, then ${continuityInstruction()}`, { purpose: "contract-acceptance", responseOnly: true }, (text, p) => successor.sendTurn(text, p));
|
|
347
|
-
const successorDelta = parseContinuityDelta(accepted.text);
|
|
348
|
-
const firstAcceptanceLine = accepted.text.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
349
|
-
if (firstAcceptanceLine !== "HANDOFF_ACCEPTED" || !successorDelta.delta || !successor.sessionId()) {
|
|
350
|
-
await successor.close().catch(() => { });
|
|
351
|
-
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "fresh successor did not validate and accept the cumulative checkpoint");
|
|
352
|
-
}
|
|
353
|
-
await this.adoptValidatedSuccessor(successor);
|
|
354
|
-
this.publish(successorDelta.delta, "fresh_successor_accepted", accepted);
|
|
355
|
-
const leaseDb = new WorkflowDb(this.options.projectDir);
|
|
356
|
-
try {
|
|
357
|
-
leaseDb.moveRoleLeaseAfterValidatedRecovery(this.options.runId, this.options.role, successor.sessionRef?.() ?? successor.sessionId(), "double-invalid continuity recovery");
|
|
358
|
-
}
|
|
359
|
-
finally {
|
|
360
|
-
leaseDb.close();
|
|
361
408
|
}
|
|
362
|
-
return { ...original, text: parsed.cleanText, hostInstruction: instruction, providerInstruction,
|
|
363
|
-
rawResponse: original.rawResponse ?? original.text, cleanedResponse: parsed.cleanText };
|
|
364
|
-
}
|
|
365
|
-
observeEvents(listener) {
|
|
366
|
-
if (!this.adapter.observeEvents)
|
|
367
|
-
throw new Error("wrapped provider does not expose turn observation");
|
|
368
|
-
return this.adapter.observeEvents(listener);
|
|
369
|
-
}
|
|
370
|
-
acceptHandbackTurn(turn) {
|
|
371
|
-
const parsed = parseContinuityDelta(turn.rawResponse ?? turn.text);
|
|
372
|
-
if (turn.isError || turn.failure || !parsed.delta)
|
|
373
|
-
throw new Error("cannot publish an invalid handback checkpoint");
|
|
374
|
-
this.publish(parsed.delta, "handback_turn_completed", turn);
|
|
375
|
-
this.moveRecoveryLeaseAfterCheckpoint();
|
|
376
409
|
}
|
|
377
410
|
sessionId() { return this.adapter.sessionId(); }
|
|
378
411
|
sessionRef() { return this.adapter.sessionRef?.(); }
|
|
@@ -422,30 +455,32 @@ export class ContinuityAdapter {
|
|
|
422
455
|
const db = new WorkflowDb(this.options.projectDir);
|
|
423
456
|
try {
|
|
424
457
|
const sessionRef = this.adapter.sessionRef?.();
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
458
|
+
let moved = false;
|
|
459
|
+
const checkpoint = db.atomic(() => {
|
|
460
|
+
if (this.recoveryLeasePending && sessionRef)
|
|
461
|
+
db.assertFreshRecoveryValidation(result.selectedDispatch.dispatchId, sessionRef);
|
|
462
|
+
const prior = db.providerCheckpoint(result.selectedDispatch.dispatchId, kind);
|
|
463
|
+
if (prior) {
|
|
464
|
+
if (JSON.stringify(prior.delta) !== JSON.stringify(delta))
|
|
465
|
+
throw new Error("Conflicting repeated continuity publication");
|
|
466
|
+
return prior;
|
|
467
|
+
}
|
|
468
|
+
db.appendContinuityEvent({ runId: this.options.runId, role: this.options.role, kind, payload: { dispatchId: result.selectedDispatch.dispatchId, isError: result.isError, numTurns: result.numTurns, sessionId: this.adapter.sessionId(), sessionRef, delta }, authoritativeStateRevision: this.revision(), sessionRef });
|
|
469
|
+
const published = db.publishContinuityCheckpoint({ runId: this.options.runId, role: this.options.role, delta, authoritativeStateRevision: this.revision(), sessionRef, origin: { kind: "provider", dispatchId: result.selectedDispatch.dispatchId, publicationKind: kind } });
|
|
470
|
+
if (this.recoveryLeasePending && sessionRef) {
|
|
471
|
+
db.moveRoleLeaseAfterValidatedRecovery(this.options.runId, this.options.role, sessionRef, "explicit fresh-recovery-only continuation");
|
|
472
|
+
moved = true;
|
|
473
|
+
}
|
|
474
|
+
return published;
|
|
428
475
|
});
|
|
476
|
+
if (moved)
|
|
477
|
+
this.recoveryLeasePending = false;
|
|
478
|
+
return checkpoint;
|
|
429
479
|
}
|
|
430
480
|
finally {
|
|
431
481
|
db.close();
|
|
432
482
|
}
|
|
433
483
|
}
|
|
434
|
-
moveRecoveryLeaseAfterCheckpoint() {
|
|
435
|
-
if (!this.recoveryLeasePending)
|
|
436
|
-
return;
|
|
437
|
-
const session = this.adapter.sessionRef?.() ?? this.adapter.sessionId();
|
|
438
|
-
if (!session)
|
|
439
|
-
return;
|
|
440
|
-
const db = new WorkflowDb(this.options.projectDir);
|
|
441
|
-
try {
|
|
442
|
-
db.moveRoleLeaseAfterValidatedRecovery(this.options.runId, this.options.role, session, "explicit fresh-recovery-only continuation");
|
|
443
|
-
}
|
|
444
|
-
finally {
|
|
445
|
-
db.close();
|
|
446
|
-
}
|
|
447
|
-
this.recoveryLeasePending = false;
|
|
448
|
-
}
|
|
449
484
|
recordSessionUnavailable(failure, instruction) {
|
|
450
485
|
const sessionRef = this.adapter.sessionRef?.();
|
|
451
486
|
const db = new WorkflowDb(this.options.projectDir);
|
|
@@ -473,22 +508,6 @@ export class ContinuityAdapter {
|
|
|
473
508
|
db.close();
|
|
474
509
|
}
|
|
475
510
|
}
|
|
476
|
-
reconstructionHandoff() {
|
|
477
|
-
const db = new WorkflowDb(this.options.projectDir);
|
|
478
|
-
try {
|
|
479
|
-
const checkpoints = db.continuityCheckpoints(this.options.runId, this.options.role);
|
|
480
|
-
const events = db.continuityEvents(this.options.runId, checkpoints.at(-1)?.sequence ?? 0);
|
|
481
|
-
return [
|
|
482
|
-
`Validated cumulative recovery for run ${this.options.runId}, role ${this.options.role}.`,
|
|
483
|
-
JSON.stringify(mergeContinuityDeltas(checkpoints.map((checkpoint) => checkpoint.delta))),
|
|
484
|
-
`Later host facts: ${JSON.stringify(events.map((event) => ({ kind: event.kind, payload: event.payload, digest: event.digest })))}`,
|
|
485
|
-
"The prior in-flight operation is unknown unless a host fact proves completion. Reconcile receipts before retrying side effects.",
|
|
486
|
-
].join("\n\n");
|
|
487
|
-
}
|
|
488
|
-
finally {
|
|
489
|
-
db.close();
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
511
|
revision() { return this.options.authoritativeStateRevision?.() ?? this.options.settings.settings_revision; }
|
|
493
512
|
pumpEvents() {
|
|
494
513
|
const source = this.adapter;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
export type CheckpointOrigin = {
|
|
3
|
+
kind: "provider";
|
|
4
|
+
dispatchId: string;
|
|
5
|
+
publicationKind: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "host";
|
|
8
|
+
eventSequence: number;
|
|
9
|
+
inputCheckpointDigests: string[];
|
|
10
|
+
};
|
|
11
|
+
/** Read-only replay: never opens a writer or migrates storage. */
|
|
12
|
+
export declare function verifyCheckpointOrigin(db: Database.Database, projectDir: string, checkpointId: number, visited?: Set<number>): void;
|
|
13
|
+
export declare function verifyContinuityHeads(db: Database.Database, projectDir: string, runId: string): void;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { persistedProviderWireContract } from "rafi-spec";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { canonicalJson } from "rafi-spec";
|
|
4
|
+
import { graphDerivedAllowed } from "./graph/derived.js";
|
|
5
|
+
import { hydrateStructuredReceipt, persistedDispatchTuple, verifyPersistedDispatch } from "./structuredDispatchRecord.js";
|
|
6
|
+
/** Read-only replay: never opens a writer or migrates storage. */
|
|
7
|
+
export function verifyCheckpointOrigin(db, projectDir, checkpointId, visited = new Set()) {
|
|
8
|
+
if (visited.has(checkpointId))
|
|
9
|
+
throw new Error("Cyclic checkpoint provenance");
|
|
10
|
+
visited.add(checkpointId);
|
|
11
|
+
const checkpoint = db.prepare("SELECT * FROM continuity_checkpoints WHERE checkpoint_id=?").get(checkpointId);
|
|
12
|
+
const row = db.prepare("SELECT origin_json,delta_digest FROM continuity_checkpoint_origins WHERE checkpoint_id=?").get(checkpointId);
|
|
13
|
+
if (!checkpoint || !row || hash(checkpoint.delta_json) !== row.delta_digest)
|
|
14
|
+
throw new Error("Checkpoint has no verified provenance");
|
|
15
|
+
const origin = JSON.parse(row.origin_json);
|
|
16
|
+
if (origin.kind === "provider") {
|
|
17
|
+
const operation = db.prepare("SELECT * FROM operation_journal WHERE idempotency_key=?").get(origin.dispatchId);
|
|
18
|
+
if (!operation || operation.run_id !== checkpoint.run_id || operation.status !== "confirmed")
|
|
19
|
+
throw new Error("Checkpoint origin is not a confirmed dispatch in this run");
|
|
20
|
+
const intent = JSON.parse(operation.intent_json), result = JSON.parse(operation.result_json);
|
|
21
|
+
const tuple = persistedDispatchTuple(intent.sealedDispatch);
|
|
22
|
+
if (canonicalJson(intent.tuple) !== intent.sealedDispatch.tupleJson || tuple.dispatchId !== origin.dispatchId || intent.sessionRef?.role !== checkpoint.role)
|
|
23
|
+
throw new Error("Checkpoint origin tuple/role mismatch");
|
|
24
|
+
const frozen = intent.sessionRef;
|
|
25
|
+
if (!frozen || frozen.sessionId !== tuple.sessionId || frozen.provider !== tuple.provider || frozen.generation !== tuple.generation)
|
|
26
|
+
throw new Error("Checkpoint origin frozen session mismatch");
|
|
27
|
+
const review = db.prepare("SELECT intent_json FROM qa_turns WHERE operation_id=?").get(origin.dispatchId);
|
|
28
|
+
if (review && canonicalJson(JSON.parse(review.intent_json).sealedDispatch) !== canonicalJson(intent.sealedDispatch))
|
|
29
|
+
throw new Error("Checkpoint QA owner differs from transport intent");
|
|
30
|
+
const delivery = db.prepare("SELECT record_json FROM qa_delivery_turns WHERE turn_record_id=?").get(origin.dispatchId);
|
|
31
|
+
if (delivery && canonicalJson(JSON.parse(delivery.record_json).sealedDispatch) !== canonicalJson(intent.sealedDispatch))
|
|
32
|
+
throw new Error("Checkpoint remediation owner differs from transport intent");
|
|
33
|
+
const descriptor = persistedProviderWireContract(tuple);
|
|
34
|
+
if (!descriptor || result.validation !== "accepted")
|
|
35
|
+
throw new Error("Checkpoint origin descriptor/validation mismatch");
|
|
36
|
+
const read = (digest) => {
|
|
37
|
+
const bytes = db.prepare("SELECT content FROM content_refs WHERE digest=?").get(digest)?.content;
|
|
38
|
+
if (!bytes || hash(bytes) !== digest || !graphDerivedAllowed(db, projectDir, digest))
|
|
39
|
+
throw new Error("Checkpoint origin evidence missing, altered or revoked");
|
|
40
|
+
return bytes;
|
|
41
|
+
};
|
|
42
|
+
const retained = JSON.parse(read(result.responseDigest).toString());
|
|
43
|
+
if (retained.tupleDigest !== tuple.digest)
|
|
44
|
+
throw new Error("Checkpoint origin receipt tuple mismatch");
|
|
45
|
+
const response = verifyPersistedDispatch(descriptor.canonical, intent.sealedDispatch, hydrateStructuredReceipt(retained, read));
|
|
46
|
+
if (canonicalJson(response.continuity) !== canonicalJson(JSON.parse(checkpoint.delta_json)))
|
|
47
|
+
throw new Error("Checkpoint delta differs from its native origin");
|
|
48
|
+
if (checkpoint.session_ref_json) {
|
|
49
|
+
const session = JSON.parse(checkpoint.session_ref_json);
|
|
50
|
+
if (["provider", "role", "stream", "cwd", "configRoot", "workspaceIdentity", "ticketId", "deliveryUnitId"].some(field => session[field] !== frozen[field]))
|
|
51
|
+
throw new Error("Checkpoint scoped session differs from transport");
|
|
52
|
+
if (session.sessionId !== tuple.sessionId || session.generation !== tuple.generation) {
|
|
53
|
+
// Generation promotion is permitted only by the recorded accepted handoff.
|
|
54
|
+
const acceptance = db.prepare("SELECT acceptance_receipt_digest FROM handoffs WHERE run_id=? AND successor_session_id=? AND generation=? AND state='accepted'").get(checkpoint.run_id, session.sessionId, session.generation);
|
|
55
|
+
if (!acceptance)
|
|
56
|
+
throw new Error("Checkpoint session differs from its dispatch");
|
|
57
|
+
const receipt = JSON.parse(read(acceptance.acceptance_receipt_digest).toString());
|
|
58
|
+
if (receipt.runId !== checkpoint.run_id || receipt.role !== checkpoint.role || receipt.acceptanceCheckpointDigest !== checkpoint.digest
|
|
59
|
+
|| canonicalJson(receipt.successorSessionRef) !== canonicalJson(session) || session.sessionId !== tuple.sessionId)
|
|
60
|
+
throw new Error("Checkpoint generation promotion lacks its exact accepted handoff lineage");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (origin.kind === "host") {
|
|
65
|
+
const event = db.prepare("SELECT run_id,role,payload_json FROM continuity_events WHERE sequence=?").get(origin.eventSequence);
|
|
66
|
+
if (!event || event.run_id !== checkpoint.run_id || event.role !== "host" || JSON.parse(event.payload_json).checkpointDeltaDigest !== row.delta_digest)
|
|
67
|
+
throw new Error("Host checkpoint event provenance mismatch");
|
|
68
|
+
for (const digest of origin.inputCheckpointDigests) {
|
|
69
|
+
const input = db.prepare("SELECT checkpoint_id FROM continuity_checkpoints WHERE run_id=? AND digest=?").get(checkpoint.run_id, digest);
|
|
70
|
+
if (!input || input.checkpoint_id >= checkpointId)
|
|
71
|
+
throw new Error("Host checkpoint input is unavailable or not a predecessor");
|
|
72
|
+
verifyCheckpointOrigin(db, projectDir, input.checkpoint_id, new Set(visited));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
throw new Error("Unknown checkpoint provenance");
|
|
77
|
+
}
|
|
78
|
+
function hash(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
79
|
+
export function verifyContinuityHeads(db, projectDir, runId) {
|
|
80
|
+
const heads = db.prepare("SELECT * FROM continuity_heads WHERE run_id=? ORDER BY role").all(runId);
|
|
81
|
+
const roles = heads.filter(head => head.role !== "run");
|
|
82
|
+
for (const head of roles) {
|
|
83
|
+
const checkpoint = db.prepare("SELECT checkpoint_id,event_sequence FROM continuity_checkpoints WHERE run_id=? AND role=? AND digest=?").get(runId, head.role, head.digest);
|
|
84
|
+
if (!checkpoint || checkpoint.event_sequence !== head.event_sequence)
|
|
85
|
+
throw new Error("Continuity head has no matching checkpoint");
|
|
86
|
+
verifyCheckpointOrigin(db, projectDir, checkpoint.checkpoint_id);
|
|
87
|
+
}
|
|
88
|
+
const run = heads.find(head => head.role === "run");
|
|
89
|
+
if (run && (run.digest !== hash(canonicalJson(roles.map(row => ({ role: row.role, digest: row.digest })))) || run.event_sequence !== Math.max(...roles.map(row => row.event_sequence))))
|
|
90
|
+
throw new Error("Run continuity head differs from its verified role heads");
|
|
91
|
+
}
|