ai-foreman 1.7.6 → 1.7.8

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.
Files changed (69) hide show
  1. package/README.md +26 -1
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +31 -6
  4. package/dist/adapters/claude.d.ts +61 -6
  5. package/dist/adapters/claude.js +544 -36
  6. package/dist/adapters/codex.d.ts +28 -1
  7. package/dist/adapters/codex.js +398 -27
  8. package/dist/adapters/recovering.d.ts +13 -2
  9. package/dist/adapters/recovering.js +32 -4
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +117 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +38 -0
  16. package/dist/branch/currentGuard.js +71 -0
  17. package/dist/branch/planner.d.ts +1 -1
  18. package/dist/branch/planner.js +2 -7
  19. package/dist/branch/prefix.d.ts +8 -0
  20. package/dist/branch/prefix.js +26 -0
  21. package/dist/branch/resume.d.ts +2 -0
  22. package/dist/branch/resume.js +8 -0
  23. package/dist/branch/runner.d.ts +14 -2
  24. package/dist/branch/runner.js +77 -17
  25. package/dist/buildRuns.d.ts +13 -4
  26. package/dist/buildRuns.js +85 -19
  27. package/dist/cli/handoffs.d.ts +2 -0
  28. package/dist/cli/handoffs.js +74 -0
  29. package/dist/cli/start.js +1079 -43
  30. package/dist/cli/tickets.js +199 -3
  31. package/dist/continuity.d.ts +92 -0
  32. package/dist/continuity.js +415 -0
  33. package/dist/foreman.d.ts +5 -1
  34. package/dist/foreman.js +41 -6
  35. package/dist/handoffs.d.ts +69 -0
  36. package/dist/handoffs.js +546 -0
  37. package/dist/log.d.ts +1 -1
  38. package/dist/permissions/policy.d.ts +5 -1
  39. package/dist/permissions/policy.js +48 -1
  40. package/dist/qaReview.d.ts +6 -6
  41. package/dist/qaReview.js +24 -14
  42. package/dist/sessionAvailability.d.ts +15 -0
  43. package/dist/sessionAvailability.js +57 -0
  44. package/dist/sessionIdentity.d.ts +33 -0
  45. package/dist/sessionIdentity.js +132 -0
  46. package/dist/sessionLifecycle.d.ts +198 -5
  47. package/dist/sessionLifecycle.js +1373 -20
  48. package/dist/statusReporter.d.ts +84 -8
  49. package/dist/statusReporter.js +270 -14
  50. package/dist/ticketPlanning.js +8 -0
  51. package/dist/ticketPopulation.js +88 -12
  52. package/dist/tickets/commands.js +20 -12
  53. package/dist/tickets/groupReset.d.ts +25 -0
  54. package/dist/tickets/groupReset.js +279 -0
  55. package/dist/tickets/groups.d.ts +36 -0
  56. package/dist/tickets/groups.js +68 -0
  57. package/dist/tickets/importer.js +27 -3
  58. package/dist/tickets/recommendations.js +31 -7
  59. package/dist/tickets/reset.d.ts +1 -0
  60. package/dist/tickets/reset.js +1 -0
  61. package/dist/tickets/setupConfig.d.ts +1 -0
  62. package/dist/tickets/setupConfig.js +10 -2
  63. package/dist/tickets/stateDb.d.ts +34 -0
  64. package/dist/tickets/stateDb.js +207 -0
  65. package/dist/tickets/ticketLoader.js +5 -2
  66. package/dist/tickets/validate.js +4 -0
  67. package/dist/workflowDb.d.ts +145 -3
  68. package/dist/workflowDb.js +571 -10
  69. package/package.json +32 -4
@@ -1,27 +1,122 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SessionUnavailableError, sessionUnavailableErrorFromFailure } from "./adapters/sessionFailure.js";
1
3
  import { sanitizeDiagnostics } from "./runtimeReadiness.js";
4
+ import { WorkflowDb } from "./workflowDb.js";
5
+ import { providerSessionKey } from "./sessionIdentity.js";
6
+ import { AsyncQueue } from "./util/asyncQueue.js";
7
+ export class RoleSessionValidationError extends Error {
8
+ role;
9
+ availability;
10
+ constructor(role, availability) {
11
+ super(`exact ${role} session is ${availability.status} (${availability.reason ?? "validation failed"}): ${availability.detail ?? "no provider detail"}`);
12
+ this.role = role;
13
+ this.availability = availability;
14
+ this.name = "RoleSessionValidationError";
15
+ }
16
+ }
2
17
  /** One lifecycle implementation shared by interviews, builds, QA, and recovery. */
