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.
@@ -3,7 +3,6 @@ import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./a
3
3
  import { sanitizeDiagnostics } from "./runtimeReadiness.js";
4
4
  import { WorkflowDb } from "./workflowDb.js";
5
5
  import { providerSessionKey } from "./sessionIdentity.js";
6
- import { AsyncQueue } from "./util/asyncQueue.js";
7
6
  export class RoleSessionValidationError extends Error {
8
7
  role;
9
8
  availability;
@@ -60,15 +59,6 @@ export class RoleSessionController {
60
59
  this.adapter = result.adapter;
61
60
  return result;
62
61
  }
63
- /** Return the stable role facade whose sendTurn() contains the lifecycle gate. */
64
- manage(adapter) {
65
- if (!this.thresholdController)
66
- throw new Error("this role session controller does not own threshold lifecycle policy");
67
- const managed = this.thresholdController.manage(adapter);
68
- this.adapter = managed;
69
- return managed;
70
- }
71
- contextSnapshot() { return this.thresholdController?.snapshot(); }
72
62
  async atWorkSessionBoundary(adapter, frozenAction, strategy) {
73
63
  if (!this.thresholdController)
74
64
  throw new Error("this role session controller does not own threshold lifecycle policy");
@@ -77,6 +67,13 @@ export class RoleSessionController {
77
67
  this.adapter = result.adapter;
78
68
  return result;
79
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
+ }
80
77
  effectiveSettings() {
81
78
  return this.thresholdController?.effectiveSettings() ?? this.options.settings;
82
79
  }
@@ -147,13 +144,6 @@ export class RoleSessionController {
147
144
  const result = this.boundary("missing", validated ? "exact session unavailable; transferred to a validated fresh successor" : "exact session unavailable; used the compatibility fresh fallback", issue);
148
145
  return result;
149
146
  }
150
- if ((this.options.role === "builder" || this.options.role === "qa") && this.options.settings.session_strategy === "compact") {
151
- const usage = await this.adapter.contextUsage?.().catch(() => undefined);
152
- const percentage = usage?.percentage ?? (usage?.maximum ? usage.used / usage.maximum * 100 : undefined);
153
- if (percentage !== undefined && percentage < (this.options.settings.auto_compact_threshold_percent ?? 50)) {
154
- return this.boundary("continued", `context ${percentage.toFixed(1)}% remains below the configured ${this.options.settings.auto_compact_threshold_percent ?? 50}% ceiling`);
155
- }
156
- }
157
147
  let firstError = "";
158
148
  for (let attempt = 1; attempt <= 2; attempt++) {
159
149
  try {
@@ -312,1101 +302,405 @@ export class ContextCapabilityError extends Error {
312
302
  this.role = role;
313
303
  }
314
304
  }
315
- /** Stable facade: every Builder/QA provider turn enters the coordinator here. */
316
- export class ManagedRoleAdapter {
317
- coordinator;
318
- adapter;
319
- queue = new AsyncQueue();
320
- eventPump;
321
- processing = Promise.resolve();
322
- closed = false;
323
- activeSessionKeyValue;
324
- constructor(coordinator, adapter) {
325
- this.coordinator = coordinator;
326
- this.adapter = adapter;
327
- this.attachDispatcher(adapter);
328
- this.pump(adapter);
329
- }
330
- get agent() { return this.adapter.agent; }
331
- sendTurn(text) { return this.coordinator.dispatch(this, text); }
332
- sessionId() { return this.adapter.sessionId(); }
333
- sessionRef() { return this.adapter.sessionRef?.(); }
334
- adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
335
- validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
336
- compact() { return this.coordinator.compactExplicit(this); }
337
- prepareContextManagement(policy) { if (!this.adapter.prepareContextManagement)
338
- return Promise.reject(new Error("native context management unavailable")); return this.adapter.prepareContextManagement(policy); }
339
- updateContextManagement(policy) { if (!this.adapter.updateContextManagement)
340
- return Promise.reject(new Error("native context reconfiguration unavailable")); return this.adapter.updateContextManagement(policy); }
341
- interruptTurnAtCompactionBoundary(providerEventId) { return this.adapter.interruptTurnAtCompactionBoundary?.(providerEventId) ?? Promise.resolve({ ok: false, error: "compaction-boundary interruption unavailable", providerEventId }); }
342
- contextUsage() { return Promise.resolve(this.coordinator.contextUsage(this)); }
343
- sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
344
- switchSettings(settings) { return this.adapter.switchSettings?.(settings) ?? Promise.resolve({ ok: false, error: "settings switch unavailable" }); }
345
- events() { return this.queue; }
346
- underlying() { return this.adapter; }
347
- noteActiveSession(key) {
348
- const previous = this.activeSessionKeyValue;
349
- this.activeSessionKeyValue = key;
350
- return previous !== key ? previous : undefined;
351
- }
352
- activeSessionKey() { return this.activeSessionKeyValue; }
353
- isClosed() { return this.closed; }
354
- replace(adapter) {
355
- if (adapter === this.adapter)
356
- return;
357
- this.adapter = adapter;
358
- this.attachDispatcher(adapter);
359
- this.pump(adapter);
360
- }
361
- async settleEvents() {
362
- // A provider may resolve sendTurn() in the same microtask that enqueues its
363
- // final usage/compaction event. Wait until both the event pump and its
364
- // serialized coordinator work reach a stable point.
365
- for (let attempt = 0; attempt < 10; attempt++) {
366
- const processing = this.processing;
367
- await Promise.resolve();
368
- await processing;
369
- await Promise.resolve();
370
- if (processing === this.processing)
371
- return;
372
- }
373
- await this.processing;
374
- }
375
- async close() {
376
- if (this.closed)
377
- return;
378
- this.closed = true;
379
- this.coordinator.unregister(this);
380
- await this.adapter.close().catch(() => { });
381
- await this.eventPump?.catch(() => { });
382
- this.queue.close();
383
- }
384
- pump(source) {
385
- this.eventPump = (async () => {
386
- try {
387
- for await (const event of source.events()) {
388
- if (source !== this.adapter && event.kind !== "turn-complete")
389
- continue;
390
- this.queue.push(event);
391
- this.processing = this.processing.then(() => this.coordinator.observe(this, source, event));
392
- await this.processing;
393
- }
394
- }
395
- catch (error) {
396
- if (!this.closed)
397
- this.queue.push({ kind: "error", message: error instanceof Error ? error.message : String(error) });
398
- }
399
- })();
400
- }
401
- attachDispatcher(adapter) {
402
- adapter.installManagedTurnDispatcher?.((text, invoke) => this.coordinator.dispatchFollowup(this, text, invoke));
403
- }
404
- }
405
- /** Per-scoped-session context state machine and the sole occupancy sample store. */
305
+ /** Threshold-triggered native compaction at host-owned safe boundaries. */
406
306
  export class ThresholdCompactionController {
407
307
  options;
408
308
  settings;
309
+ armed = true;
310
+ crossing = 0;
311
+ seenSessions = new Set();
312
+ runOnlyThreshold;
313
+ bootstrapScheduled = false;
314
+ bootstrapRecoveryDepth = 0;
409
315
  historicalCountUncertain;
410
- managed = new WeakMap();
411
- states = new Map();
412
- nativeAttempts = new Map();
413
- observedCompactionPhases = new Set();
414
- settingsWatchers = new WeakMap();
415
- active;
416
- provisional = 0;
417
- refreshingSettings;
418
- dispatchDepth = 0;
419
316
  constructor(options) {
420
317
  this.options = options;
421
318
  this.settings = normalizeContextSettings(options.initialSettings);
422
319
  this.historicalCountUncertain = Boolean(options.historicalCountUncertain);
423
320
  }
424
- manage(adapter) {
425
- if (adapter instanceof ManagedRoleAdapter && adapter.coordinator === this)
426
- return adapter;
427
- const existing = this.managed.get(adapter);
428
- if (existing)
429
- return existing;
430
- const wrapper = new ManagedRoleAdapter(this, adapter);
431
- this.managed.set(adapter, wrapper);
432
- return wrapper;
433
- }
434
321
  async atSafeBoundary(adapter, frozenAction) {
435
- const managed = this.manage(adapter);
436
- this.active = managed;
437
- await this.refreshSettings(managed, frozenAction, true);
438
- let state = await this.ensureInitialized(managed, frozenAction);
439
- state = await this.recoverMeasurementFailure(managed, state, frozenAction);
440
- if (state.capabilityFailure)
441
- throw this.capability(state.capabilityFailure);
442
- state = await this.measureIntoState(managed, state, "provider-query").catch((error) => { throw this.capability(error); });
443
- if ((state.latest.percentage ?? 100) >= this.threshold()) {
444
- return this.enforceThreshold(managed, state, frozenAction, true);
445
- }
446
- this.transition(state, "armed");
447
- return this.result(managed, state, "below-threshold");
448
- }
449
- async atWorkSessionBoundary(adapter, frozenAction, strategy = this.settings.session_strategy) {
450
- const managed = this.manage(adapter);
451
- this.active = managed;
452
- await this.refreshSettings(managed, frozenAction, true);
453
- let state = await this.ensureInitialized(managed, frozenAction);
454
- state = await this.recoverMeasurementFailure(managed, state, frozenAction);
455
- if (state.capabilityFailure)
456
- throw this.capability(state.capabilityFailure);
457
- state = await this.measureIntoState(managed, state, "provider-query");
458
- if (strategy === "fresh") {
459
- return this.performHandoff(managed, state, frozenAction, "ordinary session_strategy=fresh boundary requires a validated fresh successor", false);
460
- }
461
- // `compact` is a threshold policy, not an unconditional work-boundary compact.
462
- if ((state.latest.percentage ?? 100) < this.threshold()) {
463
- this.transition(state, "armed");
464
- return this.result(managed, state, "below-threshold");
465
- }
466
- return this.enforceThreshold(managed, state, frozenAction, true);
467
- }
468
- effectiveSettings() { return this.settings; }
469
- effectiveThreshold() { return this.threshold(); }
470
- snapshot() {
471
- if (!this.active)
472
- return undefined;
473
- const state = this.stateFor(this.active, false);
474
- if (!state)
475
- return undefined;
476
- return {
477
- role: this.options.role, provider: this.active.agent, model: this.settings.model,
478
- lifecycleState: state.state, contextSample: state.latest,
479
- configuredCeilingPercent: this.threshold(),
480
- ...(state.installedNativeTokenLimit === undefined ? {} : { installedNativeTokenLimit: state.installedNativeTokenLimit }),
481
- ...(state.installedNativePercent === undefined ? {} : { installedNativePercent: state.installedNativePercent }),
482
- compactionCount: state.compactionCount, compactMaximum: this.maximum(), settingsRevision: this.settings.settings_revision,
483
- };
484
- }
485
- contextUsage(adapter) {
486
- const state = this.stateFor(adapter, false);
487
- if (!state || state.latest.freshness !== "fresh" || state.latest.used === undefined)
488
- return undefined;
489
- return {
490
- used: state.latest.used, maximum: state.latest.maximum, percentage: state.latest.percentage,
491
- observedAt: state.latest.observedAt, source: state.latest.source, sequence: state.latest.sequence,
492
- sessionId: state.latest.providerSessionId, model: state.latest.model,
493
- };
494
- }
495
- async dispatch(managed, frozenAction, resumed = false) {
496
- if (this.dispatchDepth > 0 && this.active === managed) {
497
- // Nested role wrappers (for example continuity repair) remain inside the
498
- // already-established gate and native policy.
499
- return managed.underlying().sendTurn(frozenAction);
500
- }
501
- this.active = managed;
502
- await this.refreshSettings(managed, frozenAction, true);
503
- let state = await this.ensureInitialized(managed, frozenAction);
504
- state = await this.recoverMeasurementFailure(managed, state, frozenAction);
505
- if (state.capabilityFailure)
506
- throw this.capability(state.capabilityFailure);
507
- if (state.state === "handoff_required") {
508
- const moved = await this.performHandoff(managed, state, frozenAction, state.handoffAfterTurn ?? "durable lifecycle recovery requires a validated successor", false);
509
- state = this.stateFor(moved.adapter, true);
510
- }
511
- if ((state.latest.percentage ?? -1) >= this.threshold() || state.state === "threshold_pending" || state.state === "retrying" || state.state === "compacted_unverified") {
512
- const enforced = await this.enforceThreshold(managed, state, frozenAction, true);
513
- state = this.stateFor(enforced.adapter, true);
514
- }
515
- let result;
516
- this.dispatchDepth += 1;
517
- try {
518
- result = await managed.underlying().sendTurn(frozenAction);
519
- }
520
- finally {
521
- this.dispatchDepth -= 1;
522
- }
523
- await managed.settleEvents();
524
- // A continuity-owned validated handoff may adopt its successor while the
525
- // outer action is still running. Establish independent successor state
526
- // before interpreting any post-turn lifecycle flags.
527
- state = this.stateFor(managed, false) ?? await this.ensureInitialized(managed, frozenAction);
528
- if (state.capabilityFailure) {
529
- // The in-flight action settled. Preserve its result, then fence the next turn.
530
- this.options.report?.({ kind: "capability-failure", detail: state.capabilityFailure, sample: state.latest });
531
- return result;
532
- }
533
- if (state.boundaryHandoff) {
534
- if (!result.interrupted) {
535
- state.capabilityFailure = "the provider completed a turn after the compact maximum boundary instead of returning a distinct interrupted result";
536
- this.persist(state);
537
- return result;
322
+ adapter = await this.adoptSettings(adapter, frozenAction);
323
+ if (!adapter.sessionId() && !this.bootstrapScheduled) {
324
+ this.bootstrapScheduled = true;
325
+ const sample = {
326
+ version: 1, runId: this.options.runId, role: this.options.role, provider: adapter.agent,
327
+ model: this.settings.model, observedAt: this.now().toISOString(), source: "provider-query", freshness: "measuring",
328
+ settingsRevision: this.settings.settings_revision, compactionCount: 0, handoffGeneration: this.handoffGeneration(),
329
+ };
330
+ const db = new WorkflowDb(this.options.projectDir);
331
+ try {
332
+ db.recordContextSample(sample);
538
333
  }
539
- if (resumed)
540
- throw this.capability("a fresh successor immediately repeated a maximum-triggered compaction boundary");
541
- await this.performHandoff(managed, state, frozenAction, state.boundaryHandoff.reason, true);
542
- return this.dispatch(managed, frozenAction, true);
543
- }
544
- try {
545
- await this.refreshSettings(managed, frozenAction, true);
546
- }
547
- catch (error) {
548
- state.capabilityFailure = sanitize(error);
549
- this.persist(state);
550
- return result;
551
- }
552
- try {
553
- state = await this.measureIntoState(managed, this.stateFor(managed, true), "provider-query");
554
- }
555
- catch (error) {
556
- state.capabilityFailure = `context measurement failed after provider action: ${sanitize(error)}`;
557
- this.persist(state);
558
- return result;
559
- }
560
- if ((state.latest.percentage ?? 100) >= this.threshold() || state.retryAfterTurn || state.handoffAfterTurn) {
561
- await this.enforceThreshold(managed, state, frozenAction, true);
562
- }
563
- return result;
564
- }
565
- /** Same gate for continuity repair, recursive continuation, and runtime retry. */
566
- async dispatchFollowup(managed, frozenAction, invoke) {
567
- if (this.dispatchDepth === 0 || this.active !== managed) {
568
- throw this.capability("a wrapper attempted a provider follow-up outside the managed role dispatcher");
569
- }
570
- let state = this.stateFor(managed, false) ?? await this.ensureInitialized(managed, frozenAction);
571
- if (state.capabilityFailure)
572
- throw this.capability(state.capabilityFailure);
573
- if (state.state === "handoff_required") {
574
- await this.performHandoff(managed, state, frozenAction, state.handoffAfterTurn ?? "durable lifecycle recovery requires a validated successor", false);
575
- state = this.stateFor(managed, true);
576
- }
577
- if ((state.latest.percentage ?? -1) >= this.threshold()
578
- || state.state === "threshold_pending" || state.state === "retrying" || state.state === "compacted_unverified") {
579
- await this.enforceThreshold(managed, state, frozenAction, true);
580
- }
581
- return invoke();
582
- }
583
- async compactExplicit(managed) {
584
- try {
585
- const state = await this.ensureInitialized(managed, "explicit native compaction");
586
- const result = await this.compactAndVerify(managed, state, "explicit native compaction", true);
587
- return { ok: result.action === "compacted" || result.action === "handed-off" };
588
- }
589
- catch (error) {
590
- if (error instanceof SessionUnavailableError)
591
- return { ok: false, error: error.message, failure: error.failure };
592
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
593
- }
594
- }
595
- unregister(managed) {
596
- this.settingsWatchers.get(managed)?.stop();
597
- this.settingsWatchers.delete(managed);
598
- const sessionKey = managed.activeSessionKey();
334
+ finally {
335
+ db.close();
336
+ }
337
+ return { adapter, action: "continued", sample, effectiveThreshold: this.threshold(), compactionCount: 0 };
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);
342
+ let sample = await this.measure(adapter, "provider-query");
343
+ const threshold = this.threshold();
344
+ let session = adapter.sessionRef?.() ?? adapter.sessionId();
345
+ let sessionKey = adapterSessionKey(adapter);
346
+ const firstObservedSession = Boolean(sessionKey && !this.seenSessions.has(sessionKey));
347
+ if (sessionKey)
348
+ this.seenSessions.add(sessionKey);
599
349
  const db = new WorkflowDb(this.options.projectDir);
350
+ let count = 0;
600
351
  try {
601
- if (sessionKey)
602
- db.clearRoleAdapterActive(this.options.runId, this.options.role, sessionKey);
352
+ db.recordContextSample(sample);
353
+ count = nativeCount ?? db.successfulCompactionCount(this.options.runId, this.options.role, session);
603
354
  }
604
355
  finally {
605
356
  db.close();
606
357
  }
607
- if (this.active === managed)
608
- this.active = undefined;
609
- }
610
- async observe(managed, source, event) {
611
- if (source !== managed.underlying())
612
- return;
613
- if (event.kind === "context-usage") {
614
- const state = this.stateFor(managed, false);
615
- if (!state)
616
- return;
617
- this.acceptUsage(managed, state, event, event.source ?? "provider-event");
618
- return;
619
- }
620
- if (event.kind === "context-compaction")
621
- await this.observeCompaction(managed, event);
622
- }
623
- async ensureInitialized(managed, frozenAction) {
624
- let existing = this.stateFor(managed, false);
625
- if (existing && existing.settingsRevision === this.settings.settings_revision && existing.model === this.settings.model) {
626
- this.markActive(managed, existing);
627
- this.ensureSettingsWatcher(managed, frozenAction);
628
- return existing;
629
- }
630
- const raw = managed.underlying();
631
- const prepared = await this.installNative(raw, this.settings, !existing);
632
- const key = adapterStateKey(raw) ?? `pending:${this.options.role}:${++this.provisional}`;
633
- if (this.options.requireNativeContextManagement && key.includes(":unscoped:")) {
634
- throw this.capability("provider initialized context management without a location-scoped session reference");
635
- }
636
- existing = this.states.get(key);
637
- if (!existing)
638
- existing = this.recoverOrCreateState(managed, key, frozenAction);
639
- existing.installedNativeTokenLimit = prepared.installedNativeTokenLimit;
640
- existing.installedNativePercent = prepared.installedNativePercent;
641
- existing.settingsRevision = this.settings.settings_revision;
642
- existing.model = this.settings.model;
643
- if (prepared.sample)
644
- this.acceptUsage(managed, existing, prepared.sample, prepared.sample.source ?? "provider-query");
645
- if (existing.latest.freshness !== "fresh")
646
- existing = await this.measureIntoState(managed, existing, "provider-query");
647
- if (existing.state === "native_compacting" || existing.state === "host_compacting") {
648
- this.reconcileIncompleteCompaction(managed, existing);
649
- }
650
- else if (existing.state === "compacted_unverified") {
651
- if ((existing.latest.percentage ?? 100) < this.threshold())
652
- this.transition(existing, "armed");
653
- else
654
- existing.handoffAfterTurn = "recovered compaction remained unverified at or above the configured ceiling";
655
- }
656
- else if (existing.state !== "handoff_required") {
657
- this.transition(existing, (existing.latest.percentage ?? 100) < this.threshold() ? "armed" : "threshold_pending");
658
- }
659
- this.markActive(managed, existing);
660
- this.ensureSettingsWatcher(managed, frozenAction);
661
- return existing;
662
- }
663
- async installNative(adapter, settings, initial) {
664
- const strict = Boolean(this.options.requireNativeContextManagement);
665
- if (strict && (!adapter.prepareContextManagement || !adapter.updateContextManagement || !adapter.interruptTurnAtCompactionBoundary)) {
666
- throw this.capability(`${adapter.agent} does not expose prepare, live update, and compaction-boundary interruption capabilities`);
667
- }
668
- const known = this.persistedMaximum(adapter, settings.model);
669
- const policy = {
670
- role: this.options.role, configuredThresholdPercent: settings.auto_compact_threshold_percent,
671
- compactMaximum: settings.compact_maximum, settingsRevision: settings.settings_revision, model: settings.model,
672
- providerSequenceStart: this.providerSequence(adapter), ...(known ? { knownModelContextWindow: known } : {}),
673
- nativeCompactionEnabled: true,
674
- };
675
- const method = initial ? adapter.prepareContextManagement : adapter.updateContextManagement;
676
- if (method) {
677
- const prepared = await method.call(adapter, policy);
678
- if (prepared.installedNativePercent > settings.auto_compact_threshold_percent + 0.01) {
679
- throw this.capability(`installed native ceiling ${prepared.installedNativePercent.toFixed(2)}% is later than configured ${settings.auto_compact_threshold_percent}%`);
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
+ }
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`);
367
+ this.armed = true;
368
+ return { adapter, action: "below-threshold", sample, effectiveThreshold: threshold, compactionCount: count };
369
+ }
370
+ if (!this.armed)
371
+ return { adapter, action: "continued", sample, effectiveThreshold: threshold, compactionCount: count };
372
+ this.armed = false;
373
+ this.crossing += 1;
374
+ let freshBootstrap = firstObservedSession;
375
+ // A settings command published while the action settled is newer intent.
376
+ const revisionBeforeSettle = this.settings.settings_revision;
377
+ const sessionBeforeSettle = adapterSessionKey(adapter);
378
+ adapter = await this.adoptSettings(adapter, frozenAction);
379
+ if (this.settings.settings_revision !== revisionBeforeSettle || adapterSessionKey(adapter) !== sessionBeforeSettle) {
380
+ sample = await this.measure(adapter, "provider-query");
381
+ session = adapter.sessionRef?.() ?? adapter.sessionId();
382
+ sessionKey = adapterSessionKey(adapter);
383
+ freshBootstrap = Boolean(sessionKey && !this.seenSessions.has(sessionKey));
384
+ if (sessionKey)
385
+ this.seenSessions.add(sessionKey);
386
+ const refreshed = new WorkflowDb(this.options.projectDir);
387
+ try {
388
+ refreshed.recordContextSample(sample);
389
+ count = refreshed.successfulCompactionCount(this.options.runId, this.options.role, adapter.sessionRef?.() ?? adapter.sessionId());
390
+ }
391
+ finally {
392
+ refreshed.close();
680
393
  }
681
- return prepared;
682
394
  }
683
- const usage = await adapter.contextUsage?.();
684
- if (!usage?.maximum)
685
- throw this.capability("provider context occupancy and model maximum are unavailable");
686
- const configuredTokenLimit = Math.max(1, Math.floor(usage.maximum * settings.auto_compact_threshold_percent / 100));
687
- return { modelContextWindow: usage.maximum, configuredTokenLimit, installedNativeTokenLimit: configuredTokenLimit, installedNativePercent: settings.auto_compact_threshold_percent, sample: usage };
395
+ const revisedThreshold = this.threshold();
396
+ if ((sample.percentage ?? -1) < revisedThreshold) {
397
+ this.armed = true;
398
+ return { adapter, action: "continued", sample, effectiveThreshold: revisedThreshold, compactionCount: count };
399
+ }
400
+ const crossingKey = `${sessionKey ?? "missing"}:${this.crossing}:${revisedThreshold}`;
401
+ return this.compactSession(adapter, sample, count, frozenAction, crossingKey, true, freshBootstrap);
688
402
  }
689
- recoverOrCreateState(managed, key, frozenAction) {
403
+ /** Apply the configured ordinary work-session boundary without bypassing handoff or compaction accounting. */
404
+ async atWorkSessionBoundary(adapter, frozenAction, strategy = this.settings.session_strategy) {
405
+ adapter = await this.adoptSettings(adapter, frozenAction);
406
+ const nativeCount = await this.observeNativeCompactions(adapter);
407
+ const sample = await this.measure(adapter, "provider-query");
690
408
  const db = new WorkflowDb(this.options.projectDir);
409
+ let count = 0;
691
410
  try {
692
- const prior = db.thresholdGenerations(this.options.runId, this.options.role, key).at(-1);
693
- if (prior && prior.model === this.settings.model && prior.settingsRevision === this.settings.settings_revision) {
694
- const state = {
695
- key, generationId: prior.generationId, generation: prior.generation, state: prior.state,
696
- settingsRevision: prior.settingsRevision, model: prior.model,
697
- latest: prior.latestSample ?? this.measuringSample(managed, key, prior.generation),
698
- latestProviderSequence: prior.latestSample?.sequence ?? 0,
699
- installedNativeTokenLimit: prior.installedNativeTokenLimit,
700
- installedNativePercent: prior.installedNativePercent,
701
- compactionCount: db.successfulCompactionCount(this.options.runId, this.options.role, managed.sessionRef?.() ?? managed.sessionId()),
702
- };
703
- this.states.set(key, state);
704
- return state;
705
- }
706
- const generation = (prior?.generation ?? 0) + 1;
707
- const state = {
708
- key, generation, generationId: this.generationId(key, generation), state: "initializing",
709
- settingsRevision: this.settings.settings_revision, model: this.settings.model,
710
- latest: this.measuringSample(managed, key, generation), latestProviderSequence: 0,
711
- compactionCount: db.successfulCompactionCount(this.options.runId, this.options.role, managed.sessionRef?.() ?? managed.sessionId()),
712
- };
713
- this.states.set(key, state);
714
- this.persist(state, frozenAction);
715
- return state;
411
+ db.recordContextSample(sample);
412
+ count = nativeCount ?? db.successfulCompactionCount(this.options.runId, this.options.role, adapter.sessionRef?.() ?? adapter.sessionId());
716
413
  }
717
414
  finally {
718
415
  db.close();
719
416
  }
417
+ if (strategy === "fresh") {
418
+ return this.performHandoff(adapter, sample, count, frozenAction, "ordinary session_strategy=fresh boundary requires a validated fresh successor");
419
+ }
420
+ const crossingKey = `${adapterSessionKey(adapter) ?? "missing"}:ordinary:${count + 1}:${sha(frozenAction).slice(0, 20)}`;
421
+ return this.compactSession(adapter, sample, count, frozenAction, crossingKey, false, false);
720
422
  }
721
- reconcileIncompleteCompaction(managed, state) {
423
+ effectiveSettings() { return this.settings; }
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);
722
437
  const db = new WorkflowDb(this.options.projectDir);
723
438
  try {
724
- const incomplete = db.compactionAttempts(this.options.runId, this.options.role, managed.sessionRef?.() ?? managed.sessionId())
725
- .filter((attempt) => attempt.status === "started" && attempt.thresholdGenerationId === state.generationId);
726
- if (!incomplete.length) {
727
- this.transition(state, (state.latest.percentage ?? 100) < this.threshold() ? "armed" : "retrying");
728
- return;
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 });
729
453
  }
730
- if ((state.latest.percentage ?? 100) < this.threshold()) {
731
- for (const attempt of incomplete)
732
- db.finishCompactionAttempt(attempt.idempotencyKey, { ok: true, afterSample: state.latest });
733
- state.compactionCount = db.successfulCompactionCount(this.options.runId, this.options.role, managed.sessionRef?.() ?? managed.sessionId());
734
- this.transition(state, "armed");
735
- }
736
- else {
737
- for (const attempt of incomplete)
738
- db.finishCompactionAttempt(attempt.idempotencyKey, { ok: true, status: "unverified", error: "process restarted before provider completion could be reconciled" });
739
- state.compactionCount = db.successfulCompactionCount(this.options.runId, this.options.role, managed.sessionRef?.() ?? managed.sessionId());
740
- state.handoffAfterTurn = "an in-flight compaction could not be reconciled after recovery";
741
- this.transition(state, "compacted_unverified");
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);
742
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;
743
468
  }
744
469
  finally {
745
470
  db.close();
746
471
  }
747
472
  }
748
- stateFor(managed, required) {
749
- const key = adapterStateKey(managed.underlying());
750
- const state = key ? this.states.get(key) : undefined;
751
- if (!state && required)
752
- throw this.capability("managed role adapter has no initialized scoped-session state");
753
- return state;
754
- }
755
- async measureIntoState(managed, state, source, requireNewerThan) {
756
- const attempts = Math.max(1, this.options.contextRetries ?? 3);
757
- let lastError = "truthful context occupancy is unavailable";
758
- for (let attempt = 0; attempt < attempts; attempt++) {
759
- let usage;
473
+ async compactSession(adapter, sample, count, frozenAction, crossingKey, thresholdCrossing, freshBootstrap) {
474
+ const threshold = this.threshold();
475
+ const sessionId = adapter.sessionId();
476
+ const sessionRef = adapter.sessionRef?.();
477
+ const sessionKey = sessionRef ? providerSessionKey(sessionRef) : undefined;
478
+ if (this.historicalCountUncertain)
479
+ return this.performHandoff(adapter, sample, count, frozenAction, "historical compaction count is uncertain; handing off rather than risking the configured maximum");
480
+ if (!sessionId)
481
+ return this.performHandoff(adapter, sample, count, frozenAction, "the provider session identity is unavailable");
482
+ if (!sessionRef)
483
+ return this.performHandoff(adapter, sample, count, frozenAction, "the provider session has no validated location-scoped identity");
484
+ if (count >= (this.settings.compact_maximum ?? 10))
485
+ return this.performHandoff(adapter, sample, count, frozenAction, `compact maximum ${this.settings.compact_maximum ?? 10} reached`);
486
+ if (!adapter.compact)
487
+ return this.performHandoff(adapter, sample, count, frozenAction, "native compaction is unavailable for this provider session");
488
+ const idempotencyKey = `compact:${this.options.runId}:${this.options.role}:${sha(crossingKey).slice(0, 24)}`;
489
+ const attemptDb = new WorkflowDb(this.options.projectDir);
490
+ const prior = attemptDb.startCompactionAttempt({ idempotencyKey, runId: this.options.runId, role: this.options.role, providerSessionId: sessionId, sessionRef, sessionKey, crossingKey, beforeSample: sample });
491
+ attemptDb.close();
492
+ if (prior.status === "succeeded" && prior.afterSample) {
493
+ const replayDb = new WorkflowDb(this.options.projectDir);
760
494
  try {
761
- usage = await managed.underlying().contextUsage?.();
762
- }
763
- catch (error) {
764
- lastError = sanitize(error);
495
+ count = replayDb.successfulCompactionCount(this.options.runId, this.options.role, sessionRef ?? sessionId);
765
496
  }
766
- if (usage && Number.isFinite(usage.used) && (usage.percentage !== undefined || usage.maximum !== undefined)) {
767
- const accepted = this.acceptUsage(managed, state, usage, source, requireNewerThan);
768
- if (accepted)
769
- return state;
770
- lastError = "provider returned only a stale or out-of-order context sample";
497
+ finally {
498
+ replayDb.close();
771
499
  }
772
- if (attempt + 1 < attempts)
773
- await new Promise((resolve) => setTimeout(resolve, 100));
500
+ return { adapter, action: "compacted", sample: prior.afterSample, effectiveThreshold: threshold, compactionCount: count };
774
501
  }
775
- throw this.capability(`${lastError} after ${attempts} bounded provider query attempt(s)`);
776
- }
777
- async recoverMeasurementFailure(managed, state, frozenAction) {
778
- if (!state.capabilityFailure?.startsWith("context measurement failed after provider action:"))
779
- return state;
502
+ this.options.report?.({ kind: "compacting", detail: thresholdCrossing
503
+ ? `context ${sample.percentage?.toFixed(1)}% reached ${threshold}%`
504
+ : `ordinary compact session boundary (${count}/${this.settings.compact_maximum ?? 10})`, sample });
505
+ let compacted;
780
506
  try {
781
- state = await this.measureIntoState(managed, state, "provider-query");
782
- state.capabilityFailure = undefined;
783
- this.transition(state, (state.latest.percentage ?? 100) < this.threshold() ? "armed" : "threshold_pending");
507
+ compacted = await adapter.compact();
784
508
  }
785
509
  catch (error) {
786
- state.capabilityFailure = `context measurement failed after provider action: ${sanitize(error)}`;
787
- this.persist(state, frozenAction);
788
- }
789
- return state;
790
- }
791
- acceptUsage(managed, state, usage, source, requireNewerThan) {
792
- if (!Number.isFinite(usage.used))
793
- return false;
794
- const percentage = usage.percentage ?? (usage.maximum ? usage.used / usage.maximum * 100 : undefined);
795
- if (percentage === undefined || !Number.isFinite(percentage))
796
- return false;
797
- if (usage.sessionId && managed.sessionId() && usage.sessionId !== managed.sessionId())
798
- return false;
799
- if (usage.model && this.settings.model !== "default" && usage.model !== this.settings.model)
800
- return false;
801
- const sequence = usage.sequence;
802
- if (sequence !== undefined && sequence === state.latestProviderSequence && requireNewerThan === undefined && state.latest.freshness === "fresh")
803
- return true;
804
- if (sequence !== undefined && sequence < state.latestProviderSequence)
805
- return false;
806
- if (requireNewerThan !== undefined && sequence !== undefined && sequence <= requireNewerThan)
807
- return false;
808
- if (requireNewerThan !== undefined && sequence === undefined
809
- && state.latest.used === usage.used && state.latest.maximum === usage.maximum && state.latest.percentage === percentage)
810
- return false;
811
- if (sequence !== undefined)
812
- state.latestProviderSequence = sequence;
813
- if (state.capabilityFailure?.startsWith("context measurement failed after provider action:"))
814
- state.capabilityFailure = undefined;
815
- const ref = managed.sessionRef?.();
816
- state.compactionCount = this.compactionCount(ref ?? managed.sessionId());
817
- state.latest = {
818
- version: 1, runId: this.options.runId, role: this.options.role, provider: managed.agent,
819
- providerSessionId: managed.sessionId(), ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : { sessionKey: state.key }),
820
- model: this.settings.model, observedAt: usage.observedAt ?? this.now().toISOString(), source, freshness: "fresh",
821
- used: usage.used, maximum: usage.maximum, percentage, settingsRevision: this.settings.settings_revision,
822
- compactionCount: state.compactionCount, handoffGeneration: this.handoffGeneration(),
823
- ...(sequence === undefined ? {} : { sequence }), thresholdGeneration: state.generation,
824
- };
825
- const db = new WorkflowDb(this.options.projectDir);
826
- try {
827
- db.recordContextSample(state.latest);
828
- }
829
- finally {
830
- db.close();
831
- }
832
- this.persist(state);
833
- this.options.report?.({
834
- kind: "context-sample",
835
- detail: `authoritative context ${percentage.toFixed(1)}% (${usage.used}${usage.maximum === undefined ? "" : `/${usage.maximum}`})`,
836
- sample: state.latest,
837
- });
838
- if (percentage >= this.threshold() && state.state === "armed")
839
- this.beginGeneration(state, "threshold_pending");
840
- return true;
841
- }
842
- async enforceThreshold(managed, state, frozenAction, allowHandoff) {
843
- if (state.handoffAfterTurn)
844
- return this.performHandoff(managed, state, frozenAction, state.handoffAfterTurn, false);
845
- if (this.historicalCountUncertain)
846
- return this.performHandoff(managed, state, frozenAction, "historical compaction count is uncertain; handing off rather than risking the configured maximum", false);
847
- if ((state.latest.percentage ?? 100) < this.threshold() && !state.retryAfterTurn) {
848
- this.transition(state, "armed");
849
- return this.result(managed, state, "below-threshold");
850
- }
851
- if (state.compactionCount >= this.maximum())
852
- return this.performHandoff(managed, state, frozenAction, `compact maximum ${this.maximum()} reached`, false);
853
- return this.compactAndVerify(managed, state, frozenAction, allowHandoff);
854
- }
855
- async compactAndVerify(managed, state, frozenAction, allowHandoff) {
856
- const raw = managed.underlying();
857
- if (!raw.compact) {
858
- if (allowHandoff)
859
- return this.performHandoff(managed, state, frozenAction, "native compaction is unavailable", false);
860
- throw this.capability("fresh successor does not expose native compaction");
510
+ compacted = error instanceof SessionUnavailableError
511
+ ? { ok: false, error: error.message, failure: error.failure }
512
+ : { ok: false, error: error instanceof Error ? error.message : String(error) };
861
513
  }
862
- let failure = "native compaction failed";
863
- for (let attempt = 1; attempt <= 2; attempt++) {
864
- const terminalAttempts = this.generationTerminalAttempts(managed, state);
865
- if (terminalAttempts >= 2) {
866
- failure = "the threshold generation exhausted its one bounded compaction retry";
867
- break;
868
- }
869
- state.compactionCount = this.compactionCount(managed.sessionRef?.() ?? managed.sessionId());
870
- if (state.compactionCount >= this.maximum())
871
- break;
872
- if (attempt > 1)
873
- this.transition(state, "retrying");
874
- else if (state.state === "armed")
875
- this.beginGeneration(state, "threshold_pending");
876
- this.transition(state, "host_compacting");
877
- const beforeSample = state.latest;
878
- const beforeProviderSequence = state.latestProviderSequence;
879
- this.invalidateSample(managed, state);
880
- const crossingKey = `${state.key}:${state.generation}:${attempt}:${sha(frozenAction).slice(0, 20)}`;
881
- const idempotencyKey = `compact:${this.options.runId}:${this.options.role}:${sha(crossingKey).slice(0, 24)}`;
882
- const db = new WorkflowDb(this.options.projectDir);
883
- const prior = db.startCompactionAttempt({
884
- idempotencyKey, runId: this.options.runId, role: this.options.role,
885
- providerSessionId: managed.sessionId(), sessionRef: managed.sessionRef?.(), sessionKey: state.key,
886
- crossingKey, beforeSample, origin: "rafi-manual", thresholdGenerationId: state.generationId,
887
- });
888
- db.close();
889
- if (prior.status !== "started") {
890
- const recoveredSample = prior.afterSample ?? prior.beforeSample;
891
- if (recoveredSample) {
892
- state.latest = recoveredSample;
893
- state.latestProviderSequence = Math.max(state.latestProviderSequence, recoveredSample.sequence ?? 0);
894
- }
895
- state.compactionCount = this.compactionCount(managed.sessionRef?.() ?? managed.sessionId());
896
- if (prior.status === "succeeded" && prior.afterSample && (prior.afterSample.percentage ?? 100) < this.threshold()) {
897
- this.transition(state, "armed");
898
- return this.result(managed, state, "compacted");
899
- }
900
- if (prior.status === "unverified" || (prior.status === "succeeded" && !prior.afterSample)) {
901
- this.transition(state, "compacted_unverified");
902
- if (allowHandoff)
903
- return this.performHandoff(managed, state, frozenAction, "a recovered compaction has no authoritative post-compact sample", false);
904
- throw this.capability("fresh successor compaction could not be verified after recovery");
905
- }
906
- failure = prior.status === "failed"
907
- ? sanitize(prior.error ?? "the prior compaction attempt failed")
908
- : `the prior compaction remained at ${prior.afterSample?.percentage?.toFixed(1) ?? "an unknown"}%`;
909
- // The durable terminal attempt already consumed this ordinal. Move to
910
- // the one remaining retry rather than issuing the same compaction again.
911
- continue;
912
- }
913
- state.manualAttemptKey = idempotencyKey;
914
- this.options.report?.({ kind: attempt === 1 ? "compacting" : "retrying", detail: `context reached ${this.threshold()}%; compaction ${state.compactionCount + 1}/${this.maximum()}`, sample: state.latest });
915
- let compacted;
514
+ if (compacted.failure?.category === "session-unavailable") {
515
+ const failedDb = new WorkflowDb(this.options.projectDir);
916
516
  try {
917
- compacted = await raw.compact();
918
- }
919
- catch (error) {
920
- compacted = error instanceof SessionUnavailableError ? { ok: false, error: error.message, failure: error.failure } : { ok: false, error: sanitize(error) };
921
- }
922
- await managed.settleEvents();
923
- state.manualAttemptKey = undefined;
924
- if (compacted.failure?.category === "session-unavailable") {
925
- const failed = new WorkflowDb(this.options.projectDir);
926
- try {
927
- failed.finishCompactionAttempt(idempotencyKey, { ok: false, error: compacted.error ?? compacted.failure.diagnostics });
928
- this.recordCompactionSessionUnavailable(failed, managed, compacted.failure);
929
- }
930
- finally {
931
- failed.close();
932
- }
933
- throw sessionUnavailableErrorFromFailure(compacted.failure);
934
- }
935
- if (!compacted.ok) {
936
- failure = sanitize(compacted.error ?? "provider did not explicitly complete compaction");
937
- const failed = new WorkflowDb(this.options.projectDir);
938
- try {
939
- failed.finishCompactionAttempt(idempotencyKey, { ok: false, error: failure });
940
- }
941
- finally {
942
- failed.close();
943
- }
944
- continue; // one bounded retry; failures do not consume compact slots
945
- }
946
- let after;
947
- try {
948
- state = await this.measureIntoState(managed, state, "post-compact", beforeProviderSequence);
949
- after = state.latest;
950
- }
951
- catch (error) {
952
- failure = sanitize(error);
953
- }
954
- const completed = new WorkflowDb(this.options.projectDir);
955
- try {
956
- completed.finishCompactionAttempt(idempotencyKey, after
957
- ? { ok: true, afterSample: after }
958
- : { ok: true, status: "unverified", error: failure });
959
- state.compactionCount = completed.successfulCompactionCount(this.options.runId, this.options.role, managed.sessionRef?.() ?? managed.sessionId());
517
+ failedDb.finishCompactionAttempt(idempotencyKey, { ok: false, error: compacted.error ?? compacted.failure.diagnostics });
518
+ failedDb.appendContinuityEvent({
519
+ runId: this.options.runId,
520
+ role: "host",
521
+ kind: "session_unavailable",
522
+ payload: {
523
+ role: this.options.role,
524
+ operation: "native-compaction",
525
+ phase: compacted.failure.phase,
526
+ dispatchState: compacted.failure.dispatchState ?? "unknown",
527
+ reason: compacted.failure.availability?.reason,
528
+ detail: compacted.failure.diagnostics,
529
+ },
530
+ authoritativeStateRevision: failedDb.continuityHead(this.options.runId, this.options.role)?.authoritativeStateRevision ?? this.settings.settings_revision,
531
+ sessionRef,
532
+ });
533
+ if (compacted.failure.dispatchState === "unknown")
534
+ failedDb.setContinuityHeadState(this.options.runId, this.options.role, "degraded");
960
535
  }
961
536
  finally {
962
- completed.close();
537
+ failedDb.close();
963
538
  }
964
- if (!after) {
965
- this.transition(state, "compacted_unverified");
966
- if (allowHandoff)
967
- return this.performHandoff(managed, state, frozenAction, "provider reported compaction but no newer authoritative post-compact sample was available", false);
968
- throw this.capability("fresh successor compaction could not be verified");
969
- }
970
- this.options.report?.({ kind: "compacted", detail: `context reduced to ${after.percentage?.toFixed(1)}%`, sample: after });
971
- if ((after.percentage ?? 100) < this.threshold()) {
972
- state.retryAfterTurn = undefined;
973
- this.transition(state, "armed");
974
- return this.result(managed, state, "compacted");
975
- }
976
- failure = `compaction remained at ${after.percentage?.toFixed(1)}%, at or above the configured ${this.threshold()}% ceiling`;
977
- if (this.generationTerminalAttempts(managed, state) >= 2 || state.compactionCount >= this.maximum())
978
- break;
979
- }
980
- if (allowHandoff)
981
- return this.performHandoff(managed, state, frozenAction, failure, false);
982
- throw this.capability(`fresh successor could not get below the configured ceiling after bounded compaction: ${failure}`);
983
- }
984
- async performHandoff(managed, state, frozenAction, reason, resumeFrozenAction) {
985
- if (!this.options.handoff)
986
- throw this.capability(`${reason}; validated handoff is unavailable`);
987
- this.transition(state, "handoff_required", frozenAction);
988
- this.options.report?.({ kind: "handoff", detail: reason, sample: state.latest });
989
- const predecessorKey = state.key;
990
- const successor = await this.options.handoff({
991
- reason, adapter: managed.underlying(), sample: state.latest, settings: this.settings,
992
- compactionCount: state.compactionCount, frozenAction,
993
- });
994
- const successorRaw = successor instanceof ManagedRoleAdapter ? successor.underlying() : successor;
995
- const successorKey = adapterStateKey(successorRaw);
996
- if (!successorKey || successorKey === predecessorKey)
997
- throw this.capability("validated handoff did not produce a genuinely fresh location-scoped successor");
998
- managed.replace(successorRaw);
999
- this.historicalCountUncertain = false;
1000
- let next = await this.ensureInitialized(managed, frozenAction);
1001
- this.transition(next, "resuming", frozenAction);
1002
- next = await this.measureIntoState(managed, next, "provider-query");
1003
- if ((next.latest.percentage ?? 100) >= this.threshold()) {
1004
- await this.compactAndVerify(managed, next, frozenAction, false);
1005
- next = this.stateFor(managed, true);
539
+ throw sessionUnavailableErrorFromFailure(compacted.failure);
1006
540
  }
1007
- this.transition(next, "armed");
1008
- if (resumeFrozenAction)
1009
- next.boundaryHandoff = undefined;
1010
- return this.result(managed, next, "handed-off");
1011
- }
1012
- async observeCompaction(managed, event) {
1013
- const state = this.stateFor(managed, false);
1014
- if (!state)
1015
- return;
1016
- if (event.sessionId && managed.sessionId() && event.sessionId !== managed.sessionId())
1017
- return;
1018
- const eventKey = `${state.key}:${event.providerEventId}`;
1019
- const phaseKey = `${eventKey}:${event.phase}`;
1020
- if (this.observedCompactionPhases.has(phaseKey))
1021
- return;
1022
- this.observedCompactionPhases.add(phaseKey);
1023
- if (event.origin === "rafi-manual" && state.manualAttemptKey) {
1024
- const db = new WorkflowDb(this.options.projectDir);
541
+ if (!compacted.ok) {
542
+ const failedDb = new WorkflowDb(this.options.projectDir);
1025
543
  try {
1026
- db.linkCompactionProviderEvent(state.manualAttemptKey, event.providerEventId);
544
+ failedDb.finishCompactionAttempt(idempotencyKey, { ok: false, error: compacted.error ?? "provider did not explicitly complete compaction" });
1027
545
  }
1028
- catch { /* a duplicate provider event is already durably linked */ }
1029
546
  finally {
1030
- db.close();
1031
- }
1032
- this.nativeAttempts.set(eventKey, state.manualAttemptKey);
1033
- return; // the host compact path owns success verification and accounting
1034
- }
1035
- if (event.origin === "rafi-manual" && this.nativeAttempts.has(eventKey))
1036
- return;
1037
- if (event.phase === "started") {
1038
- if (state.compactionCount >= this.maximum()) {
1039
- await this.interruptForHandoff(managed, state, event.providerEventId, `compact maximum ${this.maximum()} reached at native boundary ${event.providerEventId}`);
1040
- return;
547
+ failedDb.close();
1041
548
  }
1042
- if (state.state === "armed")
1043
- this.beginGeneration(state, "threshold_pending");
1044
- if (state.handoffAfterTurn || this.generationTerminalAttempts(managed, state) >= 2) {
1045
- await this.interruptForHandoff(managed, state, event.providerEventId, state.handoffAfterTurn
1046
- ?? `bounded compaction retry exhausted at native boundary ${event.providerEventId}`);
1047
- return;
1048
- }
1049
- this.transition(state, "native_compacting", undefined, event.providerEventId);
1050
- const beforeSample = state.latest;
1051
- this.invalidateSample(managed, state);
1052
- const idempotencyKey = `native:${this.options.runId}:${this.options.role}:${sha(eventKey).slice(0, 24)}`;
1053
- const db = new WorkflowDb(this.options.projectDir);
1054
- const attempt = db.startCompactionAttempt({
1055
- idempotencyKey, runId: this.options.runId, role: this.options.role,
1056
- providerSessionId: managed.sessionId(), sessionRef: managed.sessionRef?.(), sessionKey: state.key,
1057
- crossingKey: eventKey, beforeSample, origin: event.origin,
1058
- providerEventId: event.providerEventId, thresholdGenerationId: state.generationId,
1059
- });
1060
- db.close();
1061
- this.nativeAttempts.set(eventKey, attempt.idempotencyKey);
1062
- return;
549
+ return this.performHandoff(adapter, sample, count, frozenAction, `native compaction failed: ${sanitize(compacted.error ?? "no explicit completion")}`);
1063
550
  }
1064
- const idempotencyKey = this.nativeAttempts.get(eventKey) ?? `native:${this.options.runId}:${this.options.role}:${sha(eventKey).slice(0, 24)}`;
1065
- const observedStart = this.nativeAttempts.has(eventKey);
1066
- const db = new WorkflowDb(this.options.projectDir);
1067
- let attempt = db.compactionAttempt(idempotencyKey);
1068
- if (!attempt)
1069
- attempt = db.startCompactionAttempt({
1070
- idempotencyKey, runId: this.options.runId, role: this.options.role,
1071
- providerSessionId: managed.sessionId(), sessionRef: managed.sessionRef?.(), sessionKey: state.key,
1072
- crossingKey: eventKey, beforeSample: state.latest, origin: event.origin,
1073
- providerEventId: event.providerEventId, thresholdGenerationId: state.generationId,
1074
- });
1075
- if (attempt.status !== "started") {
1076
- db.close();
1077
- return;
1078
- }
1079
- if (event.phase === "failed") {
1080
- db.finishCompactionAttempt(attempt.idempotencyKey, { ok: false, error: event.reason ?? "provider-native compaction failed" });
1081
- db.close();
1082
- state.retryAfterTurn = event.reason ?? "provider-native compaction failed";
1083
- this.transition(state, "retrying");
1084
- return;
1085
- }
1086
- db.close();
1087
- if (!observedStart && event.origin !== "rafi-manual") {
1088
- state.capabilityFailure = `provider completed native compaction ${event.providerEventId} without an observable start boundary; compact maximum ${this.maximum()} cannot be enforced`;
1089
- this.persist(state, undefined, event.providerEventId);
551
+ let after;
552
+ try {
553
+ after = await this.measure(adapter, "post-compact");
1090
554
  }
1091
- if (state.boundaryHandoff?.providerEventId === event.providerEventId) {
1092
- const exceeded = new WorkflowDb(this.options.projectDir);
555
+ catch (error) {
556
+ const failedDb = new WorkflowDb(this.options.projectDir);
1093
557
  try {
1094
- exceeded.finishCompactionAttempt(attempt.idempotencyKey, { ok: true, status: "unverified", error: "provider completed a vetoed over-maximum compaction" });
558
+ failedDb.finishCompactionAttempt(idempotencyKey, { ok: false, error: error instanceof Error ? error.message : String(error) });
1095
559
  }
1096
560
  finally {
1097
- exceeded.close();
561
+ failedDb.close();
1098
562
  }
1099
- state.compactionCount = this.compactionCount(managed.sessionRef?.() ?? managed.sessionId());
1100
- state.capabilityFailure = `provider completed native compaction ${event.providerEventId} beyond compact maximum ${this.maximum()}`;
1101
- this.persist(state);
1102
- return;
563
+ return this.performHandoff(adapter, sample, count, frozenAction, "provider reported compaction but no authoritative post-compact occupancy sample was available");
1103
564
  }
1104
- if (event.postCompactSample)
1105
- this.acceptUsage(managed, state, event.postCompactSample, "post-compact");
1106
- let verified = state.latest.freshness === "fresh" && state.latest.sequence !== undefined && state.latest.sequence > (attempt.beforeSample?.sequence ?? -1);
1107
- if (!verified) {
1108
- try {
1109
- await this.measureIntoState(managed, state, "post-compact", attempt.beforeSample?.sequence);
1110
- verified = true;
1111
- }
1112
- catch {
1113
- verified = false;
1114
- }
1115
- }
1116
- const completed = new WorkflowDb(this.options.projectDir);
565
+ const completedDb = new WorkflowDb(this.options.projectDir);
1117
566
  try {
1118
- completed.finishCompactionAttempt(attempt.idempotencyKey, verified
1119
- ? { ok: true, afterSample: state.latest }
1120
- : { ok: true, status: "unverified", error: "no newer authoritative post-compact sample" });
1121
- state.compactionCount = completed.successfulCompactionCount(this.options.runId, this.options.role, managed.sessionRef?.() ?? managed.sessionId());
567
+ completedDb.recordContextSample(after);
568
+ completedDb.finishCompactionAttempt(idempotencyKey, { ok: true, afterSample: after });
569
+ count = completedDb.successfulCompactionCount(this.options.runId, this.options.role, sessionRef ?? sessionId);
1122
570
  }
1123
571
  finally {
1124
- completed.close();
1125
- }
1126
- if (!verified) {
1127
- state.handoffAfterTurn = "provider-native compaction succeeded but post-compact occupancy could not be verified";
1128
- this.transition(state, "compacted_unverified");
572
+ completedDb.close();
1129
573
  }
1130
- else if ((state.latest.percentage ?? 100) < this.threshold()) {
1131
- this.transition(state, "armed");
1132
- }
1133
- else {
1134
- state.retryAfterTurn = `provider-native compaction remained at ${state.latest.percentage?.toFixed(1)}%`;
1135
- this.transition(state, "retrying");
1136
- }
1137
- }
1138
- async interruptForHandoff(managed, state, providerEventId, reason) {
1139
- state.boundaryHandoff = { providerEventId, reason };
1140
- this.transition(state, "handoff_required", undefined, providerEventId);
1141
- const interrupted = await managed.underlying().interruptTurnAtCompactionBoundary?.(providerEventId);
1142
- if (!interrupted?.ok) {
1143
- state.capabilityFailure = `provider could not stop native compaction ${providerEventId}: ${interrupted?.error ?? "interrupt unavailable"}`;
1144
- this.persist(state);
574
+ this.options.report?.({ kind: "compacted", detail: `context reduced to ${after.percentage?.toFixed(1)}%`, sample: after });
575
+ if (freshBootstrap && (after.percentage ?? -1) >= threshold) {
576
+ const adjusted = Math.min(99, Math.ceil((after.percentage ?? 0) + 10));
577
+ if (adjusted <= (after.percentage ?? 0))
578
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `fresh bootstrap remained at ${after.percentage?.toFixed(1)}% after compaction and no safe run-only threshold exists`);
579
+ this.runOnlyThreshold = adjusted;
580
+ this.options.report?.({ kind: "threshold-adjusted", detail: `fresh bootstrap remained high; run-only threshold is now ${adjusted}%`, sample: after });
1145
581
  }
582
+ this.armed = thresholdCrossing ? (after.percentage ?? 100) < this.threshold() : true;
583
+ return { adapter, action: "compacted", sample: after, effectiveThreshold: this.threshold(), compactionCount: count };
1146
584
  }
1147
- generationTerminalAttempts(managed, state) {
1148
- const session = managed.sessionRef?.() ?? managed.sessionId();
1149
- if (!session)
1150
- return 0;
585
+ async performHandoff(adapter, sample, count, frozenAction, reason) {
586
+ if (!this.options.handoff)
587
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `${reason}; validated handoff is unavailable`);
588
+ this.options.report?.({ kind: "handoff", detail: reason, sample });
589
+ const successor = await this.options.handoff({ reason, adapter, sample, settings: this.settings, compactionCount: count, frozenAction });
590
+ const post = await this.measure(successor, "provider-query");
591
+ this.historicalCountUncertain = false;
592
+ const successorSessionId = successor.sessionId();
593
+ const successorSessionKey = adapterSessionKey(successor);
594
+ if (successorSessionKey)
595
+ this.seenSessions.add(successorSessionKey);
1151
596
  const db = new WorkflowDb(this.options.projectDir);
1152
597
  try {
1153
- return db.compactionAttempts(this.options.runId, this.options.role, session)
1154
- .filter((attempt) => attempt.thresholdGenerationId === state.generationId && attempt.status !== "started")
1155
- .length;
598
+ db.recordContextSample(post);
1156
599
  }
1157
600
  finally {
1158
601
  db.close();
1159
602
  }
603
+ if ((post.percentage ?? 100) >= this.threshold()) {
604
+ if (this.bootstrapRecoveryDepth >= 1) {
605
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `fresh successor bootstrap remained at ${post.percentage?.toFixed(1)}% after compaction-failure recovery`);
606
+ }
607
+ this.bootstrapRecoveryDepth += 1;
608
+ try {
609
+ const bootstrapped = await this.compactSession(successor, post, 0, frozenAction, `${successorSessionKey ?? successorSessionId ?? "missing"}:fresh-bootstrap:${sha(frozenAction).slice(0, 20)}`, true, true);
610
+ return { ...bootstrapped, action: "handed-off" };
611
+ }
612
+ finally {
613
+ this.bootstrapRecoveryDepth -= 1;
614
+ }
615
+ }
616
+ this.armed = true;
617
+ return { adapter: successor, action: "handed-off", sample: post, effectiveThreshold: this.threshold(), compactionCount: 0 };
1160
618
  }
1161
- async refreshSettings(managed, frozenAction, allowTransition) {
1162
- // Timer-driven and post-turn refreshes can meet at the same provider
1163
- // boundary. Wait for the active installation, then re-read the revision so
1164
- // neither caller can proceed on partially applied settings or miss a newer
1165
- // revision that arrived during the first installation.
1166
- while (this.refreshingSettings)
1167
- await this.refreshingSettings;
1168
- if (managed.isClosed())
1169
- return;
619
+ async adoptSettings(adapter, frozenAction) {
1170
620
  const candidate = this.options.readSettings?.();
1171
621
  if (!candidate || candidate.settings_revision <= this.settings.settings_revision)
1172
- return;
1173
- const lifecycle = this.stateFor(managed, false)?.state;
1174
- if (lifecycle === "native_compacting" || lifecycle === "host_compacting")
1175
- return;
622
+ return adapter;
1176
623
  const next = normalizeContextSettings(candidate);
1177
- const providerControlsChanged = next.make !== this.settings.make || next.model !== this.settings.model || next.reasoning !== this.settings.reasoning || next.fast !== this.settings.fast;
1178
- if (providerControlsChanged && !allowTransition)
1179
- return;
1180
- let releaseRefresh;
1181
- const refresh = new Promise((resolve) => { releaseRefresh = resolve; });
1182
- this.refreshingSettings = refresh;
1183
- try {
1184
- if (providerControlsChanged) {
1185
- let adopted;
1186
- if (this.options.settingsBoundary) {
1187
- adopted = await this.options.settingsBoundary({ adapter: managed.underlying(), current: this.settings, next, frozenAction });
1188
- }
1189
- else if (next.make === this.settings.make && managed.underlying().switchSettings) {
1190
- const switched = await managed.underlying().switchSettings({ model: next.model === "default" ? undefined : next.model, effort: effort(next.reasoning), fast: next.fast });
1191
- if (switched.failure?.category === "session-unavailable")
1192
- throw sessionUnavailableErrorFromFailure(switched.failure);
1193
- if (!switched.ok)
1194
- throw this.capability(`settings revision ${next.settings_revision} could not be applied safely: ${sanitize(switched.error ?? "provider rejected settings")}`);
1195
- adopted = managed.underlying();
1196
- }
1197
- else
1198
- throw this.capability(`settings revision ${next.settings_revision} requires a validated provider/model handoff`);
1199
- const raw = adopted instanceof ManagedRoleAdapter ? adopted.underlying() : adopted;
1200
- if (raw.agent !== next.make)
1201
- throw this.capability(`settings revision ${next.settings_revision} requested ${next.make}, but successor reports ${raw.agent}`);
1202
- managed.replace(raw);
1203
- this.settings = next;
1204
- await this.ensureInitialized(managed, frozenAction);
624
+ const providerControlsChanged = next.make !== this.settings.make
625
+ || next.model !== this.settings.model
626
+ || next.reasoning !== this.settings.reasoning
627
+ || next.fast !== this.settings.fast;
628
+ const nativeCompactionChanged = next.auto_compact_threshold_percent !== this.settings.auto_compact_threshold_percent;
629
+ let adoptedAdapter = adapter;
630
+ if (providerControlsChanged) {
631
+ if (this.options.settingsBoundary) {
632
+ adoptedAdapter = await this.options.settingsBoundary({ adapter, current: this.settings, next, frozenAction });
633
+ }
634
+ else if (next.make === this.settings.make && adapter.switchSettings) {
635
+ const switched = await adapter.switchSettings({ model: next.model === "default" ? undefined : next.model, effort: effort(next.reasoning), fast: next.fast });
636
+ if (switched.failure?.category === "session-unavailable")
637
+ throw sessionUnavailableErrorFromFailure(switched.failure);
638
+ if (!switched.ok)
639
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `settings revision ${next.settings_revision} could not be applied safely: ${sanitize(switched.error ?? "provider rejected the settings switch")}`);
1205
640
  }
1206
641
  else {
1207
- // Threshold-only revisions are acknowledged only after native control is installed.
1208
- const priorState = this.stateFor(managed, false);
1209
- if (!priorState) {
1210
- this.settings = next;
1211
- await this.ensureInitialized(managed, frozenAction);
1212
- }
1213
- else {
1214
- const prepared = await this.installNative(managed.underlying(), next, false);
1215
- this.settings = next;
1216
- const state = this.stateFor(managed, true);
1217
- this.beginGeneration(state, (state.latest.percentage ?? 100) < this.threshold() ? "armed" : "threshold_pending");
1218
- state.settingsRevision = next.settings_revision;
1219
- state.model = next.model;
1220
- state.installedNativeTokenLimit = prepared.installedNativeTokenLimit;
1221
- state.installedNativePercent = prepared.installedNativePercent;
1222
- if (prepared.sample)
1223
- this.acceptUsage(managed, state, prepared.sample, prepared.sample.source ?? "provider-query");
1224
- this.persist(state, frozenAction);
1225
- }
642
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `settings revision ${next.settings_revision} requires a validated provider/model handoff, but no settings boundary is available`);
1226
643
  }
1227
- const adoptedState = this.stateFor(managed, false);
1228
- if (adoptedState?.capabilityFailure) {
1229
- adoptedState.capabilityFailure = undefined;
1230
- this.persist(adoptedState, frozenAction);
644
+ if (adoptedAdapter.agent !== next.make) {
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}`);
1231
646
  }
1232
- const db = new WorkflowDb(this.options.projectDir);
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`);
1233
651
  try {
1234
- const ref = managed.sessionRef?.();
1235
- db.acknowledgeSettings({ runId: this.options.runId, role: this.options.role, providerSessionId: managed.sessionId(), ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}), revision: next.settings_revision, acknowledgedAt: this.now().toISOString() });
652
+ await adapter.prepareAutoCompaction(next.auto_compact_threshold_percent);
1236
653
  }
1237
- finally {
1238
- db.close();
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)}`);
1239
656
  }
1240
- this.options.settingsAdopted?.(next, managed);
1241
- this.options.report?.({ kind: "settings-adopted", detail: `adopted settings revision ${next.settings_revision}` });
1242
- }
1243
- finally {
1244
- releaseRefresh();
1245
- if (this.refreshingSettings === refresh)
1246
- this.refreshingSettings = undefined;
1247
657
  }
1248
- }
1249
- ensureSettingsWatcher(managed, frozenAction) {
1250
- if (managed.isClosed())
1251
- return;
1252
- const existing = this.settingsWatchers.get(managed);
1253
- if (existing) {
1254
- existing.frozenAction = frozenAction;
1255
- return;
1256
- }
1257
- if (!this.options.readSettings)
1258
- return;
1259
- const watcher = { frozenAction, stop: () => { } };
1260
- const timer = setInterval(() => {
1261
- // Provider/model transitions may hand off only while no role turn is in
1262
- // flight. Threshold-only changes may be attempted during a turn; an
1263
- // adapter that cannot safely reconfigure then is retried while idle.
1264
- void this.refreshSettings(managed, watcher.frozenAction, this.dispatchDepth === 0).catch((error) => {
1265
- const state = this.stateFor(managed, false);
1266
- if (state && this.dispatchDepth > 0) {
1267
- this.options.report?.({ kind: "settings-pending", detail: sanitize(error), sample: state.latest });
1268
- this.transition(state, "threshold_pending", watcher.frozenAction);
1269
- return;
1270
- }
1271
- if (state) {
1272
- state.capabilityFailure = `live settings revision could not be installed: ${sanitize(error)}`;
1273
- this.options.report?.({ kind: "capability-failure", detail: state.capabilityFailure, sample: state.latest });
1274
- this.transition(state, "threshold_pending", watcher.frozenAction);
1275
- }
1276
- watcher.stop();
1277
- this.settingsWatchers.delete(managed);
1278
- });
1279
- const state = this.stateFor(managed, false);
1280
- if (state)
1281
- this.markActive(managed, state);
1282
- }, Math.max(50, this.options.settingsPollMs ?? 250));
1283
- timer.unref();
1284
- watcher.stop = () => clearInterval(timer);
1285
- this.settingsWatchers.set(managed, watcher);
1286
- }
1287
- beginGeneration(state, lifecycle) {
1288
- state.generation += 1;
1289
- state.generationId = this.generationId(state.key, state.generation);
1290
- state.state = lifecycle;
1291
- state.latest = { ...state.latest, thresholdGeneration: state.generation, settingsRevision: this.settings.settings_revision };
1292
- state.retryAfterTurn = undefined;
1293
- state.handoffAfterTurn = undefined;
1294
- this.persist(state);
1295
- }
1296
- transition(state, lifecycle, frozenAction, providerEventId) {
1297
- const changed = state.state !== lifecycle;
1298
- state.state = lifecycle;
1299
- this.persist(state, frozenAction, providerEventId);
1300
- if (changed)
1301
- this.options.report?.({ kind: "lifecycle", detail: lifecycle, sample: state.latest });
1302
- }
1303
- persist(state, frozenAction, providerEventId) {
658
+ this.settings = next;
659
+ this.runOnlyThreshold = undefined;
1304
660
  const db = new WorkflowDb(this.options.projectDir);
1305
661
  try {
1306
- const ref = state.latest.sessionRef;
1307
- db.upsertThresholdGeneration({
1308
- generationId: state.generationId, runId: this.options.runId, role: this.options.role,
1309
- providerSessionId: state.latest.providerSessionId, ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : { sessionKey: state.key }),
1310
- generation: state.generation, state: state.state, configuredCeilingPercent: this.threshold(),
1311
- installedNativeTokenLimit: state.installedNativeTokenLimit, installedNativePercent: state.installedNativePercent,
1312
- settingsRevision: this.settings.settings_revision, model: this.settings.model, latestSample: state.latest,
1313
- ...(frozenAction ? { frozenActionDigest: sha(frozenAction) } : {}), ...(providerEventId ? { providerEventId } : {}),
1314
- });
662
+ const ref = adoptedAdapter.sessionRef?.();
663
+ db.acknowledgeSettings({ runId: this.options.runId, role: this.options.role, providerSessionId: adoptedAdapter.sessionId(), ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}), revision: next.settings_revision, acknowledgedAt: this.now().toISOString() });
1315
664
  }
