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.
@@ -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.providerTurn(`${instruction}\n\n${continuityInstruction()}`);
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.providerTurn([
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
- // A raw create-and-send fallback cannot install native context control,
261
- // account compactions, or validate the scoped successor atomically. Refuse
262
- // it instead of allowing continuity recovery to escape the managed gate.
263
- throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "continuity repair failed in the original session and no lifecycle-managed recoverWithHandoff callback is configured");
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
- prepareContextManagement(policy) { if (!this.adapter.prepareContextManagement)
271
- return Promise.reject(new Error("provider adapter does not expose native context management")); return this.adapter.prepareContextManagement(policy); }
272
- updateContextManagement(policy) { if (!this.adapter.updateContextManagement)
273
- return Promise.reject(new Error("provider adapter does not expose native context reconfiguration")); return this.adapter.updateContextManagement(policy); }
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" || event.kind === "context-compaction") {
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 in an acceptance-only control turn.",
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
- `Your response must start with ${HANDOFF_ACCEPTED} on the first line, then ${continuityInstruction()}`,
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 [
@@ -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?.();