ai-foreman 1.7.6 → 1.7.7

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 +16 -1
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +29 -4
  4. package/dist/adapters/claude.d.ts +33 -2
  5. package/dist/adapters/claude.js +208 -17
  6. package/dist/adapters/codex.d.ts +11 -1
  7. package/dist/adapters/codex.js +195 -20
  8. package/dist/adapters/recovering.d.ts +8 -2
  9. package/dist/adapters/recovering.js +23 -3
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +52 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +34 -0
  16. package/dist/branch/currentGuard.js +65 -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 +940 -42
  30. package/dist/cli/tickets.js +199 -3
  31. package/dist/continuity.d.ts +86 -0
  32. package/dist/continuity.js +365 -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 +326 -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 +20 -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 +102 -3
  47. package/dist/sessionLifecycle.js +578 -20
  48. package/dist/statusReporter.d.ts +58 -8
  49. package/dist/statusReporter.js +210 -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 +99 -2
  68. package/dist/workflowDb.js +434 -8
  69. package/package.json +31 -3
@@ -1,27 +1,112 @@
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
+ export class RoleSessionValidationError extends Error {
7
+ role;
8
+ availability;
9
+ constructor(role, availability) {
10
+ super(`exact ${role} session is ${availability.status} (${availability.reason ?? "validation failed"}): ${availability.detail ?? "no provider detail"}`);
11
+ this.role = role;
12
+ this.availability = availability;
13
+ this.name = "RoleSessionValidationError";
14
+ }
15
+ }
2
16
  /** One lifecycle implementation shared by interviews, builds, QA, and recovery. */