1316
665
  finally {
1317
666
  db.close();
1318
667
  }
668
+ this.options.settingsAdopted?.(next, adoptedAdapter);
669
+ this.options.report?.({ kind: "settings-adopted", detail: `adopted settings revision ${next.settings_revision}` });
670
+ return adoptedAdapter;
1319
671
  }
1320
- invalidateSample(managed, state) {
1321
- state.latest = {
1322
- ...this.measuringSample(managed, state.key, state.generation), source: "post-compact",
1323
- observedAt: this.now().toISOString(), freshness: "measuring", compactionCount: state.compactionCount,
1324
- };
1325
- const db = new WorkflowDb(this.options.projectDir);
1326
- try {
1327
- db.recordContextSample(state.latest);
1328
- }
1329
- finally {
1330
- db.close();
672
+ async measure(adapter, source) {
673
+ const attempts = Math.max(1, this.options.contextRetries ?? 3);
674
+ let usage;
675
+ for (let attempt = 0; attempt < attempts; attempt++) {
676
+ try {
677
+ usage = await adapter.contextUsage?.();
678
+ }
679
+ catch {
680
+ usage = undefined;
681
+ }
682
+ if (usage && Number.isFinite(usage.used) && (usage.percentage !== undefined || usage.maximum !== undefined))
683
+ break;
1331
684
  }
1332
- this.persist(state);
685
+ if (!usage || !Number.isFinite(usage.used))
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);
1333
688
  }
1334
- measuringSample(managed, key, generation) {
1335
- const ref = managed.sessionRef?.();
689
+ sampleFromUsage(adapter, usage, source) {
690
+ const percentage = usage.percentage ?? (usage.maximum ? usage.used / usage.maximum * 100 : undefined);
691
+ if (percentage === undefined || !Number.isFinite(percentage))
692
+ throw new ContextCapabilityError(this.options.runId, this.options.role, "provider context occupancy lacks a usable percentage and maximum");
693
+ const ref = adapter.sessionRef?.();
1336
694
  return {
1337
- version: 1, runId: this.options.runId, role: this.options.role, provider: managed.agent,
1338
- providerSessionId: managed.sessionId(), ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : { sessionKey: key }),
1339
- model: this.settings.model, observedAt: this.now().toISOString(), source: "provider-query", freshness: "measuring",
1340
- settingsRevision: this.settings.settings_revision, compactionCount: 0, handoffGeneration: this.handoffGeneration(), thresholdGeneration: generation,
695
+ version: 1, runId: this.options.runId, role: this.options.role, provider: adapter.agent,
696
+ providerSessionId: adapter.sessionId(), ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}), model: this.settings.model, observedAt: usage.observedAt ?? this.now().toISOString(),
697
+ source, freshness: "fresh", used: usage.used, maximum: usage.maximum, percentage,
698
+ settingsRevision: this.settings.settings_revision,
699
+ compactionCount: adapter.sessionId() ? this.compactionCount(ref ?? adapter.sessionId()) : 0,
700
+ handoffGeneration: this.handoffGeneration(),
1341
701
  };
