ai-foreman 1.7.7 → 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.
@@ -1,7 +1,7 @@
1
1
  import type { SDKSessionInfo, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
2
2
  /** Lazy-load the Claude Agent SDK. Throws an actionable error if not installed. */
3
3
  export declare function requireClaudeSDK(): Promise<typeof import("@anthropic-ai/claude-agent-sdk")>;
4
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, PermissionDecision, ProviderSettingSwitch, ProviderSessionUsage, TurnResult } from "./types.js";
4
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, NativeCompaction, CompactResult, ContextUsage, PermissionDecision, ProviderSettingSwitch, ProviderSessionUsage, TurnResult } from "./types.js";
5
5
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
6
6
  /**
7
7
  * Pure function: build the `options` object passed to `query()`.
@@ -55,6 +55,11 @@ export declare class ClaudeAdapter implements BuilderAdapter {
55
55
  private structuredError?;
56
56
  private apiErrorStatus?;
57
57
  private compactResult?;
58
+ private autoCompactionPrepared;
59
+ private preparedAutoCompactThreshold?;
60
+ private manualCompactionInFlight;
61
+ private nativeCompactions;
62
+ private nativeCompactionSequence;
58
63
  private cumulativeUsage;
59
64
  private pending?;
60
65
  private terminalResult?;
@@ -73,6 +78,9 @@ export declare class ClaudeAdapter implements BuilderAdapter {
73
78
  adoptSessionRef(ref: ProviderSessionRefV1): void;
74
79
  validateSession(): Promise<SessionAvailabilityV1>;
75
80
  compact(): Promise<CompactResult>;
81
+ prepareAutoCompaction(thresholdPercent?: number | undefined): Promise<void>;
82
+ drainNativeCompactions(): NativeCompaction[];
83
+ restoreNativeCompactions(compactions: NativeCompaction[]): void;
76
84
  contextUsage(): Promise<ContextUsage | undefined>;
77
85
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
78
86
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
@@ -152,6 +152,11 @@ export class ClaudeAdapter {
152
152
  structuredError;
153
153
  apiErrorStatus;
154
154
  compactResult;
155
+ autoCompactionPrepared = false;
156
+ preparedAutoCompactThreshold;
157
+ manualCompactionInFlight = false;
158
+ nativeCompactions = [];
159
+ nativeCompactionSequence = 0;
155
160
  cumulativeUsage = { observedAt: new Date(0).toISOString(), source: "provider" };
156
161
  pending;
157
162
  terminalResult;
@@ -292,6 +297,8 @@ export class ClaudeAdapter {
292
297
  if (msg.compact_result === "success") {
293
298
  this.compactResult = { ok: true };
294
299
  this.eventQueue.push({ kind: "session-transition", transition: "compacted" });
300
+ if (!this.manualCompactionInFlight)
301
+ this.nativeCompactions.push({ id: `claude:${this._sessionId ?? "unknown"}:${++this.nativeCompactionSequence}`, occurredAt: new Date().toISOString(), provider: "claude" });
295
302
  }
296
303
  else if (msg.compact_result === "failed") {
297
304
  this.compactResult = { ok: false, error: sanitizeDiagnostics(msg.compact_error ?? "Claude native compaction failed") };
@@ -423,7 +430,14 @@ export class ClaudeAdapter {
423
430
  }
424
431
  async compact() {
425
432
  this.compactResult = undefined;
426
- const result = await this.sendTurn("/compact");
433
+ this.manualCompactionInFlight = true;
434
+ let result;
435
+ try {
436
+ result = await this.sendTurn("/compact");
437
+ }
438
+ finally {
439
+ this.manualCompactionInFlight = false;
440
+ }
427
441
  if (result.failure?.category === "session-unavailable") {
428
442
  return { ok: false, error: result.text || result.failure.diagnostics, failure: result.failure };
429
443
  }
@@ -431,6 +445,43 @@ export class ClaudeAdapter {
431
445
  return this.compactResult;
432
446
  return { ok: false, error: sanitizeDiagnostics(result.text || "Claude did not emit an explicit compact status") };
433
447
  }
448
+ async prepareAutoCompaction(thresholdPercent = this.opts.autoCompactThresholdPercent) {
449
+ if (thresholdPercent === undefined)
450
+ return;
451
+ const threshold = validThreshold(thresholdPercent);
452
+ if (this.autoCompactionPrepared && this.preparedAutoCompactThreshold === threshold)
453
+ return;
454
+ this.opts.autoCompactThresholdPercent = threshold;
455
+ await this.query.initializationResult();
456
+ await this.query.applyFlagSettings({ autoCompactEnabled: true });
457
+ const baseline = await this.query.getContextUsage();
458
+ if (!Number.isFinite(baseline.maxTokens) || baseline.maxTokens <= 0
459
+ || !Number.isFinite(baseline.autoCompactThreshold) || baseline.autoCompactThreshold === undefined
460
+ || !baseline.isAutoCompactEnabled) {
461
+ throw new Error("Claude did not expose an enabled native automatic-compaction threshold");
462
+ }
463
+ // Claude's setting is the total window. Preserve the provider's own
464
+ // response/compaction reserve, so the resulting used-token trigger is at
465
+ // or before Rafi's configured percentage.
466
+ const reserve = baseline.maxTokens - baseline.autoCompactThreshold;
467
+ if (reserve < 0)
468
+ throw new Error("Claude reported an invalid automatic-compaction reserve");
469
+ const requestedWindow = tokenLimit(baseline.maxTokens, threshold) + reserve;
470
+ await this.query.applyFlagSettings({ autoCompactEnabled: true, autoCompactWindow: requestedWindow });
471
+ const installed = await this.query.getContextUsage();
472
+ if (!installed.isAutoCompactEnabled || installed.autoCompactThreshold === undefined
473
+ || installed.autoCompactThreshold > tokenLimit(baseline.maxTokens, threshold)) {
474
+ throw new Error("Claude did not accept the requested native automatic-compaction ceiling");
475
+ }
476
+ this.autoCompactionPrepared = true;
477
+ this.preparedAutoCompactThreshold = threshold;
478
+ }
479
+ drainNativeCompactions() {
480
+ const pending = this.nativeCompactions;
481
+ this.nativeCompactions = [];
482
+ return pending;
483
+ }
484
+ restoreNativeCompactions(compactions) { this.nativeCompactions.unshift(...compactions); }
434
485
  async contextUsage() {
435
486
  try {
436
487
  const usage = await this.query.getContextUsage();
@@ -510,6 +561,14 @@ export class ClaudeAdapter {
510
561
  }
511
562
  }
512
563
  function finiteNumber(value) { const parsed = Number(value); return value !== null && value !== undefined && value !== "" && Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; }
564
+ function validThreshold(value) {
565
+ if (!Number.isInteger(value) || value < 1 || value > 99)
566
+ throw new Error("automatic compaction threshold must be an integer from 1 to 99");
567
+ return value;
568
+ }
569
+ function tokenLimit(maximum, percentage) {
570
+ return Math.max(1, Math.floor(maximum * percentage / 100));
571
+ }
513
572
  function isMissingClaudeSession(message) {
514
573
  return /no conversation found with session id|session .* not found|conversation .* not found/i.test(message);
515
574
  }
@@ -1,4 +1,4 @@
1
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
1
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
2
2
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
3
  export interface CodexLineResult {
4
4
  events: BuilderEvent[];
@@ -27,6 +27,13 @@ export declare class CodexAdapter implements BuilderAdapter {
27
27
  private providerSessionUsage?;
28
28
  private lastTurnTokens?;
29
29
  private stderr;
30
+ private nativeAutoCompactTokenLimit?;
31
+ private autoCompactionPrepared;
32
+ private preparedAutoCompactThreshold?;
33
+ private nativeCompactions;
34
+ private nativeCompactionSequence;
35
+ private manualCompactionInFlight;
36
+ private observedToolCalls;
30
37
  constructor(opts: BuilderAdapterOptions);
31
38
  buildInstruction(instruction: string): string;
32
39
  buildAppServerArgs(): string[];
@@ -35,7 +42,11 @@ export declare class CodexAdapter implements BuilderAdapter {
35
42
  sendTurn(instruction: string): Promise<TurnResult>;
36
43
  private sendTurnInternal;
37
44
  compact(): Promise<CompactResult>;
45
+ prepareAutoCompaction(thresholdPercent?: number | undefined): Promise<void>;
46
+ drainNativeCompactions(): NativeCompaction[];
47
+ restoreNativeCompactions(compactions: NativeCompaction[]): void;
38
48
  contextUsage(): Promise<ContextUsage | undefined>;
49
+ contextUsageAfterNativeCompaction(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
39
50
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
40
51
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
41
52
  sessionId(): string | undefined;
@@ -52,5 +63,6 @@ export declare class CodexAdapter implements BuilderAdapter {
52
63
  private handle;
53
64
  private waitFor;
54
65
  private disconnect;
66
+ private restartForAutoCompaction;
55
67
  private buildSkillsAppendix;
56
68
  }
@@ -43,6 +43,13 @@ export class CodexAdapter {
43
43
  providerSessionUsage;
44
44
  lastTurnTokens;
45
45
  stderr = "";
46
+ nativeAutoCompactTokenLimit;
47
+ autoCompactionPrepared = false;
48
+ preparedAutoCompactThreshold;
49
+ nativeCompactions = [];
50
+ nativeCompactionSequence = 0;
51
+ manualCompactionInFlight = false;
52
+ observedToolCalls = 0;
46
53
  constructor(opts) {
47
54
  this.opts = opts;
48
55
  this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
@@ -52,7 +59,13 @@ export class CodexAdapter {
52
59
  return [this.opts.systemPromptAppend, this.buildSkillsAppendix(), instruction]
53
60
  .filter((part) => Boolean(part)).join("\n\n");
54
61
  }
55
- buildAppServerArgs() { return ["app-server", "--listen", "stdio://"]; }
62
+ buildAppServerArgs() {
63
+ const args = ["app-server", "--listen", "stdio://"];
64
+ if (this.nativeAutoCompactTokenLimit !== undefined) {
65
+ args.push("-c", `model_auto_compact_token_limit=${this.nativeAutoCompactTokenLimit}`, "-c", 'model_auto_compact_token_limit_scope="total"');
66
+ }
67
+ return args;
68
+ }
56
69
  /** Deprecated fixture helper. Runtime execution uses only app-server. */