3
18
  export class RoleSessionController {
4
19
  options;
5
20
  adapter;
6
21
  workSessions = 0;
22
+ thresholdController;
7
23
  constructor(options) {
8
24
  this.options = options;
9
25
  }
10
- async next(_durableHandoff, nextSettings) {
26
+ /**
27
+ * Production facade: the role owner authorizes scoped adapters and validated
28
+ * successors while the threshold controller remains delegated policy and
29
+ * accounting. Compatibility callers may still construct the threshold
30
+ * controller directly.
31
+ */
32
+ static managed(options) {
33
+ const controller = new RoleSessionController({
34
+ role: options.role,
35
+ settings: options.initialSettings,
36
+ create: async () => { throw new Error("managed role sessions create adapters only through validated lifecycle callbacks"); },
37
+ });
38
+ const settingsBoundary = options.settingsBoundary
39
+ ? async (input) => {
40
+ const predecessor = controller.captureManagedIdentity(input.adapter);
41
+ const successor = await options.settingsBoundary(input);
42
+ return controller.acceptManagedTransition(predecessor, successor, "settings");
43
+ }
44
+ : undefined;
45
+ const handoff = options.handoff
46
+ ? async (input) => {
47
+ const predecessor = controller.captureManagedIdentity(input.adapter);
48
+ const successor = await options.handoff(input);
49
+ return controller.acceptManagedTransition(predecessor, successor, "handoff");
50
+ }
51
+ : undefined;
52
+ controller.thresholdController = new ThresholdCompactionController({ ...options, settingsBoundary, handoff });
53
+ return controller;
54
+ }
55
+ async atSafeBoundary(adapter, frozenAction) {
56
+ if (!this.thresholdController)
57
+ throw new Error("this role session controller does not own threshold lifecycle policy");
58
+ await this.adoptManagedAdapter(adapter);
59
+ const result = await this.thresholdController.atSafeBoundary(adapter, frozenAction);
60
+ this.adapter = result.adapter;
61
+ return result;
62
+ }
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
+ async atWorkSessionBoundary(adapter, frozenAction, strategy) {
73
+ if (!this.thresholdController)
74
+ throw new Error("this role session controller does not own threshold lifecycle policy");
75
+ await this.adoptManagedAdapter(adapter);
76
+ const result = await this.thresholdController.atWorkSessionBoundary(adapter, frozenAction, strategy);
77
+ this.adapter = result.adapter;
78
+ return result;
79
+ }
80
+ effectiveSettings() {
81
+ return this.thresholdController?.effectiveSettings() ?? this.options.settings;
82
+ }
83
+ effectiveThreshold() {
84
+ if (!this.thresholdController)
85
+ return this.options.settings.auto_compact_threshold_percent ?? 50;
86
+ return this.thresholdController.effectiveThreshold();
87
+ }
88
+ async next(durableHandoff, nextSettings) {
11
89
  this.workSessions += 1;
12
90
  if (!this.adapter) {
13
91
  if (nextSettings)
14
92
  this.options.settings = nextSettings;
15
- this.adapter = await this.options.create(this.options.settings, this.options.initialSessionId);
93
+ if (this.options.initialSessionId && !this.options.initialSessionRef) {
94
+ throw new RoleSessionValidationError(this.options.role, {
95
+ version: 1, status: "unknown", checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
96
+ reason: "legacy-unscoped", detail: "a raw provider session ID has no verifiable location scope",
97
+ });
98
+ }
99
+ this.adapter = await this.options.create(this.options.settings, this.options.initialSessionRef?.sessionId ?? this.options.initialSessionId, this.options.initialSessionRef);
100
+ if (this.options.initialSessionRef) {
101
+ const availability = await (this.adapter.validateSession?.() ?? Promise.resolve({
102
+ version: 1, status: "unknown", checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
103
+ reason: "legacy-unscoped", detail: "provider adapter does not expose exact-session validation", sessionRef: this.options.initialSessionRef,
104
+ }));
105
+ if (availability.status !== "available" || !availability.sessionRef) {
106
+ await this.adapter.close().catch(() => { });
107
+ this.adapter = undefined;
108
+ throw new RoleSessionValidationError(this.options.role, availability);
109
+ }
110
+ this.adapter.adoptSessionRef?.(availability.sessionRef);
111
+ }
16
112
  return this.boundary("initial", "started a workflow-scoped provider conversation");
17
113
  }
18
114
  if (nextSettings) {
19
115
  const current = this.options.settings;
20
116
  if (nextSettings.make !== current.make) {
21
- await this.adapter.close();
22
117
  this.options.settings = nextSettings;
23
- this.adapter = await this.options.create(nextSettings);
24
- return this.boundary("settings-fallback", `provider changed from ${current.make} to ${nextSettings.make}; started fresh from the durable handoff`);
118
+ const validated = await this.moveFresh(durableHandoff, `provider changed from ${current.make} to ${nextSettings.make}`);
119
+ return this.boundary("settings-fallback", `${validated ? "validated cumulative handoff" : "fresh compatibility fallback"} after provider changed from ${current.make} to ${nextSettings.make}`);
25
120
  }
26
121
  const changed = nextSettings.model !== current.model || nextSettings.reasoning !== current.reasoning || nextSettings.fast !== current.fast;
27
122
  this.options.settings = nextSettings;
@@ -30,55 +125,167 @@ export class RoleSessionController {
30
125
  const switched = this.adapter.switchSettings
31
126
  ? await this.adapter.switchSettings({ model: nextSettings.model === "default" ? undefined : nextSettings.model, effort: effort(nextSettings.reasoning), fast: nextSettings.fast })
32
127
  : { ok: false, error: "provider adapter does not support an in-conversation settings switch" };
128
+ if (switched.failure?.category === "session-unavailable")
129
+ throw sessionUnavailableErrorFromFailure(switched.failure);
33
130
  if (!switched.ok) {
34
- await this.adapter.close();
35
- this.adapter = await this.options.create(nextSettings);
36
131
  const detail = sanitize(switched.error ?? "settings switch failed");
37
- const issue = this.issue("session_model_switch_failure", detail, "This work session continued fresh with the current project settings.");
38
- return this.boundary("settings-fallback", `settings switch failed (${detail}); started fresh and continuity was lost`, issue);
132
+ const validated = await this.moveFresh(durableHandoff, `settings switch failed: ${detail}`);
133
+ const issue = this.issue("session_model_switch_failure", detail, validated ? "The role continued through a validated cumulative handoff." : "A validated handoff callback was unavailable; inspect continuity before proceeding.");
134
+ return this.boundary("settings-fallback", `settings switch failed (${detail}); ${validated ? "continued through a validated fresh successor" : "used the compatibility fresh fallback"}`, issue);
39
135
  }
40
136
  this.emit("settings-continued", "provider accepted the same-conversation settings switch");
41
137
  }
42
138
  }
43
139
  if (this.options.settings.session_strategy === "fresh") {
44
- await this.adapter.close();
45
- this.adapter = await this.options.create(this.options.settings);
46
- const result = this.boundary("fresh", "started a fresh conversation with a durable handoff");
140
+ const validated = await this.moveFresh(durableHandoff, "ordinary session_strategy=fresh boundary");
141
+ const result = this.boundary("fresh", validated ? "transferred to a validated fresh successor with the durable handoff" : "started a compatibility fresh conversation without host validation");
47
142
  return result;
48
143
  }
49
144
  if (!this.adapter.sessionId()) {
50
- await this.adapter.close();
51
- this.adapter = await this.options.create(this.options.settings);
52
- const issue = this.issue("session_missing", "exact provider session is unavailable", "Continue fresh from the durable handoff.");
53
- const result = this.boundary("missing", "continuity unavailable; started fresh from the durable handoff", issue);
145
+ const validated = await this.moveFresh(durableHandoff, "exact provider session identity is unavailable");
146
+ const issue = this.issue("session_missing", "exact provider session is unavailable", validated ? "Continued through a validated cumulative handoff." : "Validated handoff support is unavailable; inspect continuity before proceeding.");
147
+ const result = this.boundary("missing", validated ? "exact session unavailable; transferred to a validated fresh successor" : "exact session unavailable; used the compatibility fresh fallback", issue);
54
148
  return result;
55
149
  }
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
+ }
56
157
  let firstError = "";
57
158
  for (let attempt = 1; attempt <= 2; attempt++) {
58
159
  try {
59
160
  const compacted = this.adapter.compact
60
161
  ? await this.adapter.compact()
61
162
  : { ok: false, error: "provider adapter does not expose native compaction" };
163
+ if (compacted.failure?.category === "session-unavailable")
164
+ throw sessionUnavailableErrorFromFailure(compacted.failure);
62
165
  if (compacted.ok)
63
166
  return this.boundary("compacted", attempt === 1 ? "compacted and continued the exact conversation" : "compaction retry succeeded; continued the exact conversation");
64
167
  firstError = sanitize(compacted.error ?? "provider reported compaction failure");
65
168
  }
66
169
  catch (error) {
170
+ if (error instanceof SessionUnavailableError)
171
+ throw error;
67
172
  firstError = sanitize(error);
68
173
  }
69
174
  if (attempt === 1)
70
175
  this.emit("compaction-retry", `compaction failed (${firstError}); retrying once`);
71
176
  }
72
- await this.adapter.close();
73
- this.adapter = await this.options.create(this.options.settings);
74
- const issue = this.issue("session_compaction_failure", firstError, "Inspect provider health; this work session continued fresh.");
75
- const result = this.boundary("compaction-fallback", `compaction failed twice (${firstError}); started fresh and continuity was lost`, issue);
177
+ const validated = await this.moveFresh(durableHandoff, `native compaction failed twice: ${firstError}`);
178
+ const issue = this.issue("session_compaction_failure", firstError, validated ? "Inspect provider health; the role continued through a validated cumulative handoff." : "Validated handoff support is unavailable; inspect continuity before proceeding.");
179
+ const result = this.boundary("compaction-fallback", `compaction failed twice (${firstError}); ${validated ? "continued through a validated fresh successor" : "used the compatibility fresh fallback"}`, issue);
76
180
  return result;
77
181
  }
78
182
  current() { return this.adapter; }
79
183
  count() { return this.workSessions; }
80
184
  async usage() { return this.adapter?.contextUsage?.(); }
81
185
  async close() { await this.adapter?.close(); this.adapter = undefined; }
186
+ async moveFresh(durableHandoff, reason) {
187
+ const predecessor = this.adapter;
188
+ if (this.options.handoff) {
189
+ const successor = await this.options.handoff({ predecessor, durableHandoff, reason, settings: this.options.settings });
190
+ const predecessorRef = predecessor.sessionRef?.();
191
+ const successorRef = successor.sessionRef?.();
192
+ if (predecessorRef && (!successorRef || providerSessionKey(predecessorRef) === providerSessionKey(successorRef))) {
193
+ if (successor !== predecessor)
194
+ await successor.close().catch(() => { });
195
+ throw new Error("validated handoff did not return a genuinely fresh location-scoped successor");
196
+ }
197
+ this.adapter = successor;
198
+ if (successor !== predecessor)
199
+ await predecessor.close().catch(() => { });
200
+ return true;
201
+ }
202
+ await predecessor.close();
203
+ this.adapter = await this.options.create(this.options.settings);
204
+ return false;
205
+ }
206
+ async adoptManagedAdapter(adapter) {
207
+ const sessionId = adapter.sessionId();
208
+ let ref = adapter.sessionRef?.();
209
+ if (sessionId && !ref) {
210
+ throw new RoleSessionValidationError(this.options.role, {
211
+ version: 1,
212
+ status: "unknown",
213
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
214
+ reason: "legacy-unscoped",
215
+ detail: "a live provider session ID has no location-scoped reference",
216
+ });
217
+ }
218
+ if (ref?.role !== undefined && ref.role !== this.options.role) {
219
+ throw new RoleSessionValidationError(this.options.role, {
220
+ version: 1,
221
+ status: "unavailable",
222
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
223
+ reason: "role-mismatch",
224
+ detail: `provider session belongs to role ${ref.role}, not ${this.options.role}`,
225
+ sessionRef: ref,
226
+ });
227
+ }
228
+ if (ref?.source === "legacy-inferred" && !ref.validatedAt) {
229
+ const availability = await (adapter.validateSession?.() ?? Promise.resolve({
230
+ version: 1,
231
+ status: "unknown",
232
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
233
+ reason: "legacy-unscoped",
234
+ detail: "provider adapter does not expose exact-session validation",
235
+ sessionRef: ref,
236
+ }));
237
+ if (availability.status !== "available" || !availability.sessionRef)
238
+ throw new RoleSessionValidationError(this.options.role, availability);
239
+ ref = availability.sessionRef;
240
+ adapter.adoptSessionRef?.(ref);
241
+ }
242
+ this.adapter = adapter;
243
+ }
244
+ captureManagedIdentity(adapter) {
245
+ const sessionRef = adapter.sessionRef?.();
246
+ return {
247
+ ...(adapter.sessionId() ? { sessionId: adapter.sessionId() } : {}),
248
+ ...(sessionRef ? { sessionRef, sessionKey: providerSessionKey(sessionRef) } : {}),
249
+ };
250
+ }
251
+ acceptManagedTransition(predecessor, successor, kind) {
252
+ const successorRef = successor.sessionRef?.();
253
+ const successorId = successor.sessionId();
254
+ if (!successorId || !successorRef) {
255
+ throw new RoleSessionValidationError(this.options.role, {
256
+ version: 1,
257
+ status: "unknown",
258
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
259
+ reason: "legacy-unscoped",
260
+ detail: `${kind} transition did not return a location-scoped successor`,
261
+ });
262
+ }
263
+ if (successorRef.role !== this.options.role) {
264
+ throw new RoleSessionValidationError(this.options.role, {
265
+ version: 1,
266
+ status: "unavailable",
267
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
268
+ reason: "role-mismatch",
269
+ detail: `${kind} successor belongs to role ${successorRef.role}`,
270
+ sessionRef: successorRef,
271
+ });
272
+ }
273
+ const successorKey = providerSessionKey(successorRef);
274
+ const changed = predecessor.sessionKey !== successorKey;
275
+ if (kind === "handoff" && !changed)
276
+ throw new Error("validated handoff reused the predecessor's scoped provider session");
277
+ if (changed) {
278
+ if (!successorRef.validatedAt)
279
+ throw new Error(`${kind} successor was not validated before adoption`);
280
+ }
281
+ if (changed && predecessor.sessionRef) {
282
+ if (successorRef.generation <= predecessor.sessionRef.generation) {
283
+ throw new Error(`${kind} successor generation did not advance after validated acceptance`);
284
+ }
285
+ }
286
+ this.adapter = successor;
287
+ return successor;
288
+ }
82
289
  boundary(kind, message, issue) {
83
290
  const transition = this.emit(kind, message);
84
291
  return { adapter: this.adapter, transition, issue };
@@ -96,9 +303,1155 @@ export class RoleSessionController {
96
303
  };
97
304
  }
98
305
  }