1342
702
  }
1343
- result(managed, state, action) {
1344
- return { adapter: managed, action, sample: state.latest, effectiveThreshold: this.threshold(), compactionCount: state.compactionCount };
1345
- }
1346
- markActive(managed, state) {
1347
- if (managed.isClosed())
1348
- return;
1349
- const db = new WorkflowDb(this.options.projectDir);
1350
- try {
1351
- const previous = managed.noteActiveSession(state.key);
1352
- if (previous)
1353
- db.clearRoleAdapterActive(this.options.runId, this.options.role, previous);
1354
- db.markRoleAdapterActive({ runId: this.options.runId, role: this.options.role, providerSessionId: managed.sessionId(), sessionKey: state.key, settingsRevision: this.settings.settings_revision, observedAt: this.now().toISOString() });
1355
- }
1356
- finally {
1357
- db.close();
1358
- }
1359
- }
1360
- providerSequence(adapter, state) {
1361
- const key = adapterStateKey(adapter);
1362
- if (state)
1363
- return state.latestProviderSequence;
1364
- if (!key)
1365
- return 0;
1366
- const db = new WorkflowDb(this.options.projectDir);
1367
- try {
1368
- return Math.max(0, ...db.thresholdGenerations(this.options.runId, this.options.role, key)
1369
- .map((generation) => generation.latestSample?.sequence ?? 0));
1370
- }
1371
- finally {
1372
- db.close();
1373
- }
1374
- }
1375
- recordCompactionSessionUnavailable(db, managed, failure) {
1376
- const sessionRef = managed.sessionRef?.();
1377
- db.appendContinuityEvent({
1378
- runId: this.options.runId,
1379
- role: "host",
1380
- kind: "session_unavailable",
1381
- payload: {
1382
- role: this.options.role,
1383
- operation: "native-compaction",
1384
- phase: failure.phase,
1385
- dispatchState: failure.dispatchState ?? "unknown",
1386
- reason: failure.availability?.reason,
1387
- detail: failure.diagnostics,
1388
- },
1389
- authoritativeStateRevision: db.continuityHead(this.options.runId, this.options.role)?.authoritativeStateRevision ?? this.settings.settings_revision,
1390
- sessionRef,
1391
- });
1392
- if (failure.dispatchState === "unknown")
1393
- db.setContinuityHeadState(this.options.runId, this.options.role, "degraded");
1394
- }
1395
- persistedMaximum(adapter, model) {
1396
- const key = adapterStateKey(adapter);
1397
- if (!key)
1398
- return undefined;
1399
- const db = new WorkflowDb(this.options.projectDir);
1400
- try {
1401
- return db.thresholdGenerations(this.options.runId, this.options.role, key).filter((item) => item.model === model).at(-1)?.latestSample?.maximum;
1402
- }
1403
- finally {
1404
- db.close();
1405
- }
1406
- }
1407
703
  compactionCount(session) {
1408
- if (!session)
1409
- return 0;
1410
704
  const db = new WorkflowDb(this.options.projectDir);
1411
705
  try {
1412
706
  return db.successfulCompactionCount(this.options.runId, this.options.role, session);
@@ -1424,13 +718,8 @@ export class ThresholdCompactionController {
1424
718
  db.close();
1425
719
  }
1426
720
  }
1427
- generationId(key, generation) {
1428
- return `threshold:${this.options.runId}:${this.options.role}:${sha(`${key}:${generation}`).slice(0, 24)}`;
1429
- }
1430
- threshold() { return this.settings.auto_compact_threshold_percent; }
1431
- maximum() { return this.settings.compact_maximum; }
721
+ threshold() { return this.runOnlyThreshold ?? this.settings.auto_compact_threshold_percent ?? 50; }
1432
722
  now() { return this.options.now?.() ?? new Date(); }
1433
- capability(error) { return error instanceof ContextCapabilityError ? error : new ContextCapabilityError(this.options.runId, this.options.role, sanitize(error)); }
1434
723
  }
1435
724
  function normalizeContextSettings(settings) {
1436
725
  const threshold = settings.auto_compact_threshold_percent ?? 50;
@@ -1452,6 +741,3 @@ function adapterSessionKey(adapter) {
1452
741
  const ref = adapter.sessionRef?.();
1453
742
  return ref ? providerSessionKey(ref) : undefined;
1454
743
  }
1455
- function adapterStateKey(adapter) {
1456
- return adapterSessionKey(adapter) ?? (adapter.sessionId() ? `${adapter.agent}:unscoped:${adapter.sessionId()}` : undefined);
1457
- }