3
17
  export class RoleSessionController {
4
18
  options;
5
19
  adapter;
6
20
  workSessions = 0;
21
+ thresholdController;
7
22
  constructor(options) {
8
23
  this.options = options;
9
24
  }
10
- async next(_durableHandoff, nextSettings) {
25
+ /**
26
+ * Production facade: the role owner authorizes scoped adapters and validated
27
+ * successors while the threshold controller remains delegated policy and
28
+ * accounting. Compatibility callers may still construct the threshold
29
+ * controller directly.
30
+ */
31
+ static managed(options) {
32
+ const controller = new RoleSessionController({
33
+ role: options.role,
34
+ settings: options.initialSettings,
35
+ create: async () => { throw new Error("managed role sessions create adapters only through validated lifecycle callbacks"); },
36
+ });
37
+ const settingsBoundary = options.settingsBoundary
38
+ ? async (input) => {
39
+ const predecessor = controller.captureManagedIdentity(input.adapter);
40
+ const successor = await options.settingsBoundary(input);
41
+ return controller.acceptManagedTransition(predecessor, successor, "settings");
42
+ }
43
+ : undefined;
44
+ const handoff = options.handoff
45
+ ? async (input) => {
46
+ const predecessor = controller.captureManagedIdentity(input.adapter);
47
+ const successor = await options.handoff(input);
48
+ return controller.acceptManagedTransition(predecessor, successor, "handoff");
49
+ }
50
+ : undefined;
51
+ controller.thresholdController = new ThresholdCompactionController({ ...options, settingsBoundary, handoff });
52
+ return controller;
53
+ }
54
+ async atSafeBoundary(adapter, frozenAction) {
55
+ if (!this.thresholdController)
56
+ throw new Error("this role session controller does not own threshold lifecycle policy");
57
+ await this.adoptManagedAdapter(adapter);
58
+ const result = await this.thresholdController.atSafeBoundary(adapter, frozenAction);
59
+ this.adapter = result.adapter;
60
+ return result;
61
+ }
62
+ async atWorkSessionBoundary(adapter, frozenAction, strategy) {
63
+ if (!this.thresholdController)
64
+ throw new Error("this role session controller does not own threshold lifecycle policy");
65
+ await this.adoptManagedAdapter(adapter);
66
+ const result = await this.thresholdController.atWorkSessionBoundary(adapter, frozenAction, strategy);
67
+ this.adapter = result.adapter;
68
+ return result;
69
+ }
70
+ effectiveSettings() {
71
+ return this.thresholdController?.effectiveSettings() ?? this.options.settings;
72
+ }
73
+ effectiveThreshold() {
74
+ if (!this.thresholdController)
75
+ return this.options.settings.auto_compact_threshold_percent ?? 50;
76
+ return this.thresholdController.effectiveThreshold();
77
+ }
78
+ async next(durableHandoff, nextSettings) {
11
79
  this.workSessions += 1;
12
80
  if (!this.adapter) {
13
81
  if (nextSettings)
14
82
  this.options.settings = nextSettings;
15
- this.adapter = await this.options.create(this.options.settings, this.options.initialSessionId);
83
+ if (this.options.initialSessionId && !this.options.initialSessionRef) {
84
+ throw new RoleSessionValidationError(this.options.role, {
85
+ version: 1, status: "unknown", checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
86
+ reason: "legacy-unscoped", detail: "a raw provider session ID has no verifiable location scope",
87
+ });
88
+ }
89
+ this.adapter = await this.options.create(this.options.settings, this.options.initialSessionRef?.sessionId ?? this.options.initialSessionId, this.options.initialSessionRef);
90
+ if (this.options.initialSessionRef) {
91
+ const availability = await (this.adapter.validateSession?.() ?? Promise.resolve({
92
+ version: 1, status: "unknown", checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
93
+ reason: "legacy-unscoped", detail: "provider adapter does not expose exact-session validation", sessionRef: this.options.initialSessionRef,
94
+ }));
95
+ if (availability.status !== "available" || !availability.sessionRef) {
96
+ await this.adapter.close().catch(() => { });
97
+ this.adapter = undefined;
98
+ throw new RoleSessionValidationError(this.options.role, availability);
99
+ }
100
+ this.adapter.adoptSessionRef?.(availability.sessionRef);
101
+ }
16
102
  return this.boundary("initial", "started a workflow-scoped provider conversation");
17
103
  }
18
104
  if (nextSettings) {
19
105
  const current = this.options.settings;
20
106
  if (nextSettings.make !== current.make) {
21
- await this.adapter.close();
22
107
  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`);
108
+ const validated = await this.moveFresh(durableHandoff, `provider changed from ${current.make} to ${nextSettings.make}`);
109
+ return this.boundary("settings-fallback", `${validated ? "validated cumulative handoff" : "fresh compatibility fallback"} after provider changed from ${current.make} to ${nextSettings.make}`);
25
110
  }
26
111
  const changed = nextSettings.model !== current.model || nextSettings.reasoning !== current.reasoning || nextSettings.fast !== current.fast;
27
112
  this.options.settings = nextSettings;
@@ -30,27 +115,26 @@ export class RoleSessionController {
30
115
  const switched = this.adapter.switchSettings
31
116
  ? await this.adapter.switchSettings({ model: nextSettings.model === "default" ? undefined : nextSettings.model, effort: effort(nextSettings.reasoning), fast: nextSettings.fast })
32
117
  : { ok: false, error: "provider adapter does not support an in-conversation settings switch" };
118
+ if (switched.failure?.category === "session-unavailable")
119
+ throw sessionUnavailableErrorFromFailure(switched.failure);
33
120
  if (!switched.ok) {
34
- await this.adapter.close();
35
- this.adapter = await this.options.create(nextSettings);
36
121
  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);
122
+ const validated = await this.moveFresh(durableHandoff, `settings switch failed: ${detail}`);
123
+ 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.");
124
+ return this.boundary("settings-fallback", `settings switch failed (${detail}); ${validated ? "continued through a validated fresh successor" : "used the compatibility fresh fallback"}`, issue);
39
125
  }
40
126
  this.emit("settings-continued", "provider accepted the same-conversation settings switch");
41
127
  }
42
128
  }
43
129
  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");
130
+ const validated = await this.moveFresh(durableHandoff, "ordinary session_strategy=fresh boundary");
131
+ 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
132
  return result;
48
133
  }
49
134
  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);
135
+ const validated = await this.moveFresh(durableHandoff, "exact provider session identity is unavailable");
136
+ 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.");
137
+ 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
138
  return result;
55
139
  }
56
140
  let firstError = "";
@@ -59,26 +143,132 @@ export class RoleSessionController {
59
143
  const compacted = this.adapter.compact
60
144
  ? await this.adapter.compact()
61
145
  : { ok: false, error: "provider adapter does not expose native compaction" };
146
+ if (compacted.failure?.category === "session-unavailable")
147
+ throw sessionUnavailableErrorFromFailure(compacted.failure);
62
148
  if (compacted.ok)
63
149
  return this.boundary("compacted", attempt === 1 ? "compacted and continued the exact conversation" : "compaction retry succeeded; continued the exact conversation");
64
150
  firstError = sanitize(compacted.error ?? "provider reported compaction failure");
65
151
  }
66
152
  catch (error) {
153
+ if (error instanceof SessionUnavailableError)
154
+ throw error;
67
155
  firstError = sanitize(error);
68
156
  }
69
157
  if (attempt === 1)
70
158
  this.emit("compaction-retry", `compaction failed (${firstError}); retrying once`);
71
159
  }
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);
160
+ const validated = await this.moveFresh(durableHandoff, `native compaction failed twice: ${firstError}`);
161
+ 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.");
162
+ const result = this.boundary("compaction-fallback", `compaction failed twice (${firstError}); ${validated ? "continued through a validated fresh successor" : "used the compatibility fresh fallback"}`, issue);
76
163
  return result;
77
164
  }
78
165
  current() { return this.adapter; }
79
166
  count() { return this.workSessions; }
80
167
  async usage() { return this.adapter?.contextUsage?.(); }
81
168
  async close() { await this.adapter?.close(); this.adapter = undefined; }
169
+ async moveFresh(durableHandoff, reason) {
170
+ const predecessor = this.adapter;
171
+ if (this.options.handoff) {
172
+ const successor = await this.options.handoff({ predecessor, durableHandoff, reason, settings: this.options.settings });
173
+ const predecessorRef = predecessor.sessionRef?.();
174
+ const successorRef = successor.sessionRef?.();
175
+ if (predecessorRef && (!successorRef || providerSessionKey(predecessorRef) === providerSessionKey(successorRef))) {
176
+ if (successor !== predecessor)
177
+ await successor.close().catch(() => { });
178
+ throw new Error("validated handoff did not return a genuinely fresh location-scoped successor");
179
+ }
180
+ this.adapter = successor;
181
+ if (successor !== predecessor)
182
+ await predecessor.close().catch(() => { });
183
+ return true;
184
+ }
185
+ await predecessor.close();
186
+ this.adapter = await this.options.create(this.options.settings);
187
+ return false;
188
+ }
189
+ async adoptManagedAdapter(adapter) {
190
+ const sessionId = adapter.sessionId();
191
+ let ref = adapter.sessionRef?.();
192
+ if (sessionId && !ref) {
193
+ throw new RoleSessionValidationError(this.options.role, {
194
+ version: 1,
195
+ status: "unknown",
196
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
197
+ reason: "legacy-unscoped",
198
+ detail: "a live provider session ID has no location-scoped reference",
199
+ });
200
+ }
201
+ if (ref?.role !== undefined && ref.role !== this.options.role) {
202
+ throw new RoleSessionValidationError(this.options.role, {
203
+ version: 1,
204
+ status: "unavailable",
205
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
206
+ reason: "role-mismatch",
207
+ detail: `provider session belongs to role ${ref.role}, not ${this.options.role}`,
208
+ sessionRef: ref,
209
+ });
210
+ }
211
+ if (ref?.source === "legacy-inferred" && !ref.validatedAt) {
212
+ const availability = await (adapter.validateSession?.() ?? Promise.resolve({
213
+ version: 1,
214
+ status: "unknown",
215
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
216
+ reason: "legacy-unscoped",
217
+ detail: "provider adapter does not expose exact-session validation",
218
+ sessionRef: ref,
219
+ }));
220
+ if (availability.status !== "available" || !availability.sessionRef)
221
+ throw new RoleSessionValidationError(this.options.role, availability);
222
+ ref = availability.sessionRef;
223
+ adapter.adoptSessionRef?.(ref);
224
+ }
225
+ this.adapter = adapter;
226
+ }
227
+ captureManagedIdentity(adapter) {
228
+ const sessionRef = adapter.sessionRef?.();
229
+ return {
230
+ ...(adapter.sessionId() ? { sessionId: adapter.sessionId() } : {}),
231
+ ...(sessionRef ? { sessionRef, sessionKey: providerSessionKey(sessionRef) } : {}),
232
+ };
233
+ }
234
+ acceptManagedTransition(predecessor, successor, kind) {
235
+ const successorRef = successor.sessionRef?.();
236
+ const successorId = successor.sessionId();
237
+ if (!successorId || !successorRef) {
238
+ throw new RoleSessionValidationError(this.options.role, {
239
+ version: 1,
240
+ status: "unknown",
241
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
242
+ reason: "legacy-unscoped",
243
+ detail: `${kind} transition did not return a location-scoped successor`,
244
+ });
245
+ }
246
+ if (successorRef.role !== this.options.role) {
247
+ throw new RoleSessionValidationError(this.options.role, {
248
+ version: 1,
249
+ status: "unavailable",
250
+ checkedAt: (this.options.now?.() ?? new Date()).toISOString(),
251
+ reason: "role-mismatch",
252
+ detail: `${kind} successor belongs to role ${successorRef.role}`,
253
+ sessionRef: successorRef,
254
+ });
255
+ }
256
+ const successorKey = providerSessionKey(successorRef);
257
+ const changed = predecessor.sessionKey !== successorKey;
258
+ if (kind === "handoff" && !changed)
259
+ throw new Error("validated handoff reused the predecessor's scoped provider session");
260
+ if (changed) {
261
+ if (!successorRef.validatedAt)
262
+ throw new Error(`${kind} successor was not validated before adoption`);
263
+ }
264
+ if (changed && predecessor.sessionRef) {
265
+ if (successorRef.generation <= predecessor.sessionRef.generation) {
266
+ throw new Error(`${kind} successor generation did not advance after validated acceptance`);
267
+ }
268
+ }
269
+ this.adapter = successor;
270
+ return successor;
271
+ }
82
272
  boundary(kind, message, issue) {
83
273
  const transition = this.emit(kind, message);
84
274
  return { adapter: this.adapter, transition, issue };
@@ -96,9 +286,377 @@ export class RoleSessionController {
96
286
  };
97
287
  }
98
288
  }
289
+ export class ContextCapabilityError extends Error {
290
+ runId;
291
+ role;
292
+ constructor(runId, role, message) {
293
+ super(`${message}; new ${role} work is paused because truthful context occupancy is required for run ${runId}`);
294
+ this.runId = runId;
295
+ this.role = role;
296
+ }
297
+ }
298
+ /** Threshold-triggered native compaction at host-owned safe boundaries. */
299
+ export class ThresholdCompactionController {
300
+ options;
301
+ settings;
302
+ armed = true;
303
+ crossing = 0;
304
+ seenSessions = new Set();
305
+ runOnlyThreshold;
306
+ bootstrapScheduled = false;
307
+ bootstrapRecoveryDepth = 0;
308
+ historicalCountUncertain;
309
+ constructor(options) {
310
+ this.options = options;
311
+ this.settings = normalizeContextSettings(options.initialSettings);
312
+ this.historicalCountUncertain = Boolean(options.historicalCountUncertain);
313
+ }
314
+ async atSafeBoundary(adapter, frozenAction) {
315
+ adapter = await this.adoptSettings(adapter, frozenAction);
316
+ if (!adapter.sessionId() && !this.bootstrapScheduled) {
317
+ this.bootstrapScheduled = true;
318
+ const sample = {
319
+ version: 1, runId: this.options.runId, role: this.options.role, provider: adapter.agent,
320
+ model: this.settings.model, observedAt: this.now().toISOString(), source: "provider-query", freshness: "measuring",
321
+ settingsRevision: this.settings.settings_revision, compactionCount: 0, handoffGeneration: this.handoffGeneration(),
322
+ };
323
+ const db = new WorkflowDb(this.options.projectDir);
324
+ try {
325
+ db.recordContextSample(sample);
326
+ }
327
+ finally {
328
+ db.close();
329
+ }
330
+ return { adapter, action: "continued", sample, effectiveThreshold: this.threshold(), compactionCount: 0 };
331
+ }
332
+ let sample = await this.measure(adapter, "provider-query");
333
+ const threshold = this.threshold();
334
+ let session = adapter.sessionRef?.() ?? adapter.sessionId();
335
+ let sessionKey = adapterSessionKey(adapter);
336
+ const firstObservedSession = Boolean(sessionKey && !this.seenSessions.has(sessionKey));
337
+ if (sessionKey)
338
+ this.seenSessions.add(sessionKey);
339
+ const db = new WorkflowDb(this.options.projectDir);
340
+ let count = 0;
341
+ try {
342
+ db.recordContextSample(sample);
343
+ count = db.successfulCompactionCount(this.options.runId, this.options.role, session);
344
+ }
345
+ finally {
346
+ db.close();
347
+ }
348
+ if ((sample.percentage ?? -1) < threshold) {
349
+ this.armed = true;
350
+ return { adapter, action: "below-threshold", sample, effectiveThreshold: threshold, compactionCount: count };
351
+ }
352
+ if (!this.armed)
353
+ return { adapter, action: "continued", sample, effectiveThreshold: threshold, compactionCount: count };
354
+ this.armed = false;
355
+ this.crossing += 1;
356
+ let freshBootstrap = firstObservedSession;
357
+ // A settings command published while the action settled is newer intent.
358
+ const revisionBeforeSettle = this.settings.settings_revision;
359
+ const sessionBeforeSettle = adapterSessionKey(adapter);
360
+ adapter = await this.adoptSettings(adapter, frozenAction);
361
+ if (this.settings.settings_revision !== revisionBeforeSettle || adapterSessionKey(adapter) !== sessionBeforeSettle) {
362
+ sample = await this.measure(adapter, "provider-query");
363
+ session = adapter.sessionRef?.() ?? adapter.sessionId();
364
+ sessionKey = adapterSessionKey(adapter);
365
+ freshBootstrap = Boolean(sessionKey && !this.seenSessions.has(sessionKey));
366
+ if (sessionKey)
367
+ this.seenSessions.add(sessionKey);
368
+ const refreshed = new WorkflowDb(this.options.projectDir);
369
+ try {
370
+ refreshed.recordContextSample(sample);
371
+ count = refreshed.successfulCompactionCount(this.options.runId, this.options.role, adapter.sessionRef?.() ?? adapter.sessionId());
372
+ }
373
+ finally {
374
+ refreshed.close();
375
+ }
376
+ }
377
+ const revisedThreshold = this.threshold();
378
+ if ((sample.percentage ?? -1) < revisedThreshold) {
379
+ this.armed = true;
380
+ return { adapter, action: "continued", sample, effectiveThreshold: revisedThreshold, compactionCount: count };
381
+ }
382
+ const crossingKey = `${sessionKey ?? "missing"}:${this.crossing}:${revisedThreshold}`;
383
+ return this.compactSession(adapter, sample, count, frozenAction, crossingKey, true, freshBootstrap);
384
+ }
385
+ /** Apply the configured ordinary work-session boundary without bypassing handoff or compaction accounting. */
386
+ async atWorkSessionBoundary(adapter, frozenAction, strategy = this.settings.session_strategy) {
387
+ adapter = await this.adoptSettings(adapter, frozenAction);
388
+ const sample = await this.measure(adapter, "provider-query");
389
+ const db = new WorkflowDb(this.options.projectDir);
390
+ let count = 0;
391
+ try {
392
+ db.recordContextSample(sample);
393
+ count = db.successfulCompactionCount(this.options.runId, this.options.role, adapter.sessionRef?.() ?? adapter.sessionId());
394
+ }
395
+ finally {
396
+ db.close();
397
+ }
398
+ if (strategy === "fresh") {
399
+ return this.performHandoff(adapter, sample, count, frozenAction, "ordinary session_strategy=fresh boundary requires a validated fresh successor");
400
+ }
401
+ const crossingKey = `${adapterSessionKey(adapter) ?? "missing"}:ordinary:${count + 1}:${sha(frozenAction).slice(0, 20)}`;
402
+ return this.compactSession(adapter, sample, count, frozenAction, crossingKey, false, false);
403
+ }
404
+ effectiveSettings() { return this.settings; }
405
+ effectiveThreshold() { return this.threshold(); }
406
+ async compactSession(adapter, sample, count, frozenAction, crossingKey, thresholdCrossing, freshBootstrap) {
407
+ const threshold = this.threshold();
408
+ const sessionId = adapter.sessionId();
409
+ const sessionRef = adapter.sessionRef?.();
410
+ const sessionKey = sessionRef ? providerSessionKey(sessionRef) : undefined;
411
+ if (this.historicalCountUncertain)
412
+ return this.performHandoff(adapter, sample, count, frozenAction, "historical compaction count is uncertain; handing off rather than risking the configured maximum");
413
+ if (!sessionId)
414
+ return this.performHandoff(adapter, sample, count, frozenAction, "the provider session identity is unavailable");
415
+ if (!sessionRef)
416
+ return this.performHandoff(adapter, sample, count, frozenAction, "the provider session has no validated location-scoped identity");
417
+ if (count >= (this.settings.compact_maximum ?? 10))
418
+ return this.performHandoff(adapter, sample, count, frozenAction, `compact maximum ${this.settings.compact_maximum ?? 10} reached`);
419
+ if (!adapter.compact)
420
+ return this.performHandoff(adapter, sample, count, frozenAction, "native compaction is unavailable for this provider session");
421
+ const idempotencyKey = `compact:${this.options.runId}:${this.options.role}:${sha(crossingKey).slice(0, 24)}`;
422
+ const attemptDb = new WorkflowDb(this.options.projectDir);
423
+ const prior = attemptDb.startCompactionAttempt({ idempotencyKey, runId: this.options.runId, role: this.options.role, providerSessionId: sessionId, sessionRef, sessionKey, crossingKey, beforeSample: sample });
424
+ attemptDb.close();
425
+ if (prior.status === "succeeded" && prior.afterSample) {
426
+ const replayDb = new WorkflowDb(this.options.projectDir);
427
+ try {
428
+ count = replayDb.successfulCompactionCount(this.options.runId, this.options.role, sessionRef ?? sessionId);
429
+ }
430
+ finally {
431
+ replayDb.close();
432
+ }
433
+ return { adapter, action: "compacted", sample: prior.afterSample, effectiveThreshold: threshold, compactionCount: count };
434
+ }
435
+ this.options.report?.({ kind: "compacting", detail: thresholdCrossing
436
+ ? `context ${sample.percentage?.toFixed(1)}% reached ${threshold}%`
437
+ : `ordinary compact session boundary (${count}/${this.settings.compact_maximum ?? 10})`, sample });
438
+ let compacted;
439
+ try {
440
+ compacted = await adapter.compact();
441
+ }
442
+ catch (error) {
443
+ compacted = error instanceof SessionUnavailableError
444
+ ? { ok: false, error: error.message, failure: error.failure }
445
+ : { ok: false, error: error instanceof Error ? error.message : String(error) };
446
+ }
447
+ if (compacted.failure?.category === "session-unavailable") {
448
+ const failedDb = new WorkflowDb(this.options.projectDir);
449
+ try {
450
+ failedDb.finishCompactionAttempt(idempotencyKey, { ok: false, error: compacted.error ?? compacted.failure.diagnostics });
451
+ failedDb.appendContinuityEvent({
452
+ runId: this.options.runId,
453
+ role: "host",
454
+ kind: "session_unavailable",
455
+ payload: {
456
+ role: this.options.role,
457
+ operation: "native-compaction",
458
+ phase: compacted.failure.phase,
459
+ dispatchState: compacted.failure.dispatchState ?? "unknown",
460
+ reason: compacted.failure.availability?.reason,
461
+ detail: compacted.failure.diagnostics,
462
+ },
463
+ authoritativeStateRevision: failedDb.continuityHead(this.options.runId, this.options.role)?.authoritativeStateRevision ?? this.settings.settings_revision,
464
+ sessionRef,
465
+ });
466
+ if (compacted.failure.dispatchState === "unknown")
467
+ failedDb.setContinuityHeadState(this.options.runId, this.options.role, "degraded");
468
+ }
469
+ finally {
470
+ failedDb.close();
471
+ }
472
+ throw sessionUnavailableErrorFromFailure(compacted.failure);
473
+ }
474
+ if (!compacted.ok) {
475
+ const failedDb = new WorkflowDb(this.options.projectDir);
476
+ try {
477
+ failedDb.finishCompactionAttempt(idempotencyKey, { ok: false, error: compacted.error ?? "provider did not explicitly complete compaction" });
478
+ }
479
+ finally {
480
+ failedDb.close();
481
+ }
482
+ return this.performHandoff(adapter, sample, count, frozenAction, `native compaction failed: ${sanitize(compacted.error ?? "no explicit completion")}`);
483
+ }
484
+ let after;
485
+ try {
486
+ after = await this.measure(adapter, "post-compact");
487
+ }
488
+ catch (error) {
489
+ const failedDb = new WorkflowDb(this.options.projectDir);
490
+ try {
491
+ failedDb.finishCompactionAttempt(idempotencyKey, { ok: false, error: error instanceof Error ? error.message : String(error) });
492
+ }
493
+ finally {
494
+ failedDb.close();
495
+ }
496
+ return this.performHandoff(adapter, sample, count, frozenAction, "provider reported compaction but no authoritative post-compact occupancy sample was available");
497
+ }
498
+ const completedDb = new WorkflowDb(this.options.projectDir);
499
+ try {
500
+ completedDb.recordContextSample(after);
501
+ completedDb.finishCompactionAttempt(idempotencyKey, { ok: true, afterSample: after });
502
+ count = completedDb.successfulCompactionCount(this.options.runId, this.options.role, sessionRef ?? sessionId);
503
+ }
504
+ finally {
505
+ completedDb.close();
506
+ }
507
+ this.options.report?.({ kind: "compacted", detail: `context reduced to ${after.percentage?.toFixed(1)}%`, sample: after });
508
+ if (freshBootstrap && (after.percentage ?? -1) >= threshold) {
509
+ const adjusted = Math.min(99, Math.ceil((after.percentage ?? 0) + 10));
510
+ if (adjusted <= (after.percentage ?? 0))
511
+ 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`);
512
+ this.runOnlyThreshold = adjusted;
513
+ this.options.report?.({ kind: "threshold-adjusted", detail: `fresh bootstrap remained high; run-only threshold is now ${adjusted}%`, sample: after });
514
+ }
515
+ this.armed = thresholdCrossing ? (after.percentage ?? 100) < this.threshold() : true;
516
+ return { adapter, action: "compacted", sample: after, effectiveThreshold: this.threshold(), compactionCount: count };
517
+ }
518
+ async performHandoff(adapter, sample, count, frozenAction, reason) {
519
+ if (!this.options.handoff)
520
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `${reason}; validated handoff is unavailable`);
521
+ this.options.report?.({ kind: "handoff", detail: reason, sample });
522
+ const successor = await this.options.handoff({ reason, adapter, sample, settings: this.settings, compactionCount: count, frozenAction });
523
+ const post = await this.measure(successor, "provider-query");
524
+ this.historicalCountUncertain = false;
525
+ const successorSessionId = successor.sessionId();
526
+ const successorSessionKey = adapterSessionKey(successor);
527
+ if (successorSessionKey)
528
+ this.seenSessions.add(successorSessionKey);
529
+ const db = new WorkflowDb(this.options.projectDir);
530
+ try {
531
+ db.recordContextSample(post);
532
+ }
533
+ finally {
534
+ db.close();
535
+ }
536
+ if ((post.percentage ?? 100) >= this.threshold()) {
537
+ if (this.bootstrapRecoveryDepth >= 1) {
538
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `fresh successor bootstrap remained at ${post.percentage?.toFixed(1)}% after compaction-failure recovery`);
539
+ }
540
+ this.bootstrapRecoveryDepth += 1;
541
+ try {
542
+ const bootstrapped = await this.compactSession(successor, post, 0, frozenAction, `${successorSessionKey ?? successorSessionId ?? "missing"}:fresh-bootstrap:${sha(frozenAction).slice(0, 20)}`, true, true);
543
+ return { ...bootstrapped, action: "handed-off" };
544
+ }
545
+ finally {
546
+ this.bootstrapRecoveryDepth -= 1;
547
+ }
548
+ }
549
+ this.armed = true;
550
+ return { adapter: successor, action: "handed-off", sample: post, effectiveThreshold: this.threshold(), compactionCount: 0 };
551
+ }
552
+ async adoptSettings(adapter, frozenAction) {
553
+ const candidate = this.options.readSettings?.();
554
+ if (!candidate || candidate.settings_revision <= this.settings.settings_revision)
555
+ return adapter;
556
+ const next = normalizeContextSettings(candidate);
557
+ const providerControlsChanged = next.make !== this.settings.make
558
+ || next.model !== this.settings.model
559
+ || next.reasoning !== this.settings.reasoning
560
+ || next.fast !== this.settings.fast;
561
+ let adoptedAdapter = adapter;
562
+ if (providerControlsChanged) {
563
+ if (this.options.settingsBoundary) {
564
+ adoptedAdapter = await this.options.settingsBoundary({ adapter, current: this.settings, next, frozenAction });
565
+ }
566
+ else if (next.make === this.settings.make && adapter.switchSettings) {
567
+ const switched = await adapter.switchSettings({ model: next.model === "default" ? undefined : next.model, effort: effort(next.reasoning), fast: next.fast });
568
+ if (switched.failure?.category === "session-unavailable")
569
+ throw sessionUnavailableErrorFromFailure(switched.failure);
570
+ if (!switched.ok)
571
+ 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")}`);
572
+ }
573
+ else {
574
+ 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`);
575
+ }
576
+ if (adoptedAdapter.agent !== next.make) {
577
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `settings revision ${next.settings_revision} requested ${next.make}, but the adopted session reports ${adoptedAdapter.agent}`);
578
+ }
579
+ }
580
+ this.settings = next;
581
+ this.runOnlyThreshold = undefined;
582
+ const db = new WorkflowDb(this.options.projectDir);
583
+ try {
584
+ const ref = adoptedAdapter.sessionRef?.();
585
+ 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() });
586
+ }
587
+ finally {
588
+ db.close();
589
+ }
590
+ this.options.settingsAdopted?.(next, adoptedAdapter);
591
+ this.options.report?.({ kind: "settings-adopted", detail: `adopted settings revision ${next.settings_revision}` });
592
+ return adoptedAdapter;
593
+ }
594
+ async measure(adapter, source) {
595
+ const attempts = Math.max(1, this.options.contextRetries ?? 3);
596
+ let usage;
597
+ for (let attempt = 0; attempt < attempts; attempt++) {
598
+ try {
599
+ usage = await adapter.contextUsage?.();
600
+ }
601
+ catch {
602
+ usage = undefined;
603
+ }
604
+ if (usage && Number.isFinite(usage.used) && (usage.percentage !== undefined || usage.maximum !== undefined))
605
+ break;
606
+ }
607
+ if (!usage || !Number.isFinite(usage.used))
608
+ throw new ContextCapabilityError(this.options.runId, this.options.role, `truthful context occupancy was unavailable after ${attempts} provider attempt(s)`);
609
+ const percentage = usage.percentage ?? (usage.maximum ? usage.used / usage.maximum * 100 : undefined);
610
+ if (percentage === undefined || !Number.isFinite(percentage))
611
+ throw new ContextCapabilityError(this.options.runId, this.options.role, "provider context occupancy lacks a usable percentage and maximum");
612
+ const ref = adapter.sessionRef?.();
613
+ return {
614
+ version: 1, runId: this.options.runId, role: this.options.role, provider: adapter.agent,
615
+ providerSessionId: adapter.sessionId(), ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}), model: this.settings.model, observedAt: usage.observedAt ?? this.now().toISOString(),
616
+ source, freshness: "fresh", used: usage.used, maximum: usage.maximum, percentage,
617
+ settingsRevision: this.settings.settings_revision,
618
+ compactionCount: adapter.sessionId() ? this.compactionCount(ref ?? adapter.sessionId()) : 0,
619
+ handoffGeneration: this.handoffGeneration(),
620
+ };
621
+ }
622
+ compactionCount(session) {
623
+ const db = new WorkflowDb(this.options.projectDir);
624
+ try {
625
+ return db.successfulCompactionCount(this.options.runId, this.options.role, session);
626
+ }
627
+ finally {
628
+ db.close();
629
+ }
630
+ }
631
+ handoffGeneration() {
632
+ const db = new WorkflowDb(this.options.projectDir);
633
+ try {
634
+ return db.handoffs(this.options.runId).at(-1)?.generation ?? 0;
635
+ }
636
+ finally {
637
+ db.close();
638
+ }
639
+ }
640
+ threshold() { return this.runOnlyThreshold ?? this.settings.auto_compact_threshold_percent ?? 50; }
641
+ now() { return this.options.now?.() ?? new Date(); }
642
+ }
643
+ function normalizeContextSettings(settings) {
644
+ const threshold = settings.auto_compact_threshold_percent ?? 50;
645
+ const maximum = settings.compact_maximum ?? 10;
646
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > 99)
647
+ throw new Error("auto_compact_threshold_percent must be an integer from 1 to 99");
648
+ if (!Number.isSafeInteger(maximum) || maximum < 1)
649
+ throw new Error("compact_maximum must be a positive safe integer");
650
+ return { ...settings, display_session_cost: settings.display_session_cost ?? false, auto_compact_threshold_percent: threshold, compact_maximum: maximum };
651
+ }
652
+ function sha(value) { return createHash("sha256").update(value).digest("hex"); }
99
653
  function effort(value) {
100
654
  return ["low", "medium", "high", "xhigh"].includes(value) ? value : undefined;
101
655
  }
102
656
  function sanitize(error) {
103
657
  return sanitizeDiagnostics(error instanceof Error ? error.message : String(error)).slice(0, 1000);
104
658
  }
659
+ function adapterSessionKey(adapter) {
660
+ const ref = adapter.sessionRef?.();
661
+ return ref ? providerSessionKey(ref) : undefined;
662
+ }