306
+ export class ContextCapabilityError extends Error {
307
+ runId;
308
+ role;
309
+ constructor(runId, role, message) {
310
+ super(`${message}; new ${role} work is paused because truthful context occupancy is required for run ${runId}`);
311
+ this.runId = runId;
312
+ this.role = role;
313
+ }
314
+ }
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. */
406
+ export class ThresholdCompactionController {
407
+ options;
408
+ settings;
409
+ 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
+ constructor(options) {
420
+ this.options = options;
421
+ this.settings = normalizeContextSettings(options.initialSettings);
422
+ this.historicalCountUncertain = Boolean(options.historicalCountUncertain);
423
+ }
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
+ 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;
538
+ }
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();
599
+ const db = new WorkflowDb(this.options.projectDir);
600
+ try {
601
+ if (sessionKey)
602
+ db.clearRoleAdapterActive(this.options.runId, this.options.role, sessionKey);
603
+ }
604
+ finally {
605
+ db.close();
606
+ }
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}%`);
680
+ }
681
+ return prepared;
682
+ }
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 };
688
+ }
689
+ recoverOrCreateState(managed, key, frozenAction) {
690
+ const db = new WorkflowDb(this.options.projectDir);
691
+ 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;
716
+ }
717
+ finally {
718
+ db.close();
719
+ }
720
+ }
721
+ reconcileIncompleteCompaction(managed, state) {
722
+ const db = new WorkflowDb(this.options.projectDir);
723
+ 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;
729
+ }
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");
742
+ }
743
+ }
744
+ finally {
745
+ db.close();
746
+ }
747
+ }
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;
760
+ try {
761
+ usage = await managed.underlying().contextUsage?.();
762
+ }
763
+ catch (error) {
764
+ lastError = sanitize(error);
765
+ }
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";
771
+ }
772
+ if (attempt + 1 < attempts)
773
+ await new Promise((resolve) => setTimeout(resolve, 100));
774
+ }
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;
780
+ 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");
784
+ }
785
+ 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");
861
+ }
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;
916
+ 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());
960
+ }
961
+ finally {
962
+ completed.close();
963
+ }
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);
1006
+ }
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);
1025
+ try {
1026
+ db.linkCompactionProviderEvent(state.manualAttemptKey, event.providerEventId);
1027
+ }
1028
+ catch { /* a duplicate provider event is already durably linked */ }
1029
+ 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;
1041
+ }
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;
1063
+ }
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);
1090
+ }
1091
+ if (state.boundaryHandoff?.providerEventId === event.providerEventId) {
1092
+ const exceeded = new WorkflowDb(this.options.projectDir);
1093
+ try {
1094
+ exceeded.finishCompactionAttempt(attempt.idempotencyKey, { ok: true, status: "unverified", error: "provider completed a vetoed over-maximum compaction" });
1095
+ }
1096
+ finally {
1097
+ exceeded.close();
1098
+ }
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;
1103
+ }
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);
1117
+ 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());
1122
+ }
1123
+ 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");
1129
+ }
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);
1145
+ }
1146
+ }
1147
+ generationTerminalAttempts(managed, state) {
1148
+ const session = managed.sessionRef?.() ?? managed.sessionId();
1149
+ if (!session)
1150
+ return 0;
1151
+ const db = new WorkflowDb(this.options.projectDir);
1152
+ try {
1153
+ return db.compactionAttempts(this.options.runId, this.options.role, session)
1154
+ .filter((attempt) => attempt.thresholdGenerationId === state.generationId && attempt.status !== "started")
1155
+ .length;
1156
+ }
1157
+ finally {
1158
+ db.close();
1159
+ }
1160
+ }
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;
1170
+ const candidate = this.options.readSettings?.();
1171
+ 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;
1176
+ 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);
1205
+ }
1206
+ 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
+ }
1226
+ }
1227
+ const adoptedState = this.stateFor(managed, false);
1228
+ if (adoptedState?.capabilityFailure) {
1229
+ adoptedState.capabilityFailure = undefined;
1230
+ this.persist(adoptedState, frozenAction);
1231
+ }
1232
+ const db = new WorkflowDb(this.options.projectDir);
1233
+ 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() });
1236
+ }
1237
+ finally {
1238
+ db.close();
1239
+ }
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
+ }
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) {
1304
+ const db = new WorkflowDb(this.options.projectDir);
1305
+ 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
+ });
1315
+ }
1316
+ finally {
1317
+ db.close();
1318
+ }
1319
+ }
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();
1331
+ }
1332
+ this.persist(state);
1333
+ }
1334
+ measuringSample(managed, key, generation) {
1335
+ const ref = managed.sessionRef?.();
1336
+ 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,
1341
+ };
1342
+ }
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
+ compactionCount(session) {
1408
+ if (!session)
1409
+ return 0;
1410
+ const db = new WorkflowDb(this.options.projectDir);
1411
+ try {
1412
+ return db.successfulCompactionCount(this.options.runId, this.options.role, session);
1413
+ }
1414
+ finally {
1415
+ db.close();
1416
+ }
1417
+ }
1418
+ handoffGeneration() {
1419
+ const db = new WorkflowDb(this.options.projectDir);
1420
+ try {
1421
+ return db.handoffs(this.options.runId).at(-1)?.generation ?? 0;
1422
+ }
1423
+ finally {
1424
+ db.close();
1425
+ }
1426
+ }
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; }
1432
+ 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
+ }
1435
+ function normalizeContextSettings(settings) {
1436
+ const threshold = settings.auto_compact_threshold_percent ?? 50;
1437
+ const maximum = settings.compact_maximum ?? 10;
1438
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > 99)
1439
+ throw new Error("auto_compact_threshold_percent must be an integer from 1 to 99");
1440
+ if (!Number.isSafeInteger(maximum) || maximum < 1)
1441
+ throw new Error("compact_maximum must be a positive safe integer");
1442
+ return { ...settings, display_session_cost: settings.display_session_cost ?? false, auto_compact_threshold_percent: threshold, compact_maximum: maximum };
1443
+ }
1444
+ function sha(value) { return createHash("sha256").update(value).digest("hex"); }
99
1445
  function effort(value) {
100
1446
  return ["low", "medium", "high", "xhigh"].includes(value) ? value : undefined;
101
1447
  }
102
1448
  function sanitize(error) {
103
1449
  return sanitizeDiagnostics(error instanceof Error ? error.message : String(error)).slice(0, 1000);
104
1450
  }
1451
+ function adapterSessionKey(adapter) {
1452
+ const ref = adapter.sessionRef?.();
1453
+ return ref ? providerSessionKey(ref) : undefined;
1454
+ }
1455
+ function adapterStateKey(adapter) {
1456
+ return adapterSessionKey(adapter) ?? (adapter.sessionId() ? `${adapter.agent}:unscoped:${adapter.sessionId()}` : undefined);
1457
+ }