57
70
  buildArgs(instruction) {
58
71
  const args = ["exec", "--json", "--skip-git-repo-check", "--sandbox", this.opts.sandboxMode ?? "workspace-write", "-C", this.opts.cwd];
@@ -143,11 +156,17 @@ export class CodexAdapter {
143
156
  && this.usageRevision > usageRevision
144
157
  && this.usage !== undefined;
145
158
  }, 30_000);
146
- await Promise.all([
147
- this.request("thread/compact/start", { threadId: this._sessionId }),
148
- done,
149
- postCompactUsage,
150
- ]);
159
+ this.manualCompactionInFlight = true;
160
+ try {
161
+ await Promise.all([
162
+ this.request("thread/compact/start", { threadId: this._sessionId }),
163
+ done,
164
+ postCompactUsage,
165
+ ]);
166
+ }
167
+ finally {
168
+ this.manualCompactionInFlight = false;
169
+ }
151
170
  this.eventQueue.push({ kind: "session-transition", transition: "compacted" });
152
171
  return { ok: true };
153
172
  }
@@ -157,7 +176,54 @@ export class CodexAdapter {
157
176
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
158
177
  }
159
178
  }
179
+ async prepareAutoCompaction(thresholdPercent = this.opts.autoCompactThresholdPercent) {
180
+ if (thresholdPercent === undefined)
181
+ return;
182
+ const threshold = validThreshold(thresholdPercent);
183
+ if (this.autoCompactionPrepared && this.preparedAutoCompactThreshold === threshold)
184
+ return;
185
+ this.opts.autoCompactThresholdPercent = threshold;
186
+ await this.ensureThread();
187
+ // Codex reports its model context window in token-usage notifications, not
188
+ // in thread/start. Establish the otherwise idle thread with a constrained
189
+ // setup turn, then restart the app server with the provider-native ceiling
190
+ // before any Builder or QA work is sent.
191
+ if (!this.usage?.maximum) {
192
+ const toolsBefore = this.observedToolCalls;
193
+ const setup = await this.sendTurnInternal("Rafi internal initialization only. Do not call tools or modify files. Reply briefly that the context is ready.");
194
+ if (setup.isError)
195
+ throw new Error(`Codex automatic-compaction setup failed: ${setup.text.slice(0, 240)}`);
196
+ if (this.observedToolCalls !== toolsBefore)
197
+ throw new Error("Codex automatic-compaction setup unexpectedly called a tool");
198
+ }
199
+ const maximum = this.usage?.maximum;
200
+ if (!maximum || !Number.isFinite(maximum) || maximum <= 0) {
201
+ throw new Error("Codex did not report a model context window during automatic-compaction setup");
202
+ }
203
+ this.nativeAutoCompactTokenLimit = tokenLimit(maximum, threshold);
204
+ await this.restartForAutoCompaction();
205
+ await this.ensureThread();
206
+ this.autoCompactionPrepared = true;
207
+ this.preparedAutoCompactThreshold = threshold;
208
+ }
209
+ drainNativeCompactions() {
210
+ const pending = this.nativeCompactions;
211
+ this.nativeCompactions = [];
212
+ return pending;
213
+ }
214
+ restoreNativeCompactions(compactions) { this.nativeCompactions.unshift(...compactions); }
160
215
  async contextUsage() { return this.usage; }
216
+ async contextUsageAfterNativeCompaction(compaction) {
217
+ if (compaction.usageRevision === undefined || this.usageRevision > compaction.usageRevision)
218
+ return this.usage;
219
+ try {
220
+ await this.waitFor("thread/tokenUsage/updated", () => this.usageRevision > compaction.usageRevision, 5_000);
221
+ return this.usage;
222
+ }
223
+ catch {
224
+ return undefined;
225
+ }
226
+ }
161
227
  async sessionUsage() { return this.providerSessionUsage ? { ...this.providerSessionUsage } : undefined; }
162
228
  async switchSettings(settings) {
163
229
  this.opts.model = settings.model;
@@ -303,8 +369,10 @@ export class CodexAdapter {
303
369
  }
304
370
  catch { /* ignore non-protocol stdout */ } });
305
371
  child.stderr.on("data", (chunk) => { this.stderr = `${this.stderr}${chunk.toString()}`.slice(-8192); });
