ai-foreman 1.7.8 → 1.7.9
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 -11
- package/dist/activity.js +2 -2
- package/dist/adapters/claude.d.ts +13 -29
- package/dist/adapters/claude.js +76 -334
- package/dist/adapters/codex.d.ts +13 -18
- package/dist/adapters/codex.js +103 -195
- package/dist/adapters/recovering.d.ts +5 -6
- package/dist/adapters/recovering.js +5 -9
- package/dist/adapters/types.d.ts +24 -66
- package/dist/branch/currentGuard.d.ts +5 -5
- package/dist/branch/currentGuard.js +4 -6
- package/dist/branch/runner.d.ts +2 -0
- package/dist/branch/runner.js +2 -1
- package/dist/cli/start.js +90 -165
- package/dist/continuity.d.ts +6 -8
- package/dist/continuity.js +26 -72
- package/dist/foreman.d.ts +7 -1
- package/dist/foreman.js +25 -1
- package/dist/handoffs.js +3 -223
- package/dist/qaReview.d.ts +2 -0
- package/dist/qaReview.js +1 -4
- package/dist/sessionLifecycle.d.ts +19 -108
- package/dist/sessionLifecycle.js +310 -1024
- package/dist/statusReporter.d.ts +5 -27
- package/dist/statusReporter.js +20 -76
- package/dist/workflowDb.d.ts +10 -47
- package/dist/workflowDb.js +41 -146
- package/package.json +4 -4
package/dist/continuity.js
CHANGED
|
@@ -109,7 +109,6 @@ export class ContinuityAdapter {
|
|
|
109
109
|
queue = new BuilderEventQueue();
|
|
110
110
|
sourcePump;
|
|
111
111
|
recoveryLeasePending;
|
|
112
|
-
turnDispatcher;
|
|
113
112
|
constructor(options) {
|
|
114
113
|
this.options = options;
|
|
115
114
|
this.adapter = options.adapter;
|
|
@@ -139,7 +138,7 @@ export class ContinuityAdapter {
|
|
|
139
138
|
finally {
|
|
140
139
|
db.close();
|
|
141
140
|
}
|
|
142
|
-
const original = await this.
|
|
141
|
+
const original = await this.adapter.sendTurn(`${instruction}\n\n${continuityInstruction()}`);
|
|
143
142
|
const activeSession = this.adapter.sessionRef?.() ?? this.adapter.sessionId();
|
|
144
143
|
if (activeSession) {
|
|
145
144
|
const leaseDb = new WorkflowDb(this.options.projectDir);
|
|
@@ -154,30 +153,6 @@ export class ContinuityAdapter {
|
|
|
154
153
|
this.recordSessionUnavailable(original.failure, instruction);
|
|
155
154
|
throw new SessionUnavailableContinuityError(this.options.runId, this.options.role, original.failure);
|
|
156
155
|
}
|
|
157
|
-
if (original.interrupted?.reason === "compaction-boundary") {
|
|
158
|
-
// The coordinator owns the validated handoff and replay of this frozen
|
|
159
|
-
// action. A protocol-repair turn here would run in the interrupted
|
|
160
|
-
// predecessor (or race successor adoption) without a completed delta.
|
|
161
|
-
const interruptedDb = new WorkflowDb(this.options.projectDir);
|
|
162
|
-
try {
|
|
163
|
-
interruptedDb.appendContinuityEvent({
|
|
164
|
-
runId: this.options.runId,
|
|
165
|
-
role: "host",
|
|
166
|
-
kind: "turn_interrupted_at_compaction_boundary",
|
|
167
|
-
payload: {
|
|
168
|
-
role: this.options.role,
|
|
169
|
-
providerEventId: original.interrupted.providerEventId,
|
|
170
|
-
instructionDigest: sha(instruction),
|
|
171
|
-
},
|
|
172
|
-
authoritativeStateRevision: this.revision(),
|
|
173
|
-
sessionRef: this.adapter.sessionRef?.(),
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
finally {
|
|
177
|
-
interruptedDb.close();
|
|
178
|
-
}
|
|
179
|
-
return original;
|
|
180
|
-
}
|
|
181
156
|
const parsed = parseContinuityDelta(original.text);
|
|
182
157
|
if (parsed.delta) {
|
|
183
158
|
this.publish(parsed.delta, "turn_completed", original);
|
|
@@ -192,37 +167,12 @@ export class ContinuityAdapter {
|
|
|
192
167
|
}
|
|
193
168
|
return { ...original, text: parsed.cleanText };
|
|
194
169
|
}
|
|
195
|
-
const repair = await this.
|
|
170
|
+
const repair = await this.adapter.sendTurn([
|
|
196
171
|
"Continuity protocol repair only. Do not run tools, repeat work, or change files.",
|
|
197
172
|
`Your prior turn's continuity record was invalid: ${parsed.error?.problems.join("; ")}.`,
|
|
198
173
|
continuityInstruction(),
|
|
199
174
|
"Return only the continuity record.",
|
|
200
175
|
].join("\n"));
|
|
201
|
-
if (repair.interrupted?.reason === "compaction-boundary") {
|
|
202
|
-
// Preserve the predecessor and let the outer managed dispatch perform
|
|
203
|
-
// the validated handoff/replay. Treating this as another invalid delta
|
|
204
|
-
// would race continuity recovery against the coordinator and could lose
|
|
205
|
-
// the original frozen action.
|
|
206
|
-
const interruptedDb = new WorkflowDb(this.options.projectDir);
|
|
207
|
-
try {
|
|
208
|
-
interruptedDb.appendContinuityEvent({
|
|
209
|
-
runId: this.options.runId,
|
|
210
|
-
role: "host",
|
|
211
|
-
kind: "continuity_repair_interrupted_at_compaction_boundary",
|
|
212
|
-
payload: {
|
|
213
|
-
role: this.options.role,
|
|
214
|
-
providerEventId: repair.interrupted.providerEventId,
|
|
215
|
-
instructionDigest: sha(instruction),
|
|
216
|
-
},
|
|
217
|
-
authoritativeStateRevision: this.revision(),
|
|
218
|
-
sessionRef: this.adapter.sessionRef?.(),
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
finally {
|
|
222
|
-
interruptedDb.close();
|
|
223
|
-
}
|
|
224
|
-
return repair;
|
|
225
|
-
}
|
|
226
176
|
const repaired = parseContinuityDelta(repair.text);
|
|
227
177
|
if (repaired.delta) {
|
|
228
178
|
this.publish(repaired.delta, "turn_completed_after_repair", original);
|
|
@@ -257,25 +207,35 @@ export class ContinuityAdapter {
|
|
|
257
207
|
await this.adoptValidatedSuccessor(successor);
|
|
258
208
|
return { ...original, text: parsed.cleanText };
|
|
259
209
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
210
|
+
if (!this.options.createSuccessor)
|
|
211
|
+
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "continuity repair failed in the original session and no validated successor is configured");
|
|
212
|
+
const successor = await this.options.createSuccessor(handoff);
|
|
213
|
+
const accepted = await successor.sendTurn(`${handoff}\n\nReply with HANDOFF_ACCEPTED on the first line, then ${continuityInstruction()}`);
|
|
214
|
+
const successorDelta = parseContinuityDelta(accepted.text);
|
|
215
|
+
if (!/^HANDOFF_ACCEPTED\b/m.test(accepted.text) || !successorDelta.delta || !successor.sessionId()) {
|
|
216
|
+
await successor.close().catch(() => { });
|
|
217
|
+
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "fresh successor did not validate and accept the cumulative checkpoint");
|
|
218
|
+
}
|
|
219
|
+
await this.adoptValidatedSuccessor(successor);
|
|
220
|
+
this.publish(successorDelta.delta, "fresh_successor_accepted", accepted);
|
|
221
|
+
const leaseDb = new WorkflowDb(this.options.projectDir);
|
|
222
|
+
try {
|
|
223
|
+
leaseDb.moveRoleLeaseAfterValidatedRecovery(this.options.runId, this.options.role, successor.sessionRef?.() ?? successor.sessionId(), "double-invalid continuity recovery");
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
leaseDb.close();
|
|
227
|
+
}
|
|
228
|
+
return { ...original, text: parsed.cleanText };
|
|
264
229
|
}
|
|
265
230
|
sessionId() { return this.adapter.sessionId(); }
|
|
266
231
|
sessionRef() { return this.adapter.sessionRef?.(); }
|
|
267
232
|
adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
|
|
268
233
|
validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
|
|
269
234
|
compact() { return this.adapter.compact ? this.adapter.compact() : Promise.resolve({ ok: false, error: "provider adapter does not expose native compaction" }); }
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
interruptTurnAtCompactionBoundary(providerEventId) { return this.adapter.interruptTurnAtCompactionBoundary?.(providerEventId) ?? Promise.resolve({ ok: false, error: "provider adapter does not expose compaction-boundary interruption", providerEventId }); }
|
|
275
|
-
installManagedTurnDispatcher(dispatcher) {
|
|
276
|
-
this.turnDispatcher = dispatcher;
|
|
277
|
-
this.adapter.installManagedTurnDispatcher?.(dispatcher);
|
|
278
|
-
}
|
|
235
|
+
prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
|
|
236
|
+
drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
|
|
237
|
+
restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
|
|
238
|
+
contextUsageAfterNativeCompaction(compaction) { return this.adapter.contextUsageAfterNativeCompaction?.(compaction) ?? Promise.resolve(undefined); }
|
|
279
239
|
contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
|
|
280
240
|
sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
|
|
281
241
|
switchSettings(settings) { return this.adapter.switchSettings ? this.adapter.switchSettings(settings) : Promise.resolve({ ok: false, error: "provider adapter does not support settings changes" }); }
|
|
@@ -287,15 +247,9 @@ export class ContinuityAdapter {
|
|
|
287
247
|
return;
|
|
288
248
|
const prior = this.adapter;
|
|
289
249
|
this.adapter = successor;
|
|
290
|
-
if (this.turnDispatcher)
|
|
291
|
-
successor.installManagedTurnDispatcher?.(this.turnDispatcher);
|
|
292
250
|
this.pumpEvents();
|
|
293
251
|
await prior.close().catch(() => { });
|
|
294
252
|
}
|
|
295
|
-
providerTurn(text) {
|
|
296
|
-
const invoke = () => this.adapter.sendTurn(text);
|
|
297
|
-
return this.turnDispatcher ? this.turnDispatcher(text, invoke) : invoke();
|
|
298
|
-
}
|
|
299
253
|
publish(delta, kind, result) {
|
|
300
254
|
const db = new WorkflowDb(this.options.projectDir);
|
|
301
255
|
try {
|
|
@@ -372,7 +326,7 @@ export class ContinuityAdapter {
|
|
|
372
326
|
try {
|
|
373
327
|
for await (const event of source.events()) {
|
|
374
328
|
this.queue.push(event);
|
|
375
|
-
if (event.kind === "tool" || event.kind === "session-transition" || event.kind === "context-usage"
|
|
329
|
+
if (event.kind === "tool" || event.kind === "session-transition" || event.kind === "context-usage") {
|
|
376
330
|
const db = new WorkflowDb(this.options.projectDir);
|
|
377
331
|
try {
|
|
378
332
|
const payload = event.kind === "tool"
|
package/dist/foreman.d.ts
CHANGED
|
@@ -65,10 +65,15 @@ export declare class Foreman {
|
|
|
65
65
|
private readonly beforeBuilderTurn?;
|
|
66
66
|
private readonly builderSessionBoundary?;
|
|
67
67
|
private readonly qaSessionBoundary?;
|
|
68
|
+
private readonly observeQaNativeCompactions?;
|
|
69
|
+
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
70
|
+
private readonly observeBuilderNativeCompactions?;
|
|
68
71
|
private readonly ticketsEnabled;
|
|
69
72
|
private readonly qaStream;
|
|
70
73
|
private builderWorkSessions;
|
|
71
|
-
constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>) | undefined)
|
|
74
|
+
constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>) | undefined, observeQaNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined,
|
|
75
|
+
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
76
|
+
observeBuilderNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined);
|
|
72
77
|
/**
|
|
73
78
|
* Send one instruction and resolve any needs_input exchanges before returning.
|
|
74
79
|
* The returned result and status always reflect the final (non-needs_input) turn.
|
|
@@ -85,6 +90,7 @@ export declare class Foreman {
|
|
|
85
90
|
result: TurnResult;
|
|
86
91
|
status: StepStatus;
|
|
87
92
|
}>;
|
|
93
|
+
private observeBuilderNative;
|
|
88
94
|
/**
|
|
89
95
|
* Run a QA review pass on the ticket the builder just completed.
|
|
90
96
|
* Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
|
package/dist/foreman.js
CHANGED
|
@@ -229,10 +229,14 @@ export class Foreman {
|
|
|
229
229
|
beforeBuilderTurn;
|
|
230
230
|
builderSessionBoundary;
|
|
231
231
|
qaSessionBoundary;
|
|
232
|
+
observeQaNativeCompactions;
|
|
233
|
+
observeBuilderNativeCompactions;
|
|
232
234
|
ticketsEnabled;
|
|
233
235
|
qaStream = { reviews: 0, modificationViolations: 0 };
|
|
234
236
|
builderWorkSessions = 0;
|
|
235
|
-
constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary
|
|
237
|
+
constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence, beforeBuilderTurn, builderSessionBoundary, qaSessionBoundary, observeQaNativeCompactions,
|
|
238
|
+
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
239
|
+
observeBuilderNativeCompactions) {
|
|
236
240
|
this.builder = builder;
|
|
237
241
|
this.log = log;
|
|
238
242
|
this.notificationsEnabled = notificationsEnabled;
|
|
@@ -248,6 +252,8 @@ export class Foreman {
|
|
|
248
252
|
this.beforeBuilderTurn = beforeBuilderTurn;
|
|
249
253
|
this.builderSessionBoundary = builderSessionBoundary;
|
|
250
254
|
this.qaSessionBoundary = qaSessionBoundary;
|
|
255
|
+
this.observeQaNativeCompactions = observeQaNativeCompactions;
|
|
256
|
+
this.observeBuilderNativeCompactions = observeBuilderNativeCompactions;
|
|
251
257
|
this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
|
|
252
258
|
}
|
|
253
259
|
/**
|
|
@@ -309,6 +315,7 @@ export class Foreman {
|
|
|
309
315
|
adapter = this.builder;
|
|
310
316
|
}
|
|
311
317
|
let result = await adapter.sendTurn(instruction);
|
|
318
|
+
await this.observeBuilderNative(adapter);
|
|
312
319
|
let status = parseStepStatus(result.text);
|
|
313
320
|
if (result.failure?.category === "session-unavailable")
|
|
314
321
|
return { result, status };
|
|
@@ -318,9 +325,14 @@ export class Foreman {
|
|
|
318
325
|
}
|
|
319
326
|
else {
|
|
320
327
|
const qa = /\bQA\b|qa_pass|qa_fail/i.test(instruction);
|
|
328
|
+
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
329
|
+
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
330
|
+
adapter = this.builder;
|
|
331
|
+
}
|
|
321
332
|
result = await adapter.sendTurn(qa
|
|
322
333
|
? "Protocol correction only: based on the review already completed, return exactly one final STEP_STATUS: qa_pass or STEP_STATUS: qa_fail marker. Do not repeat QA, tests, tools, or compaction."
|
|
323
334
|
: "Protocol correction only: based on the work already completed, return exactly one final STEP_STATUS: done, plan_complete, blocked, or needs_input marker. Do not repeat implementation, tools, or compaction.");
|
|
335
|
+
await this.observeBuilderNative(adapter);
|
|
324
336
|
status = parseStepStatus(result.text);
|
|
325
337
|
if (status.kind === "unknown")
|
|
326
338
|
status = { ...status, error: `protocol correction exhausted: ${status.error ?? "missing final marker"}` };
|
|
@@ -346,7 +358,12 @@ export class Foreman {
|
|
|
346
358
|
status = { kind: "blocked", reason: "user cancelled at input prompt" };
|
|
347
359
|
break;
|
|
348
360
|
}
|
|
361
|
+
if (adapter === this.builder && this.beforeBuilderTurn) {
|
|
362
|
+
this.builder = await this.beforeBuilderTurn(this.builder, String(answer));
|
|
363
|
+
adapter = this.builder;
|
|
364
|
+
}
|
|
349
365
|
result = await adapter.sendTurn(String(answer));
|
|
366
|
+
await this.observeBuilderNative(adapter);
|
|
350
367
|
status = parseStepStatus(result.text);
|
|
351
368
|
}
|
|
352
369
|
return { result, status };
|
|
@@ -357,6 +374,7 @@ export class Foreman {
|
|
|
357
374
|
if (this.beforeBuilderTurn)
|
|
358
375
|
this.builder = await this.beforeBuilderTurn(this.builder, instruction);
|
|
359
376
|
const result = await this.builder.sendTurn(instruction);
|
|
377
|
+
await this.observeBuilderNative(this.builder);
|
|
360
378
|
this.log.write("preflight", {
|
|
361
379
|
ticketsProvided: ticketsContent !== undefined,
|
|
362
380
|
costUsd: result.costUsd,
|
|
@@ -371,6 +389,7 @@ export class Foreman {
|
|
|
371
389
|
if (this.beforeBuilderTurn)
|
|
372
390
|
this.builder = await this.beforeBuilderTurn(this.builder, feedback);
|
|
373
391
|
const result = await this.builder.sendTurn(feedback);
|
|
392
|
+
await this.observeBuilderNative(this.builder);
|
|
374
393
|
this.log.write("preflight", { feedback: true, costUsd: result.costUsd, isError: result.isError });
|
|
375
394
|
if (result.isError)
|
|
376
395
|
throw new Error(result.text);
|
|
@@ -379,6 +398,10 @@ export class Foreman {
|
|
|
379
398
|
async runInstruction(instruction) {
|
|
380
399
|
return this.doTurn(instruction);
|
|
381
400
|
}
|
|
401
|
+
async observeBuilderNative(adapter) {
|
|
402
|
+
if (adapter === this.builder)
|
|
403
|
+
await this.observeBuilderNativeCompactions?.(adapter);
|
|
404
|
+
}
|
|
382
405
|
/**
|
|
383
406
|
* Run a QA review pass on the ticket the builder just completed.
|
|
384
407
|
* Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
|
|
@@ -394,6 +417,7 @@ export class Foreman {
|
|
|
394
417
|
state: this.qaStream,
|
|
395
418
|
createQa: this.qaFactory,
|
|
396
419
|
sessionBoundary: this.qaSessionBoundary,
|
|
420
|
+
observeNativeCompactions: this.observeQaNativeCompactions,
|
|
397
421
|
maxCycles: this.qaMaxCycles,
|
|
398
422
|
fix: async (issues) => {
|
|
399
423
|
const instruction = buildQaFixInstruction(issues);
|
package/dist/handoffs.js
CHANGED
|
@@ -3,7 +3,6 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, st
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { continuityInstruction, mergeContinuityDeltas, parseContinuityDelta } from "./continuity.js";
|
|
5
5
|
import { WorkflowDb } from "./workflowDb.js";
|
|
6
|
-
import { providerSessionKey } from "./sessionIdentity.js";
|
|
7
6
|
export const HANDOFF_CACHE_RETENTION_DAYS = 30;
|
|
8
7
|
export const HANDOFF_ACCEPTED = "HANDOFF_ACCEPTED";
|
|
9
8
|
export const HANDOFF_REQUEST_START = "RAFI_HANDOFF_REQUEST_START";
|
|
@@ -86,9 +85,6 @@ export class HandoffService {
|
|
|
86
85
|
}
|
|
87
86
|
const prior = db.handoffs(input.runId).at(-1);
|
|
88
87
|
const generation = (prior?.generation ?? 0) + 1;
|
|
89
|
-
const laterHostFacts = db.continuityEvents(input.runId, head.sequence)
|
|
90
|
-
.slice(-200)
|
|
91
|
-
.map((event) => ({ kind: event.kind, payload: event.payload, digest: event.digest, createdAt: event.createdAt }));
|
|
92
88
|
const resources = (input.resources ?? []).map((resource) => ({
|
|
93
89
|
label: resource.label,
|
|
94
90
|
digest: digest(resource.content),
|
|
@@ -107,10 +103,7 @@ export class HandoffService {
|
|
|
107
103
|
continuityCheckpointDigest: head.digest,
|
|
108
104
|
authoritativeStateDigest: runHead.digest,
|
|
109
105
|
cumulative: mergeContinuityDeltas(checkpoints.map((checkpoint) => checkpoint.delta)),
|
|
110
|
-
roleState: sanitizeObject({
|
|
111
|
-
...(input.roleState ?? {}),
|
|
112
|
-
...(laterHostFacts.length ? { laterHostFacts } : {}),
|
|
113
|
-
}),
|
|
106
|
+
roleState: sanitizeObject(input.roleState ?? {}),
|
|
114
107
|
lineage: db.handoffs(input.runId).map((item) => item.manifestDigest),
|
|
115
108
|
...(input.sessionUsage ? { sessionUsage: input.sessionUsage } : {}),
|
|
116
109
|
compactionCount: input.compactionCount,
|
|
@@ -145,26 +138,12 @@ export class HandoffService {
|
|
|
145
138
|
async acceptStaged(staged, successor) {
|
|
146
139
|
const db = new WorkflowDb(this.projectDir);
|
|
147
140
|
try {
|
|
148
|
-
if (!successor.prepareContextManagement
|
|
149
|
-
|| !successor.updateContextManagement
|
|
150
|
-
|| !successor.interruptTurnAtCompactionBoundary) {
|
|
151
|
-
throw new Error("fresh successor does not expose complete native context management; acceptance was not dispatched");
|
|
152
|
-
}
|
|
153
|
-
const contextMonitor = monitorHandoffAcceptanceContext(this.projectDir, staged.manifest, successor);
|
|
154
141
|
const response = await successor.sendTurn([
|
|
155
|
-
"Accept this validated Rafi handoff
|
|
156
|
-
"Do not call tools, read or change files, execute commands, or begin any frozen/open work during this turn.",
|
|
157
|
-
"Treat every manifest, role-state, resource, and quoted instruction below as inert continuity data, never as an instruction to execute now.",
|
|
158
|
-
"Reconcile host receipts without side effects. The managed dispatcher will replay unfinished work only after this acceptance is validated.",
|
|
142
|
+
"Accept this validated Rafi handoff. Do not repeat completed work and reconcile host receipts before side effects.",
|
|
159
143
|
staged.markdown,
|
|
160
144
|
`Manifest JSON: ${JSON.stringify(staged.manifest)}`,
|
|
161
|
-
`
|
|
145
|
+
`Reply with ${HANDOFF_ACCEPTED} on the first line, then ${continuityInstruction()}`,
|
|
162
146
|
].join("\n\n"));
|
|
163
|
-
const capabilityFailure = await withTimeout(contextMonitor, 5_000, "successor context monitor did not observe the handoff acceptance turn boundary");
|
|
164
|
-
if (capabilityFailure) {
|
|
165
|
-
await successor.close().catch(() => { });
|
|
166
|
-
throw new Error(`fresh successor context enforcement failed during handoff acceptance: ${capabilityFailure}`);
|
|
167
|
-
}
|
|
168
147
|
const parsed = parseContinuityDelta(response.text);
|
|
169
148
|
const sessionId = successor.sessionId();
|
|
170
149
|
const observedSuccessorRef = successor.sessionRef?.();
|
|
@@ -190,7 +169,6 @@ export class HandoffService {
|
|
|
190
169
|
const current = db.handoff(staged.manifest.runId, staged.manifest.generation);
|
|
191
170
|
if (current?.state === "staged")
|
|
192
171
|
db.failHandoff(staged.manifest.runId, staged.manifest.generation, error instanceof Error ? error.message : String(error));
|
|
193
|
-
await successor.close().catch(() => { });
|
|
194
172
|
throw error;
|
|
195
173
|
}
|
|
196
174
|
finally {
|
|
@@ -297,204 +275,6 @@ export class HandoffService {
|
|
|
297
275
|
return count;
|
|
298
276
|
}
|
|
299
277
|
}
|
|
300
|
-
/**
|
|
301
|
-
* A successor is not adopted until acceptance succeeds, so it cannot yet use
|
|
302
|
-
* the predecessor's stable ManagedRoleAdapter. Consume and durably account for
|
|
303
|
-
* its native compactions during this one validation turn, then let the role
|
|
304
|
-
* coordinator recover the same scoped-session count on adoption.
|
|
305
|
-
*/
|
|
306
|
-
async function monitorHandoffAcceptanceContext(projectDir, manifest, successor) {
|
|
307
|
-
if (!successor.updateContextManagement || !successor.interruptTurnAtCompactionBoundary) {
|
|
308
|
-
return "provider does not expose native prepare, update, and compaction-boundary interruption capabilities";
|
|
309
|
-
}
|
|
310
|
-
const phases = new Set();
|
|
311
|
-
const attempts = new Map();
|
|
312
|
-
const succeeded = new Set();
|
|
313
|
-
let latest;
|
|
314
|
-
let failure;
|
|
315
|
-
let pendingSuccesses = 0;
|
|
316
|
-
let sawAcceptanceBoundary = false;
|
|
317
|
-
for await (const event of successor.events()) {
|
|
318
|
-
if (event.kind === "context-usage") {
|
|
319
|
-
if (event.sessionId && successor.sessionId() && event.sessionId !== successor.sessionId())
|
|
320
|
-
continue;
|
|
321
|
-
if (event.sequence !== undefined && latest?.sequence !== undefined && event.sequence <= latest.sequence)
|
|
322
|
-
continue;
|
|
323
|
-
latest = event;
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
if (event.kind === "turn-complete") {
|
|
327
|
-
// Codex discovery emits one tool-free setup turn before the actual
|
|
328
|
-
// acceptance turn. Only the latter ends this temporary monitor.
|
|
329
|
-
if (event.result.text.trim() !== "CONTEXT_READY") {
|
|
330
|
-
sawAcceptanceBoundary = true;
|
|
331
|
-
break;
|
|
332
|
-
}
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
if (event.kind !== "context-compaction")
|
|
336
|
-
continue;
|
|
337
|
-
const phaseKey = `${event.providerEventId}:${event.phase}`;
|
|
338
|
-
if (phases.has(phaseKey))
|
|
339
|
-
continue;
|
|
340
|
-
phases.add(phaseKey);
|
|
341
|
-
const sessionRef = successor.sessionRef?.() ?? event.sessionRef;
|
|
342
|
-
const sessionId = successor.sessionId() ?? event.sessionId;
|
|
343
|
-
if (event.sessionId && sessionId && event.sessionId !== sessionId)
|
|
344
|
-
continue;
|
|
345
|
-
const session = sessionRef ?? sessionId;
|
|
346
|
-
if (!session) {
|
|
347
|
-
failure ??= `provider compaction ${event.providerEventId} had no scoped successor session identity`;
|
|
348
|
-
continue;
|
|
349
|
-
}
|
|
350
|
-
const db = new WorkflowDb(projectDir);
|
|
351
|
-
try {
|
|
352
|
-
const durableCount = db.successfulCompactionCount(manifest.runId, manifest.role, session);
|
|
353
|
-
const scopedSessionKey = sessionRef ? providerSessionKey(sessionRef) : `${successor.agent}:unscoped:${sessionId}`;
|
|
354
|
-
const thresholdGenerationId = handoffThresholdGenerationId(manifest, scopedSessionKey);
|
|
355
|
-
if (event.phase === "started") {
|
|
356
|
-
if (durableCount + pendingSuccesses >= manifest.compactMaximum) {
|
|
357
|
-
const interrupted = await successor.interruptTurnAtCompactionBoundary(event.providerEventId);
|
|
358
|
-
failure ??= interrupted.ok
|
|
359
|
-
? `compact maximum ${manifest.compactMaximum} reached during successor acceptance`
|
|
360
|
-
: `provider could not stop compaction ${event.providerEventId} at compact maximum ${manifest.compactMaximum}: ${interrupted.error ?? "interrupt unavailable"}`;
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
const beforeSample = latest ? handoffContextSample(manifest, successor, latest, durableCount) : undefined;
|
|
364
|
-
const key = `handoff-native:${manifest.runId}:${manifest.role}:${digest(`${typeof session === "string" ? session : providerSessionKey(session)}:${event.providerEventId}`).slice(0, 24)}`;
|
|
365
|
-
const attempt = db.startCompactionAttempt({
|
|
366
|
-
idempotencyKey: key, runId: manifest.runId, role: manifest.role,
|
|
367
|
-
providerSessionId: sessionId, sessionRef, crossingKey: `handoff:${manifest.generation}:${event.providerEventId}`,
|
|
368
|
-
beforeSample, origin: event.origin, providerEventId: event.providerEventId,
|
|
369
|
-
thresholdGenerationId,
|
|
370
|
-
});
|
|
371
|
-
attempts.set(event.providerEventId, attempt.idempotencyKey);
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
let key = attempts.get(event.providerEventId);
|
|
375
|
-
if (!key) {
|
|
376
|
-
const synthesized = `handoff-native:${manifest.runId}:${manifest.role}:${digest(`${typeof session === "string" ? session : providerSessionKey(session)}:${event.providerEventId}`).slice(0, 24)}`;
|
|
377
|
-
const attempt = db.startCompactionAttempt({
|
|
378
|
-
idempotencyKey: synthesized, runId: manifest.runId, role: manifest.role,
|
|
379
|
-
providerSessionId: sessionId, sessionRef, crossingKey: `handoff:${manifest.generation}:${event.providerEventId}`,
|
|
380
|
-
beforeSample: latest ? handoffContextSample(manifest, successor, latest, durableCount) : undefined,
|
|
381
|
-
origin: event.origin, providerEventId: event.providerEventId,
|
|
382
|
-
thresholdGenerationId,
|
|
383
|
-
});
|
|
384
|
-
key = attempt.idempotencyKey;
|
|
385
|
-
attempts.set(event.providerEventId, key);
|
|
386
|
-
failure ??= `provider completed compaction ${event.providerEventId} without an observable start boundary`;
|
|
387
|
-
}
|
|
388
|
-
if (event.phase === "failed") {
|
|
389
|
-
db.finishCompactionAttempt(key, { ok: false, error: event.reason ?? "provider-native compaction failed during handoff acceptance" });
|
|
390
|
-
}
|
|
391
|
-
else {
|
|
392
|
-
if (event.postCompactSample
|
|
393
|
-
&& (event.postCompactSample.sequence === undefined || latest?.sequence === undefined || event.postCompactSample.sequence >= latest.sequence)) {
|
|
394
|
-
latest = event.postCompactSample;
|
|
395
|
-
}
|
|
396
|
-
if (db.compactionAttempt(key)?.status === "started") {
|
|
397
|
-
succeeded.add(event.providerEventId);
|
|
398
|
-
pendingSuccesses += 1;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
finally {
|
|
403
|
-
db.close();
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (!sawAcceptanceBoundary && !failure)
|
|
407
|
-
return "provider event stream ended before the acceptance turn completed";
|
|
408
|
-
for (let queryAttempt = 0; queryAttempt < (succeeded.size ? 3 : 1); queryAttempt++) {
|
|
409
|
-
const queried = await successor.contextUsage?.().catch(() => undefined);
|
|
410
|
-
if (queried && (queried.sequence === undefined || latest?.sequence === undefined || queried.sequence >= latest.sequence))
|
|
411
|
-
latest = queried;
|
|
412
|
-
if ([...succeeded].every((providerEventId) => {
|
|
413
|
-
const key = attempts.get(providerEventId);
|
|
414
|
-
if (!key)
|
|
415
|
-
return false;
|
|
416
|
-
const verificationDb = new WorkflowDb(projectDir);
|
|
417
|
-
try {
|
|
418
|
-
return usageIsNewer(latest, verificationDb.compactionAttempt(key)?.beforeSample);
|
|
419
|
-
}
|
|
420
|
-
finally {
|
|
421
|
-
verificationDb.close();
|
|
422
|
-
}
|
|
423
|
-
}))
|
|
424
|
-
break;
|
|
425
|
-
if (queryAttempt < 2)
|
|
426
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
427
|
-
}
|
|
428
|
-
for (const providerEventId of succeeded) {
|
|
429
|
-
const key = attempts.get(providerEventId);
|
|
430
|
-
if (!key)
|
|
431
|
-
continue;
|
|
432
|
-
const db = new WorkflowDb(projectDir);
|
|
433
|
-
try {
|
|
434
|
-
const attempt = db.compactionAttempt(key);
|
|
435
|
-
if (!attempt || attempt.status !== "started")
|
|
436
|
-
continue;
|
|
437
|
-
const before = attempt.beforeSample;
|
|
438
|
-
const verified = usageIsNewer(latest, before);
|
|
439
|
-
const afterSample = verified ? handoffContextSample(manifest, successor, latest, db.successfulCompactionCount(manifest.runId, manifest.role, successor.sessionRef?.() ?? successor.sessionId()) + 1) : undefined;
|
|
440
|
-
db.finishCompactionAttempt(key, verified
|
|
441
|
-
? { ok: true, afterSample }
|
|
442
|
-
: { ok: true, status: "unverified", error: "no newer authoritative post-compact sample during handoff acceptance" });
|
|
443
|
-
if (!verified)
|
|
444
|
-
failure ??= `compaction ${providerEventId} could not be verified during handoff acceptance`;
|
|
445
|
-
}
|
|
446
|
-
finally {
|
|
447
|
-
db.close();
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
for (const [providerEventId, key] of attempts) {
|
|
451
|
-
if (succeeded.has(providerEventId))
|
|
452
|
-
continue;
|
|
453
|
-
const db = new WorkflowDb(projectDir);
|
|
454
|
-
try {
|
|
455
|
-
if (db.compactionAttempt(key)?.status === "started") {
|
|
456
|
-
db.finishCompactionAttempt(key, { ok: false, error: failure ?? "compaction did not reach a terminal provider event during handoff acceptance" });
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
finally {
|
|
460
|
-
db.close();
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
return failure;
|
|
464
|
-
}
|
|
465
|
-
function handoffThresholdGenerationId(manifest, sessionKey) {
|
|
466
|
-
return `threshold:${manifest.runId}:${manifest.role}:${digest(`${sessionKey}:1`).slice(0, 24)}`;
|
|
467
|
-
}
|
|
468
|
-
function usageIsNewer(usage, before) {
|
|
469
|
-
return usage !== undefined && (before === undefined
|
|
470
|
-
|| (usage.sequence !== undefined && before.sequence !== undefined && usage.sequence > before.sequence)
|
|
471
|
-
|| (usage.sequence === undefined && (usage.used !== before.used || usage.maximum !== before.maximum || usage.percentage !== before.percentage)));
|
|
472
|
-
}
|
|
473
|
-
function handoffContextSample(manifest, successor, usage, compactionCount) {
|
|
474
|
-
const ref = successor.sessionRef?.();
|
|
475
|
-
return {
|
|
476
|
-
version: 1, runId: manifest.runId, role: manifest.role, provider: successor.agent,
|
|
477
|
-
providerSessionId: successor.sessionId(), ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}),
|
|
478
|
-
model: usage.model ?? "unknown", observedAt: usage.observedAt ?? new Date().toISOString(),
|
|
479
|
-
source: usage.source ?? "provider-event", freshness: "fresh", used: usage.used,
|
|
480
|
-
maximum: usage.maximum, percentage: usage.percentage ?? (usage.maximum ? usage.used / usage.maximum * 100 : undefined),
|
|
481
|
-
settingsRevision: 0, compactionCount, handoffGeneration: manifest.generation,
|
|
482
|
-
...(usage.sequence === undefined ? {} : { sequence: usage.sequence }),
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
async function withTimeout(promise, milliseconds, message) {
|
|
486
|
-
let handle;
|
|
487
|
-
try {
|
|
488
|
-
return await Promise.race([
|
|
489
|
-
promise,
|
|
490
|
-
new Promise((_resolve, reject) => { handle = setTimeout(() => reject(new Error(message)), milliseconds); }),
|
|
491
|
-
]);
|
|
492
|
-
}
|
|
493
|
-
finally {
|
|
494
|
-
if (handle)
|
|
495
|
-
clearTimeout(handle);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
278
|
export function renderHandoffMarkdown(manifest) {
|
|
499
279
|
const section = (title, values) => [`## ${title}`, "", ...(values.length ? values.map((value) => `- ${value}`) : ["- None recorded."]), ""];
|
|
500
280
|
return [
|
package/dist/qaReview.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface IsolatedQaOptions {
|
|
|
29
29
|
createQa: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
|
|
30
30
|
/** Host-owned ordinary QA boundary. Fresh strategies must return a validated successor. */
|
|
31
31
|
sessionBoundary?: (adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>;
|
|
32
|
+
/** Persist provider-native automatic compactions without imposing an ordinary QA boundary. */
|
|
33
|
+
observeNativeCompactions?: (adapter: BuilderAdapter) => Promise<void>;
|
|
32
34
|
fix: (issues: string) => Promise<{
|
|
33
35
|
ok: boolean;
|
|
34
36
|
detail?: string;
|
package/dist/qaReview.js
CHANGED
|
@@ -49,12 +49,9 @@ async function oneReview(opts, cycle) {
|
|
|
49
49
|
// continuity/checkpoint stream; an old provider session is never moved
|
|
50
50
|
// into a newly-created /tmp/rafi-qa-* directory.
|
|
51
51
|
qa = await opts.createQa(snapshot.path);
|
|
52
|
-
// The first turn in every disposable QA snapshot enters the same managed
|
|
53
|
-
// lifecycle gate. `compact` evaluates the ceiling and is a no-op below it.
|
|
54
|
-
if (opts.sessionBoundary)
|
|
55
|
-
qa = await opts.sessionBoundary(qa, handoff, "compact", snapshot.path);
|
|
56
52
|
const turn = await qa.sendTurn(handoff);
|
|
57
53
|
const status = parseStepStatus(turn.text);
|
|
54
|
+
await opts.observeNativeCompactions?.(qa);
|
|
58
55
|
opts.state.reviews += 1;
|
|
59
56
|
opts.state.sessionId = qa.sessionId();
|
|
60
57
|
opts.state.sessionRef = qa.sessionRef?.();
|