306
- child.on("error", (error) => this.disconnect(error));
307
- child.on("close", (code) => this.disconnect(new Error(`Codex app-server exited with code ${code ?? "unknown"}`)));
372
+ child.on("error", (error) => { if (this.process === child)
373
+ this.disconnect(error); });
374
+ child.on("close", (code) => { if (this.process === child)
375
+ this.disconnect(new Error(`Codex app-server exited with code ${code ?? "unknown"}`)); });
308
376
  await this.request("initialize", { clientInfo: { name: "rafi", title: "Rafi", version: "1" }, capabilities: { experimentalApi: true, requestAttestation: false } });
309
377
  this.write({ method: "initialized", params: {} });
310
378
  this.initialized = true;
@@ -341,6 +409,8 @@ export class CodexAdapter {
341
409
  const params = message.params ?? {};
342
410
  if (message.method === "item/started") {
343
411
  const item = params.item;
412
+ if (isCodexToolItem(item))
413
+ this.observedToolCalls += 1;
344
414
  const activity = codexItemActivity(item);
345
415
  if (activity)
346
416
  this.eventQueue.push({ kind: "activity", provider: "codex", ...activity });
@@ -351,8 +421,21 @@ export class CodexAdapter {
351
421
  this.activeText.push(item.text);
352
422
  this.eventQueue.push({ kind: "text", text: item.text });
353
423
  }
354
- else if (item?.type === "commandExecution")
424
+ else if (item?.type === "commandExecution") {
355
425
  this.eventQueue.push({ kind: "tool", name: "command_execution", input: { command: item.command } });
426
+ }
427
+ else if (item?.type === "contextCompaction" && !this.manualCompactionInFlight) {
428
+ // Completion is the provider's authoritative confirmation. Token-usage
429
+ // notifications may arrive before or after it, so do not make durable
430
+ // compaction accounting depend on their delivery order.
431
+ this.nativeCompactions.push({
432
+ id: `codex:${this._sessionId ?? "unknown"}:${Date.now()}:${++this.nativeCompactionSequence}`,
433
+ occurredAt: new Date().toISOString(),
434
+ provider: "codex",
435
+ usageRevision: this.usageRevision,
436
+ });
437
+ this.eventQueue.push({ kind: "session-transition", transition: "compacted", detail: "provider-native automatic compaction" });
438
+ }
356
439
  else {
357
440
  const activity = codexItemActivity(item, true);
358
441
  if (activity)
@@ -441,6 +524,16 @@ export class CodexAdapter {
441
524
  waiter.reject(error);
442
525
  this.notificationWaiters.clear();
443
526
  }
527
+ async restartForAutoCompaction() {
528
+ const child = this.process;
529
+ if (!child)
530
+ return;
531
+ this.process = undefined;
532
+ this.initialized = false;
533
+ this.threadAttached = false;
534
+ if (child.exitCode === null)
535
+ child.kill("SIGTERM");
536
+ }
444
537
  buildSkillsAppendix() {
445
538
  if (!this.opts.skills?.length)
446
539
  return undefined;
@@ -452,6 +545,14 @@ function optionalNonNegative(value) {
452
545
  const number = Number(value);
453
546
  return Number.isFinite(number) && number >= 0 ? number : undefined;
454
547
  }
548
+ function validThreshold(value) {
549
+ if (!Number.isInteger(value) || value < 1 || value > 99)
550
+ throw new Error("automatic compaction threshold must be an integer from 1 to 99");
551
+ return value;
552
+ }
553
+ function tokenLimit(maximum, percentage) {
554
+ return Math.max(1, Math.floor(maximum * percentage / 100));
555
+ }
455
556
  function activityPhase(phase) {
456
557
  if (phase === "planning")
457
558
  return "planning";
@@ -483,6 +584,9 @@ function codexItemActivity(item, completed = false) {
483
584
  return { state: completed ? "received response" : "writing response" };
484
585
  return undefined;
485
586
  }
587
+ function isCodexToolItem(item) {
588
+ return item?.type === "commandExecution" || item?.type === "mcpToolCall" || item?.type === "dynamicToolCall" || item?.type === "webSearch" || item?.type === "fileChange";
589
+ }
486
590
  function loadSkillMarkdown(cwd, skill) {
487
591
  const projectPath = join(cwd, ".agents", "skills", skill, "SKILL.md");
488
592
  if (existsSync(projectPath))
@@ -1,5 +1,5 @@
1
1
  import type { AgentRuntime } from "../runtimeAuth.js";
2
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, TurnResult } from "./types.js";
2
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, TurnResult } from "./types.js";
3
3
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
4
4
  export type TurnRecoveryChoice = "retry" | "switch" | "cancel";
5
5
  export interface RecoveringAdapterOptions {
@@ -29,6 +29,10 @@ export declare class RecoveringAdapter implements BuilderAdapter {
29
29
  adoptSessionRef(ref: ProviderSessionRefV1): void;
30
30
  validateSession(): Promise<SessionAvailabilityV1>;
31
31
  compact(): Promise<CompactResult>;
32
+ prepareAutoCompaction(thresholdPercent?: number): Promise<void>;
33
+ drainNativeCompactions(): import("./types.js").NativeCompaction[];
34
+ restoreNativeCompactions(compactions: NativeCompaction[]): void;
35
+ contextUsageAfterNativeCompaction(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
32
36
  contextUsage(): Promise<ContextUsage | undefined>;
33
37
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
34
38
  events(): AsyncIterable<BuilderEvent>;
@@ -67,6 +67,10 @@ export class RecoveringAdapter {
67
67
  return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped", detail: "wrapped adapter does not expose scoped session validation" });
68
68
  }
69
69
  compact() { return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
70
+ prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
71
+ drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
72
+ restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
73
+ contextUsageAfterNativeCompaction(compaction) { return this.adapter.contextUsageAfterNativeCompaction?.(compaction) ?? Promise.resolve(undefined); }
70
74
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
71
75
  sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
72
76
  events() {
@@ -122,6 +122,15 @@ export interface CompactResult {
122
122
  /** Preserve exact-session loss so lifecycle policy never treats it as an ordinary retryable compaction failure. */
123
123
  failure?: RuntimeFailure;
124
124
  }
125
+ /** A provider-confirmed compaction that happened without Rafi issuing /compact. */
126
+ export interface NativeCompaction {
127
+ /** Stable for the lifetime of an adapter; used for durable idempotency. */
128
+ id: string;
129
+ occurredAt: string;
130
+ provider: "claude" | "codex";
131
+ /** Adapter-local usage generation captured with the provider confirmation. */
132
+ usageRevision?: number;
133
+ }
125
134
  export interface ProviderSettingSwitch {
126
135
  model?: string;
127
136
  effort?: EffortLevel;
@@ -163,6 +172,8 @@ export interface BuilderAdapterOptions {
163
172
  systemPromptAppend?: string;
164
173
  /** Skill names to preload for this session (Claude: lazy-loaded; Codex: flattened). */
165
174
  skills?: string[];
175
+ /** Provider-native context ceiling, as a percentage of that provider's model window. */
176
+ autoCompactThresholdPercent?: number;
166
177
  }
167
178
  export interface BuilderAdapter {
168
179
  readonly agent: "claude" | "codex";
@@ -178,6 +189,18 @@ export interface BuilderAdapter {
178
189
  validateSession?(): Promise<SessionAvailabilityV1>;
179
190
  /** Provider-native compaction on the exact live conversation. */
180
191
  compact?(): Promise<CompactResult>;
192
+ /**
193
+ * Install and verify provider-native automatic compaction before role work is
194
+ * dispatched. This intentionally happens outside a work turn so a long,
195
+ * tool-heavy first turn is protected too.
196
+ */
197
+ prepareAutoCompaction?(thresholdPercent?: number): Promise<void>;
198
+ /** Consume provider-native compactions observed since the prior drain. */
199
+ drainNativeCompactions?(): NativeCompaction[];
200
+ /** Return a drained batch to the adapter when durable receipt failed. */
201
+ restoreNativeCompactions?(compactions: NativeCompaction[]): void;
202
+ /** Return occupancy known to postdate a native-compaction event, if available. */
203
+ contextUsageAfterNativeCompaction?(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
181
204
  /** Truthful provider context occupancy, when exposed by the provider. */
182
205
  contextUsage?(): Promise<ContextUsage | undefined>;
183
206
  /** Provider/session cumulative totals; distinct from live context occupancy. */
@@ -1,4 +1,4 @@
1
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "../adapters/types.js";
1
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "../adapters/types.js";
2
2
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
3
  export interface CurrentWorkflowIdentity {
4
4
  worktree: string;
@@ -26,6 +26,10 @@ export declare class CurrentWorkflowGuardAdapter implements BuilderAdapter {
26
26
  adoptSessionRef(ref: ProviderSessionRefV1): void;
27
27
  validateSession(): Promise<SessionAvailabilityV1>;
28
28
  compact(): Promise<CompactResult>;
29
+ prepareAutoCompaction(thresholdPercent?: number): Promise<void>;
30
+ drainNativeCompactions(): import("../adapters/types.js").NativeCompaction[];
31
+ restoreNativeCompactions(compactions: NativeCompaction[]): void;
32
+ contextUsageAfterNativeCompaction(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
29
33
  contextUsage(): Promise<ContextUsage | undefined>;
30
34
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
31
35
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
@@ -48,6 +48,10 @@ export class CurrentWorkflowGuardAdapter {
48
48
  adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
49
49
  validateSession() { assertCurrentWorkflowIdentity(this.cwd, this.expected); return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
50
50
  async compact() { assertCurrentWorkflowIdentity(this.cwd, this.expected); const result = await (this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" })); assertCurrentWorkflowIdentity(this.cwd, this.expected); return result; }
51
+ prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
52
+ drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
53
+ restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
54
+ contextUsageAfterNativeCompaction(compaction) { return this.adapter.contextUsageAfterNativeCompaction?.(compaction) ?? Promise.resolve(undefined); }
51
55
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
52
56
  sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
53
57
  switchSettings(settings) { return this.adapter.switchSettings?.(settings) ?? Promise.resolve({ ok: false, error: "settings switch unavailable" }); }
@@ -56,6 +56,8 @@ export interface BranchRunnerOptions {
56
56
  builderSessionStrategy?: SessionStrategy;
57
57
  qaSessionStrategy?: SessionStrategy;
58
58
  observeBuilder?: (builder: BuilderAdapter) => Promise<void>;
59
+ observeBuilderNativeCompactions?: (builder: BuilderAdapter, cwd: string) => Promise<void>;
60
+ observeQaNativeCompactions?: (adapter: BuilderAdapter) => Promise<void>;
59
61
  qaNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
60
62
  beforeBuilderTurn?: (adapter: BuilderAdapter, frozenAction: string, cwd: string) => Promise<BuilderAdapter>;
61
63
  builderSessionBoundary?: (adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string) => Promise<BuilderAdapter>;
@@ -173,7 +173,7 @@ export async function runBranchPlan(opts) {
173
173
  }
174
174
  builderWorkSessions += 1;
175
175
  viewer = opts.observeBuilder?.(builder);
176
- const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined);
176
+ const foreman = new Foreman(builder, opts.log, opts.notificationsEnabled, opts.qaEnabled, 3, undefined, undefined, undefined, opts.qaSessionStrategy ?? "compact", undefined, opts.builderSessionStrategy ?? "compact", undefined, opts.beforeBuilderTurn ? (adapter, action) => opts.beforeBuilderTurn(adapter, action, worktreePath) : undefined, undefined, undefined, async (adapter) => opts.observeBuilderNativeCompactions?.(adapter, worktreePath));
177
177
  const { result, status } = await foreman.runInstruction(ticketInstruction);
178
178
  builder = foreman.builderAdapter();
179
179
  workflowCheckpoint(opts.projectDir, opts.runId, "builder-after", node.ticket.id, { status: status.kind, sessionId: builder.sessionId(), worktree: worktreePath });
@@ -242,6 +242,7 @@ export async function runBranchPlan(opts) {
242
242
  ticket: node.ticket, builderWorktree: worktreePath, builderSummary: status.summary ?? result.text,
243
243
  qaStrategy: opts.qaSessionStrategy ?? "compact", state: qaStream, createQa: opts.createQa, maxCycles: 3,
244
244
  sessionBoundary: opts.qaSessionBoundary,
245
+ observeNativeCompactions: opts.observeQaNativeCompactions,
245
246
  evidence: (entry) => opts.log.write("qa-evidence", { ticket: node.ticket.id, ...entry }),
246
247
  fix: async (issues) => {
247
248
  if (!builder)
package/dist/cli/start.js CHANGED
@@ -37,7 +37,7 @@ import { eligibleTickets, evaluateTicketEligibility } from "../tickets/eligibili
37
37
  import { BUILTIN_BRANCH_PREFIX, validateBranchPrefix } from "../branch/prefix.js";
38
38
  import { captureCurrentWorkflowSessionIdentity, CurrentWorkflowChangedError, CurrentWorkflowGuardAdapter } from "../branch/currentGuard.js";
39
39
  import { ContinuityAdapter, ContinuityRecoveryRequiredError, SessionUnavailableContinuityError } from "../continuity.js";
40
- import { ContextCapabilityError, RoleSessionController } from "../sessionLifecycle.js";
40
+ import { ContextCapabilityError, RoleSessionController, ThresholdCompactionController } from "../sessionLifecycle.js";
41
41
  import { HandoffLoopError, HandoffService, parseBuilderHandoffRequest } from "../handoffs.js";
42
42
  import { captureWorkspaceIdentity, createProviderSessionRef, resolveUniqueSessionBinding } from "../sessionIdentity.js";
43
43
  import { SessionUnavailableError } from "../adapters/sessionFailure.js";
@@ -625,6 +625,7 @@ export function buildStartCommand() {
625
625
  fast: builderFast,
626
626
  systemPromptAppend: roleBundle.system || undefined,
627
627
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
628
+ autoCompactThresholdPercent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
628
629
  };
629
630
  const adapter = agent === "codex"
630
631
  ? new CodexAdapter(adapterOpts)
@@ -636,6 +637,7 @@ export function buildStartCommand() {
636
637
  throw new SessionUnavailableError({ runtime: "codex", phase: "preflight", dispatchState: "not-sent", executable: agentExecutable ?? "codex", cwd: builderCwd, diagnostics: availability.detail ?? `Codex session ${sessionRef.sessionId} is ${availability.status}`, availability });
637
638
  }
638
639
  }
640
+ await prepareNativeAutoCompaction(adapter);
639
641
  return !branchMode ? new CurrentWorkflowGuardAdapter(adapter, builderCwd) : adapter;
640
642
  };
641
643
  let activeContinuityRunId;
@@ -651,7 +653,8 @@ export function buildStartCommand() {
651
653
  role, source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
652
654
  reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact",
653
655
  settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
654
- auto_compact_threshold_percent: 50, compact_maximum: 10,
656
+ auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
657
+ compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
655
658
  };
656
659
  const liveRoleSettings = (role, base) => {
657
660
  const live = readAgentDefaults(cwd);
@@ -696,8 +699,10 @@ export function buildStartCommand() {
696
699
  fast: settings.fast,
697
700
  systemPromptAppend: roleBundle.system || undefined,
698
701
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
702
+ autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
699
703
  };
700
704
  const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
705
+ await prepareNativeAutoCompaction(created);
701
706
  return !branchMode ? new CurrentWorkflowGuardAdapter(created, builderCwd) : created;
702
707
  };
703
708
  const createQaForSettings = async (qaCwd, settings) => {
@@ -722,8 +727,11 @@ export function buildStartCommand() {
722
727
  fast: settings.fast,
723
728
  systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
724
729
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
730
+ autoCompactThresholdPercent: settings.auto_compact_threshold_percent,
725
731
  };
726
- return settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
732
+ const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
733
+ await prepareNativeAutoCompaction(created);
734
+ return created;
727
735
  };
728
736
  const createRecoveringBuilder = async (builderCwd, sessionId, sessionRef) => {
729
737
  if (sessionId && !sessionRef) {
@@ -910,6 +918,16 @@ export function buildStartCommand() {
910
918
  return continuousBuilder(transfer.successor, builderCwd, runId, input.next);
911
919
  };
912
920
  const createRawQa = async (qaCwd, sessionRef) => {
921
+ // Isolated QA conversations are intentionally disposable. Resolve live
922
+ // QA settings for each one so a threshold-only update applies to the
923
+ // very next review instead of waiting for a nonexistent session reuse.
924
+ const qaSettings = liveRoleSettings("qa", resolvedContinuitySettings("qa"));
925
+ const ready = await ensureRuntimeReadyForCommand(qaCwd, qaSettings.make, {
926
+ label: "live QA settings",
927
+ yes: true,
928
+ allowSwitch: false,
929
+ model: qaSettings.model === "default" ? undefined : qaSettings.model,
930
+ });
913
931
  const qaPolicy = new PermissionPolicy(config.permissions, qaCwd);
914
932
  const roleBundle = loadRoleBundle("qa", { projectDir: qaCwd });
915
933
  const adapterOpts = {
@@ -917,21 +935,24 @@ export function buildStartCommand() {
917
935
  configRoot: cwd,
918
936
  sessionRole: "qa",
919
937
  sessionStream: "qa",
920
- runtimeExecutable: qaExecutable,
938
+ runtimeExecutable: ready.executable,
921
939
  runtimePhase: "qa",
922
- model: qaModel,
940
+ model: qaSettings.model === "default" ? undefined : qaSettings.model,
923
941
  ...(sessionRef ? { resumeSessionRef: sessionRef } : {}),
924
942
  sessionGeneration: sessionRef?.generation ?? 0,
925
943
  workspaceIdentity: captureWorkspaceIdentity(qaCwd),
926
944
  permission: createPermissionHandler(qaPolicy, log),
927
- effort: qaEffort,
928
- fast: qaFast,
945
+ effort: explicitEffort(qaSettings.reasoning),
946
+ fast: qaSettings.fast,
929
947
  systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
930
948
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
949
+ autoCompactThresholdPercent: qaSettings.auto_compact_threshold_percent,
931
950
  };
932
- return qaAgent === "codex"
951
+ const created = qaSettings.make === "codex"
933
952
  ? new CodexAdapter(adapterOpts)
934
953
  : await ClaudeAdapter.create(adapterOpts);
954
+ await prepareNativeAutoCompaction(created);
955
+ return created;
935
956
  };
936
957
  const createRecoveringQa = async (qaCwd, sessionRef) => {
937
958
  const initial = await createRawQa(qaCwd, sessionRef);
@@ -1152,7 +1173,7 @@ export function buildStartCommand() {
1152
1173
  runId: activeContinuityRunId,
1153
1174
  role: "qa",
1154
1175
  initialSettings: qaSettings,
1155
- historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1176
+ historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, activeContinuityRunId, "qa"),
1156
1177
  readSettings: () => liveRoleSettings("qa", qaSettings),
1157
1178
  settingsBoundary: (input) => applyQaSettingsBoundary(activeContinuityRunId, qaCwd, input),
1158
1179
  settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
@@ -1185,6 +1206,19 @@ export function buildStartCommand() {
1185
1206
  });
1186
1207
  return (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
1187
1208
  };
1209
+ const observeQaNativeCompactions = async (adapter) => {
1210
+ if (!activeContinuityRunId)
1211
+ return;
1212
+ const qaSettings = resolvedContinuitySettings("qa");
1213
+ const controller = new ThresholdCompactionController({
1214
+ projectDir: cwd,
1215
+ runId: activeContinuityRunId,
1216
+ role: "qa",
1217
+ initialSettings: qaSettings,
1218
+ historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, activeContinuityRunId, "qa"),
1219
+ });
1220
+ await controller.observeNativeCompactions(adapter);
1221
+ };
1188
1222
  const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
1189
1223
  if (branchMode) {
1190
1224
  const ticketsConfig = loadTicketsConfig(cwd);
@@ -1416,7 +1450,8 @@ export function buildStartCommand() {
1416
1450
  role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
1417
1451
  reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision,
1418
1452
  display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
1419
- auto_compact_threshold_percent: 50, compact_maximum: 10,
1453
+ auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
1454
+ compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
1420
1455
  };
1421
1456
  const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1422
1457
  let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
@@ -1432,7 +1467,7 @@ export function buildStartCommand() {
1432
1467
  return existing;
1433
1468
  const controller = RoleSessionController.managed({
1434
1469
  projectDir: cwd, runId: masterRun.runId, role: "builder", initialSettings: capturedBranchBuilder,
1435
- historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1470
+ historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, masterRun.runId, "builder"),
1436
1471
  readSettings: () => liveRoleSettings("builder", capturedBranchBuilder),
1437
1472
  settingsBoundary: (input) => applyBuilderSettingsBoundary(masterRun.runId, worktreePath, input),
1438
1473
  settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
@@ -1547,8 +1582,10 @@ export function buildStartCommand() {
1547
1582
  },
1548
1583
  qaNonconvergence,
1549
1584
  beforeBuilderTurn: async (adapter, frozenAction, worktreePath) => (await branchContextController(worktreePath).atSafeBoundary(adapter, frozenAction)).adapter,
1585
+ observeBuilderNativeCompactions: async (adapter, worktreePath) => { await branchContextController(worktreePath).observeNativeCompactions(adapter); },
1550
1586
  builderSessionBoundary: async (adapter, frozenAction, strategy, worktreePath) => (await branchContextController(worktreePath).atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter,
1551
1587
  qaSessionBoundary,
1588
+ observeQaNativeCompactions,
1552
1589
  });
1553
1590
  if (branchSessionFailure) {
1554
1591
  const failure = branchSessionFailure;
@@ -1653,7 +1690,7 @@ export function buildStartCommand() {
1653
1690
  auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
1654
1691
  compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
1655
1692
  };
1656
- const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false, auto_compact_threshold_percent: 50, compact_maximum: 10, settings_revision: settingsRevision };
1693
+ const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false, auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50, compact_maximum: roleDefaults.qa?.compact_maximum ?? 10, settings_revision: settingsRevision };
1657
1694
  let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
1658
1695
  tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
1659
1696
  runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
@@ -1692,7 +1729,7 @@ export function buildStartCommand() {
1692
1729
  runId: buildRun.runId,
1693
1730
  role: "builder",
1694
1731
  initialSettings: capturedBuilder,
1695
- historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1732
+ historicalCountUncertain: recoveryCompactionHistoryUncertain(cwd, recoveryRecord, buildRun.runId, "builder"),
1696
1733
  readSettings: () => liveRoleSettings("builder", capturedBuilder),
1697
1734
  settingsBoundary: (input) => applyBuilderSettingsBoundary(buildRun.runId, cwd, input),
1698
1735
  settingsAdopted: (settings, active) => liveStatusReporter?.updateState({
@@ -1732,7 +1769,7 @@ export function buildStartCommand() {
1732
1769
  return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1733
1770
  },
1734
1771
  });
1735
- const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary);
1772
+ const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary, observeQaNativeCompactions, async (adapter) => { await contextController.observeNativeCompactions(adapter); });
1736
1773
  activeBuilderForStatus = () => foreman.builderAdapter();
1737
1774
  const statusReporter = liveStatusReporter = new AgentStatusReporter({
1738
1775
  runId: buildRun.runId,
@@ -1916,6 +1953,19 @@ export function buildStartCommand() {
1916
1953
  }
1917
1954
  });
1918
1955
  }
1956
+ async function prepareNativeAutoCompaction(adapter) {
1957
+ if (!adapter.prepareAutoCompaction) {
1958
+ await adapter.close().catch(() => { });
1959
+ throw new Error(`${adapter.agent} does not support provider-native automatic compaction`);
1960
+ }
1961
+ try {
1962
+ await adapter.prepareAutoCompaction();
1963
+ }
1964
+ catch (error) {
1965
+ await adapter.close().catch(() => { });
1966
+ throw error;
1967
+ }
1968
+ }
1919
1969
  function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
1920
1970
  return async (context) => {
1921
1971
  if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY)
@@ -2018,6 +2068,19 @@ function readAgentDefaults(projectDir) {
2018
2068
  function explicitDefaultValue(value) {
2019
2069
  return value && value !== "default" ? value : undefined;
2020
2070
  }
2071
+ function recoveryCompactionHistoryUncertain(projectDir, recovery, runId, role) {
2072
+ if (!recovery)
2073
+ return false;
2074
+ if (recovery.legacy || !recovery.runDecisions || !runId)
2075
+ return true;
2076
+ const db = new WorkflowDb(projectDir);
2077
+ try {
2078
+ return db.hasUncheckpointedRoleTurn(runId, role);
2079
+ }
2080
+ finally {
2081
+ db.close();
2082
+ }
2083
+ }
2021
2084
  /** Make the executable branch plan exactly match each explicit root-to-tip stack. */
2022
2085
  export function applyExplicitStackTopology(plan, stacks, delivery, branchPrefix) {
2023
2086
  const nodeByTicket = new Map(plan.nodes.map((node) => [node.ticket.id, node]));
@@ -1,5 +1,5 @@
1
1
  import type { ContinuityDelta, ResolvedAgentSettings } from "rafi-spec";
2
- import type { BuilderAdapter, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, RuntimeFailure, TurnResult } from "./adapters/types.js";
2
+ import type { BuilderAdapter, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, RuntimeFailure, TurnResult } from "./adapters/types.js";
3
3
  export declare const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
4
4
  export declare class ContinuityValidationError extends Error {
5
5
  readonly problems: string[];
@@ -70,6 +70,10 @@ export declare class ContinuityAdapter implements BuilderAdapter {
70
70
  adoptSessionRef(ref: import("rafi-spec").ProviderSessionRefV1): void;
71
71
  validateSession(): Promise<import("rafi-spec").SessionAvailabilityV1>;
72
72
  compact(): Promise<CompactResult>;
73
+ prepareAutoCompaction(thresholdPercent?: number): Promise<void>;
74
+ drainNativeCompactions(): import("./adapters/types.js").NativeCompaction[];
75
+ restoreNativeCompactions(compactions: NativeCompaction[]): void;
76
+ contextUsageAfterNativeCompaction(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
73
77
  contextUsage(): Promise<ContextUsage | undefined>;
74
78
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
75
79
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
@@ -232,6 +232,10 @@ export class ContinuityAdapter {
232
232
  adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
233
233
  validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
234
234
  compact() { return this.adapter.compact ? this.adapter.compact() : Promise.resolve({ ok: false, error: "provider adapter does not expose native compaction" }); }
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); }
235
239
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
236
240
  sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
237
241
  switchSettings(settings) { return this.adapter.switchSettings ? this.adapter.switchSettings(settings) : Promise.resolve({ ok: false, error: "provider adapter does not support settings changes" }); }
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);
@@ -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
@@ -51,6 +51,7 @@ async function oneReview(opts, cycle) {
51
51
  qa = await opts.createQa(snapshot.path);
52
52
  const turn = await qa.sendTurn(handoff);
53
53
  const status = parseStepStatus(turn.text);
54
+ await opts.observeNativeCompactions?.(qa);
54
55
  opts.state.reviews += 1;
55
56
  opts.state.sessionId = qa.sessionId();
56
57
  opts.state.sessionRef = qa.sessionRef?.();
@@ -49,6 +49,8 @@ export declare class RoleSessionController {
49
49
  static managed(options: ThresholdCompactionOptions): RoleSessionController;
50
50
  atSafeBoundary(adapter: BuilderAdapter, frozenAction: string): Promise<ContextBoundaryResult>;
51
51
  atWorkSessionBoundary(adapter: BuilderAdapter, frozenAction: string, strategy?: SessionStrategy): Promise<ContextBoundaryResult>;
52
+ /** Record provider-native automatic compaction immediately after a role turn. */
53
+ observeNativeCompactions(adapter: BuilderAdapter): Promise<number | undefined>;
52
54
  effectiveSettings(): ResolvedAgentSettings;
53
55
  effectiveThreshold(): number;
54
56
  next(durableHandoff: string, nextSettings?: ResolvedAgentSettings): Promise<SessionBoundary>;
@@ -125,10 +127,13 @@ export declare class ThresholdCompactionController {
125
127
  atWorkSessionBoundary(adapter: BuilderAdapter, frozenAction: string, strategy?: SessionStrategy): Promise<ContextBoundaryResult>;
126
128
  effectiveSettings(): ResolvedAgentSettings;
127
129
  effectiveThreshold(): number;
130
+ /** Persist provider-triggered compactions at a host-owned boundary. */
131
+ observeNativeCompactions(adapter: BuilderAdapter, afterSample?: ContextSample): Promise<number | undefined>;
128
132
  private compactSession;
129
133
  private performHandoff;
130
134
  private adoptSettings;
131
135
  private measure;
136
+ private sampleFromUsage;
132
137
  private compactionCount;
133
138
  private handoffGeneration;
134
139
  private threshold;
@@ -67,6 +67,13 @@ export class RoleSessionController {
67
67
  this.adapter = result.adapter;
68
68
  return result;
69
69
  }
70
+ /** Record provider-native automatic compaction immediately after a role turn. */
71
+ async observeNativeCompactions(adapter) {
72
+ if (!this.thresholdController)
73
+ throw new Error("this role session controller does not own threshold lifecycle policy");
74
+ await this.adoptManagedAdapter(adapter);
75
+ return this.thresholdController.observeNativeCompactions(adapter);
76
+ }
70
77
  effectiveSettings() {
71
78
  return this.thresholdController?.effectiveSettings() ?? this.options.settings;
72
79
  }
@@ -329,6 +336,9 @@ export class ThresholdCompactionController {
329
336
  }
330
337
  return { adapter, action: "continued", sample, effectiveThreshold: this.threshold(), compactionCount: 0 };
331
338
  }
339
+ // Drain first so a provider-confirmed compaction is persisted with a
340
+ // measurement taken after the event, regardless of provider event order.
341
+ const nativeCount = await this.observeNativeCompactions(adapter);
332
342
  let sample = await this.measure(adapter, "provider-query");
333
343
  const threshold = this.threshold();
334
344
  let session = adapter.sessionRef?.() ?? adapter.sessionId();
@@ -340,12 +350,20 @@ export class ThresholdCompactionController {
340
350
  let count = 0;
341
351
  try {
342
352
  db.recordContextSample(sample);
343
- count = db.successfulCompactionCount(this.options.runId, this.options.role, session);
353
+ count = nativeCount ?? db.successfulCompactionCount(this.options.runId, this.options.role, session);
344
354
  }
345
355
  finally {
346
356
  db.close();
347
357
  }
358
+ // An interrupted dispatched turn leaves both conversation state and native
359
+ // compaction accounting unknowable. Do not continue that exact session,
360
+ // even if its next occupancy sample happens to be below threshold.
361
+ if (this.historicalCountUncertain) {
362
+ return this.performHandoff(adapter, sample, count, frozenAction, "a prior provider turn lacked a durable completion checkpoint; handing off rather than reusing an uncertain session");
363
+ }
348
364
  if ((sample.percentage ?? -1) < threshold) {
365
+ if (nativeCount !== undefined && count >= (this.settings.compact_maximum ?? 10))
366
+ return this.performHandoff(adapter, sample, count, frozenAction, `compact maximum ${this.settings.compact_maximum ?? 10} reached after provider-native compaction`);
349
367
  this.armed = true;
350
368
  return { adapter, action: "below-threshold", sample, effectiveThreshold: threshold, compactionCount: count };
351
369
  }
@@ -385,12 +403,13 @@ export class ThresholdCompactionController {
385
403
  /** Apply the configured ordinary work-session boundary without bypassing handoff or compaction accounting. */
386
404
  async atWorkSessionBoundary(adapter, frozenAction, strategy = this.settings.session_strategy) {
387
405
  adapter = await this.adoptSettings(adapter, frozenAction);
406
+ const nativeCount = await this.observeNativeCompactions(adapter);
388
407
  const sample = await this.measure(adapter, "provider-query");
389
408
  const db = new WorkflowDb(this.options.projectDir);
390
409
  let count = 0;
391
410
  try {
392
411
  db.recordContextSample(sample);
393
- count = db.successfulCompactionCount(this.options.runId, this.options.role, adapter.sessionRef?.() ?? adapter.sessionId());
412
+ count = nativeCount ?? db.successfulCompactionCount(this.options.runId, this.options.role, adapter.sessionRef?.() ?? adapter.sessionId());
394
413
  }
395
414
  finally {
396
415
  db.close();
@@ -403,6 +422,54 @@ export class ThresholdCompactionController {
403
422
  }
404
423
  effectiveSettings() { return this.settings; }
405
424
  effectiveThreshold() { return this.threshold(); }
425
+ /** Persist provider-triggered compactions at a host-owned boundary. */
426
+ async observeNativeCompactions(adapter, afterSample) {
427
+ const observed = adapter.drainNativeCompactions?.() ?? [];
428
+ if (observed.length === 0)
429
+ return undefined;
430
+ const sessionId = adapter.sessionId();
431
+ const sessionRef = adapter.sessionRef?.();
432
+ if (!sessionId || !sessionRef) {
433
+ adapter.restoreNativeCompactions?.(observed);
434
+ throw new ContextCapabilityError(this.options.runId, this.options.role, "provider reported automatic compaction without a validated session identity");
435
+ }
436
+ const sessionKey = providerSessionKey(sessionRef);
437
+ const db = new WorkflowDb(this.options.projectDir);
438
+ try {
439
+ const keys = [];
440
+ for (const compaction of observed) {
441
+ const idempotencyKey = `native-compact:${this.options.runId}:${this.options.role}:${sha(`${sessionKey}:${compaction.id}`).slice(0, 32)}`;
442
+ keys.push(idempotencyKey);
443
+ const prior = db.startCompactionAttempt({
444
+ idempotencyKey, runId: this.options.runId, role: this.options.role,
445
+ providerSessionId: sessionId, sessionRef, sessionKey,
446
+ crossingKey: `provider-native:${compaction.provider}:${compaction.id}`,
447
+ });
448
+ // Confirmation, not occupancy telemetry, is authoritative for the
449
+ // count. Persist it first so an out-of-order or unavailable usage
450
+ // event cannot lose a completed provider compaction.
451
+ if (prior.status === "started")
452
+ db.finishCompactionAttempt(idempotencyKey, { ok: true });
453
+ }
454
+ const usage = afterSample ? undefined : await adapter.contextUsageAfterNativeCompaction?.(observed.at(-1));
455
+ const sample = afterSample ?? (usage ? this.sampleFromUsage(adapter, usage, "post-compact") : undefined);
456
+ if (sample) {
457
+ db.recordContextSample(sample);
458
+ for (const key of keys)
459
+ db.recordCompactionAfterSample(key, sample);
460
+ }
461
+ return db.successfulCompactionCount(this.options.runId, this.options.role, sessionRef);
462
+ }
463
+ catch (error) {
464
+ // Do not lose a provider confirmation merely because its durable receipt
465
+ // encountered a transient database/measurement failure.
466
+ adapter.restoreNativeCompactions?.(observed);
467
+ throw error;
468
+ }
469
+ finally {
470
+ db.close();
471
+ }
472
+ }
406
473
  async compactSession(adapter, sample, count, frozenAction, crossingKey, thresholdCrossing, freshBootstrap) {
407
474
  const threshold = this.threshold();
408
475
  const sessionId = adapter.sessionId();
@@ -558,6 +625,7 @@ export class ThresholdCompactionController {
558
625
  || next.model !== this.settings.model
559
626
  || next.reasoning !== this.settings.reasoning
560
627
  || next.fast !== this.settings.fast;
628
+ const nativeCompactionChanged = next.auto_compact_threshold_percent !== this.settings.auto_compact_threshold_percent;
561
629
  let adoptedAdapter = adapter;
562
630
  if (providerControlsChanged) {
563
631
  if (this.options.settingsBoundary) {
@@ -577,6 +645,16 @@ export class ThresholdCompactionController {
577
645
  throw new ContextCapabilityError(this.options.runId, this.options.role, `settings revision ${next.settings_revision} requested ${next.make}, but the adopted session reports ${adoptedAdapter.agent}`);
578
646
  }
579
647
  }
648
+ if (nativeCompactionChanged && !providerControlsChanged) {
649
+ if (!adapter.prepareAutoCompaction)
650
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `settings revision ${next.settings_revision} cannot apply provider-native automatic compaction`);
651
+ try {
652
+ await adapter.prepareAutoCompaction(next.auto_compact_threshold_percent);
653
+ }
654
+ catch (error) {
655
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `settings revision ${next.settings_revision} could not apply the automatic-compaction threshold: ${sanitize(error)}`);
656
+ }
657
+ }
580
658
  this.settings = next;
581
659
  this.runOnlyThreshold = undefined;
582
660
  const db = new WorkflowDb(this.options.projectDir);
@@ -606,6 +684,9 @@ export class ThresholdCompactionController {
606
684
  }
607
685
  if (!usage || !Number.isFinite(usage.used))
608
686
  throw new ContextCapabilityError(this.options.runId, this.options.role, `truthful context occupancy was unavailable after ${attempts} provider attempt(s)`);
687
+ return this.sampleFromUsage(adapter, usage, source);
688
+ }
689
+ sampleFromUsage(adapter, usage, source) {
609
690
  const percentage = usage.percentage ?? (usage.maximum ? usage.used / usage.maximum * 100 : undefined);
610
691
  if (percentage === undefined || !Number.isFinite(percentage))
611
692
  throw new ContextCapabilityError(this.options.runId, this.options.role, "provider context occupancy lacks a usable percentage and maximum");
@@ -1,5 +1,5 @@
1
1
  import type { ContextSample, SessionUsageSample } from "rafi-spec";
2
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./adapters/types.js";
2
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./adapters/types.js";
3
3
  export interface AgentStatusSnapshot {
4
4
  role: "builder" | "qa";
5
5
  provider: "claude" | "codex";
@@ -82,6 +82,10 @@ export declare class RoleStatusAdapter implements BuilderAdapter {
82
82
  adoptSessionRef(ref: import("rafi-spec").ProviderSessionRefV1): void;
83
83
  validateSession(): Promise<import("rafi-spec").SessionAvailabilityV1>;
84
84
  compact(): Promise<CompactResult>;
85
+ prepareAutoCompaction(thresholdPercent?: number): Promise<void>;
86
+ drainNativeCompactions(): import("./adapters/types.js").NativeCompaction[];
87
+ restoreNativeCompactions(compactions: NativeCompaction[]): void;
88
+ contextUsageAfterNativeCompaction(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
85
89
  contextUsage(): Promise<ContextUsage | undefined>;
86
90
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
87
91
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
@@ -177,6 +177,10 @@ export class RoleStatusAdapter {
177
177
  adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
178
178
  validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
179
179
  compact() { this.onActive(this.adapter); return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
180
+ prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
181
+ drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
182
+ restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
183
+ contextUsageAfterNativeCompaction(compaction) { return this.adapter.contextUsageAfterNativeCompaction?.(compaction) ?? Promise.resolve(undefined); }
180
184
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
181
185
  sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
182
186
  switchSettings(settings) { return this.adapter.switchSettings?.(settings) ?? Promise.resolve({ ok: false, error: "settings switch unavailable" }); }
@@ -192,10 +192,18 @@ export declare class WorkflowDb {
192
192
  afterSample?: ContextSample;
193
193
  error?: string;
194
194
  }, now?: Date): CompactionAttemptRecord;
195
+ /** Attach an eventually available measurement without changing confirmed accounting. */
196
+ recordCompactionAfterSample(idempotencyKey: string, afterSample: ContextSample, now?: Date): CompactionAttemptRecord;
195
197
  compactionAttempt(idempotencyKey: string): CompactionAttemptRecord | undefined;
196
198
  successfulCompactionCount(runId: string, role: "builder" | "qa", providerSession?: string | ProviderSessionRefV1): number;
197
199
  recordRecoveryDecision(receipt: BuildRecoveryDecisionReceipt): void;
198
200
  recoveryDecisions(runId: string): BuildRecoveryDecisionReceipt[];
201
+ /**
202
+ * A process may die after dispatching a provider turn but before its durable
203
+ * continuity checkpoint. The provider may have compacted during that gap, so
204
+ * recovery must not treat that role session's compaction history as complete.
205
+ */
206
+ hasUncheckpointedRoleTurn(runId: string, role: "builder" | "qa"): boolean;
199
207
  beginPublication(runId: string, intent: unknown, previousDigests: unknown, now?: Date): PublicationTransaction;
200
208
  updatePublication(transactionId: string, status: PublicationTransaction["status"], now?: Date): PublicationTransaction;
201
209
  publication(transactionId: string): PublicationTransaction | undefined;
@@ -455,6 +455,17 @@ export class WorkflowDb {
455
455
  this.insertEvent(prior.runId, input.ok ? "compaction_succeeded" : "compaction_failed", `context:${prior.role}`, { idempotencyKey, providerSessionId: prior.providerSessionId, sessionKey: prior.sessionKey, crossingKey: prior.crossingKey }, now.toISOString());
456
456
  return this.compactionAttempt(idempotencyKey);
457
457
  }
458
+ /** Attach an eventually available measurement without changing confirmed accounting. */
459
+ recordCompactionAfterSample(idempotencyKey, afterSample, now = new Date()) {
460
+ const prior = this.compactionAttempt(idempotencyKey);
461
+ if (!prior)
462
+ throw new Error(`compaction attempt not found: ${idempotencyKey}`);
463
+ if (prior.status !== "succeeded")
464
+ return prior;
465
+ this.db.prepare("UPDATE compaction_attempts SET after_sample_json=?,updated_at=? WHERE idempotency_key=?")
466
+ .run(json(afterSample), now.toISOString(), idempotencyKey);
467
+ return this.compactionAttempt(idempotencyKey);
468
+ }
458
469
  compactionAttempt(idempotencyKey) {
459
470
  const row = this.db.prepare("SELECT * FROM compaction_attempts WHERE idempotency_key=?").get(idempotencyKey);
460
471
  return row ? compactionAttemptFromRow(row) : undefined;
@@ -481,6 +492,25 @@ export class WorkflowDb {
481
492
  recoveryDecisions(runId) {
482
493
  return this.db.prepare("SELECT receipt_json FROM recovery_decisions WHERE run_id=? ORDER BY decision_id").all(runId).map((row) => parseJson(row.receipt_json));
483
494
  }
495
+ /**
496
+ * A process may die after dispatching a provider turn but before its durable
497
+ * continuity checkpoint. The provider may have compacted during that gap, so
498
+ * recovery must not treat that role session's compaction history as complete.
499
+ */
500
+ hasUncheckpointedRoleTurn(runId, role) {
501
+ const events = this.continuityEvents(runId);
502
+ let startSequence = 0;
503
+ for (const event of events) {
504
+ if (event.kind === "turn_started" && event.role === "host" && event.payload.role === role) {
505
+ startSequence = event.sequence;
506
+ }
507
+ else if (startSequence && event.sequence > startSequence
508
+ && (event.kind === "turn_completed" || event.kind === "turn_completed_after_repair" || event.kind === "fresh_successor_accepted")) {
509
+ startSequence = 0;
510
+ }
511
+ }
512
+ return startSequence > 0;
513
+ }
484
514
  beginPublication(runId, intent, previousDigests, now = new Date()) {
485
515
  const transactionId = randomUUID();
486
516
  const at = now.toISOString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.7",
3
+ "version": "1.7.9",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -158,8 +158,8 @@
158
158
  "@anthropic-ai/claude-agent-sdk": "^0.3.148"
159
159
  },
160
160
  "dependencies": {
161
- "special-agents": "0.8.5",
162
- "rafi-spec": "0.8.5",
161
+ "special-agents": "0.8.7",
162
+ "rafi-spec": "0.8.7",
163
163
  "@clack/prompts": "^1.4.0",
164
164
  "ajv": "^8.20.0",
165
165
  "ajv-formats": "^3.0.1",