ai-foreman 1.7.18 → 1.7.19

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 (83) hide show
  1. package/README.md +18 -7
  2. package/dist/activity.d.ts +11 -1
  3. package/dist/activity.js +70 -15
  4. package/dist/adapters/claude.d.ts +11 -1
  5. package/dist/adapters/claude.js +122 -18
  6. package/dist/adapters/codex.d.ts +13 -1
  7. package/dist/adapters/codex.js +148 -31
  8. package/dist/adapters/recovering.d.ts +3 -1
  9. package/dist/adapters/recovering.js +12 -4
  10. package/dist/adapters/types.d.ts +27 -1
  11. package/dist/agentRun.js +2 -0
  12. package/dist/branch/currentGuard.d.ts +3 -1
  13. package/dist/branch/currentGuard.js +11 -2
  14. package/dist/branch/resume.js +1 -1
  15. package/dist/branch/runner.d.ts +2 -0
  16. package/dist/branch/runner.js +90 -11
  17. package/dist/buildApproval.d.ts +9 -0
  18. package/dist/buildApproval.js +126 -0
  19. package/dist/buildRuns.js +70 -32
  20. package/dist/cli/recovery.js +9 -13
  21. package/dist/cli/runtimeAuthPrompt.d.ts +7 -0
  22. package/dist/cli/runtimeAuthPrompt.js +40 -5
  23. package/dist/cli/start.d.ts +16 -1
  24. package/dist/cli/start.js +159 -79
  25. package/dist/cli/tickets.d.ts +11 -0
  26. package/dist/cli/tickets.js +382 -145
  27. package/dist/continuity.d.ts +7 -1
  28. package/dist/continuity.js +86 -7
  29. package/dist/diagnostics.js +35 -1
  30. package/dist/foreman.d.ts +8 -1
  31. package/dist/foreman.js +144 -29
  32. package/dist/handoffs.js +121 -79
  33. package/dist/humanDecision.d.ts +18 -0
  34. package/dist/humanDecision.js +43 -0
  35. package/dist/index.js +2 -1
  36. package/dist/log.d.ts +1 -1
  37. package/dist/managerPacket.js +1 -0
  38. package/dist/observability.js +2 -1
  39. package/dist/providerQuestions.d.ts +5 -0
  40. package/dist/providerQuestions.js +24 -3
  41. package/dist/qaDeliveryJournal.d.ts +70 -0
  42. package/dist/qaDeliveryJournal.js +1 -0
  43. package/dist/qaFailureDelivery.d.ts +26 -10
  44. package/dist/qaFailureDelivery.js +399 -412
  45. package/dist/qaHandbackHistory.d.ts +4 -0
  46. package/dist/qaHandbackHistory.js +30 -0
  47. package/dist/qaHandbackMigration.d.ts +9 -0
  48. package/dist/qaHandbackMigration.js +147 -0
  49. package/dist/qaPrerequisites.d.ts +26 -0
  50. package/dist/qaPrerequisites.js +61 -0
  51. package/dist/qaProtocolV2.d.ts +8 -0
  52. package/dist/qaProtocolV2.js +3 -3
  53. package/dist/qaReview.d.ts +8 -0
  54. package/dist/qaReview.js +54 -10
  55. package/dist/questionTrace.d.ts +31 -0
  56. package/dist/questionTrace.js +85 -0
  57. package/dist/readOnlyProposal.d.ts +8 -0
  58. package/dist/readOnlyProposal.js +41 -0
  59. package/dist/recoveryPolicy.js +14 -3
  60. package/dist/runtimeAuth.d.ts +2 -1
  61. package/dist/runtimeAuth.js +2 -2
  62. package/dist/runtimeReadiness.d.ts +13 -1
  63. package/dist/runtimeReadiness.js +69 -11
  64. package/dist/sessionLifecycle.d.ts +1 -0
  65. package/dist/sessionLifecycle.js +53 -3
  66. package/dist/sources/sourceRegistry.d.ts +7 -0
  67. package/dist/sources/sourceRegistry.js +14 -0
  68. package/dist/stateTransfer.js +2 -0
  69. package/dist/statusReporter.d.ts +3 -1
  70. package/dist/statusReporter.js +8 -2
  71. package/dist/supervisedStart.d.ts +12 -0
  72. package/dist/supervisedStart.js +276 -0
  73. package/dist/supervisor.js +44 -22
  74. package/dist/ticketPopulation.js +1 -0
  75. package/dist/tickets/setupConfig.d.ts +8 -0
  76. package/dist/tickets/setupConfig.js +38 -4
  77. package/dist/util/deadline.d.ts +13 -0
  78. package/dist/util/deadline.js +60 -0
  79. package/dist/workflowDb.d.ts +55 -11
  80. package/dist/workflowDb.js +288 -63
  81. package/dist/workflowReader.d.ts +10 -2
  82. package/dist/workflowReader.js +35 -1
  83. package/package.json +3 -3
@@ -1,3 +1,4 @@
1
+ import { OperationDeadline } from "../util/deadline.js";
1
2
  import { spawn } from "node:child_process";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { existsSync, readFileSync } from "node:fs";
@@ -44,6 +45,7 @@ export class CodexAdapter {
44
45
  usage;
45
46
  usageRevision = 0;
46
47
  providerSessionUsage;
48
+ turnUsageBaseline;
47
49
  lastTurnTokens;
48
50
  stderr = "";
49
51
  nativeAutoCompactTokenLimit;
@@ -53,6 +55,9 @@ export class CodexAdapter {
53
55
  nativeCompactions = [];
54
56
  nativeCompactionSequence = 0;
55
57
  manualCompactionInFlight = false;
58
+ compactionPromise;
59
+ compactionActive = false;
60
+ compactionUncertain = false;
56
61
  observedToolCalls = 0;
57
62
  activeProviderTurnId;
58
63
  activeProviderTurnSpanId;
@@ -87,13 +92,13 @@ export class CodexAdapter {
87
92
  args.push(instruction);
88
93
  return args;
89
94
  }
90
- async sendTurn(instruction) {
95
+ async sendTurn(instruction, policy) {
91
96
  const turnId = randomUUID();
92
97
  this.activeProviderTurnId = turnId;
93
98
  const observer = this.opts.observer;
94
99
  if (!observer) {
95
100
  try {
96
- return await withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction));
101
+ return await withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction, policy));
97
102
  }
98
103
  finally {
99
104
  this.activeProviderTurnId = undefined;
@@ -104,7 +109,7 @@ export class CodexAdapter {
104
109
  this.activeProviderTurnSpanId = spanId;
105
110
  observer.store.updateCurrentState({ runId: observer.runId, role: context.role ?? "host", stream: context.stream ?? "codex", executionId: observer.executionId, ticketId: context.ticketId, deliveryUnitId: context.deliveryUnitId, providerSessionId: context.providerSessionId, phase: "provider turn", activeSpanId: spanId, activeSpanKind: "provider_turn", lastSemanticProgressAt: new Date().toISOString() });
106
111
  try {
107
- const result = await withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction));
112
+ const result = await withActivityPhase(`Codex ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(instruction, policy));
108
113
  observer.store.finishSpan(spanId, { outcome: result.isError ? "failed" : "completed", attributes: { usage: result.usage } });
109
114
  return result;
110
115
  }
@@ -121,7 +126,7 @@ export class CodexAdapter {
121
126
  this.activeProviderTurnSpanId = undefined;
122
127
  }
123
128
  }
124
- async sendTurnInternal(instruction) {
129
+ async sendTurnInternal(instruction, policy) {
125
130
  if (this.closed)
126
131
  throw new Error("builder is closed");
127
132
  this.eventQueue.push({ kind: "activity", state: "starting Codex turn", provider: "codex", model: this.opts.model });
@@ -129,8 +134,10 @@ export class CodexAdapter {
129
134
  const providerInstruction = this.buildInstruction(instruction);
130
135
  try {
131
136
  await this.ensureThread();
137
+ const toolsBefore = this.observedToolCalls;
132
138
  this.activeText = [];
133
139
  this.lastTurnTokens = undefined;
140
+ this.turnUsageBaseline = this.providerSessionUsage ? { ...this.providerSessionUsage } : undefined;
134
141
  const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId, this.providerIdleTimeoutMs(), true);
135
142
  turnStartDispatched = true;
136
143
  await this.request("turn/start", {
@@ -140,16 +147,17 @@ export class CodexAdapter {
140
147
  model: this.opts.model ?? null,
141
148
  effort: this.opts.effort ?? (this.opts.fast ? "low" : null),
142
149
  });
143
- const params = await completion;
150
+ const params = await new OperationDeadline("Codex active turn", this.opts.turnDeadlineMs ?? 3_600_000).run(() => completion);
144
151
  const turn = params.turn;
145
- const failed = turn?.status === "failed";
152
+ const responseOnlyViolation = Boolean(policy?.responseOnly && this.observedToolCalls !== toolsBefore);
153
+ const failed = turn?.status === "failed" || responseOnlyViolation;
146
154
  const error = turn?.error;
147
155
  const text = this.activeText.join("\n");
148
156
  const result = {
149
- text: failed ? normalizeRuntimeErrorText("codex", String(error?.message ?? text), null, "app-server turn") : text,
157
+ text: failed ? normalizeRuntimeErrorText("codex", String(responseOnlyViolation ? "Response-only correction used tools; its result cannot authorize completion" : error?.message ?? text), null, "app-server turn") : text,
150
158
  isError: failed, numTurns: 1, costUsd: 0, costAuthoritative: false,
151
159
  turnId: this.activeProviderTurnId, hostInstruction: instruction, providerInstruction,
152
- rawResponse: text, cleanedResponse: failed ? normalizeRuntimeErrorText("codex", String(error?.message ?? text), null, "app-server turn") : text,
160
+ rawResponse: text, cleanedResponse: failed ? normalizeRuntimeErrorText("codex", String(responseOnlyViolation ? "Response-only correction used tools; its result cannot authorize completion" : error?.message ?? text), null, "app-server turn") : text,
153
161
  providerMetadata: { provider: "codex", sessionId: this._sessionId, sessionRef: this._sessionRef },
154
162
  ...(this.lastTurnTokens ?? {}),
155
163
  };
@@ -181,7 +189,7 @@ export class CodexAdapter {
181
189
  executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: text,
182
190
  availability: { version: 1, status: turnStartDispatched ? "unknown" : "unavailable", checkedAt: new Date().toISOString(), reason: turnStartDispatched ? "probe-failed" : "attach-failed", detail: text, sessionRef: this.opts.resumeSessionRef },
183
191
  }))
184
- : { text, isError: true, numTurns: 1, costUsd: 0, costAuthoritative: false };
192
+ : { text, isError: true, numTurns: 1, costUsd: 0, costAuthoritative: false, failure: { runtime: "codex", phase: turnStartDispatched ? "turn" : "attach", category: "agent-stream", executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: text, dispatchState: turnStartDispatched ? "unknown" : "not-sent" } };
185
193
  this.eventQueue.push({ kind: "error", message: text });
186
194
  result.turnId = this.activeProviderTurnId;
187
195
  result.hostInstruction = instruction;
@@ -194,13 +202,36 @@ export class CodexAdapter {
194
202
  }
195
203
  }
196
204
  async compact() {
197
- return this.opts.observer
205
+ if (this.compactionPromise)
206
+ return this.compactionPromise;
207
+ const operation = this.opts.observer
198
208
  ? this.opts.observer.span("compaction", "Codex context compaction", () => this.compactInternal())
199
209
  : this.compactInternal();
210
+ this.compactionPromise = operation;
211
+ try {
212
+ return await operation;
213
+ }
214
+ finally {
215
+ if (this.compactionPromise === operation)
216
+ this.compactionPromise = undefined;
217
+ }
200
218
  }
201
219
  async compactInternal() {
220
+ if (this.compactionUncertain)
221
+ return { ok: false, error: "Prior compaction outcome is uncertain; validate a fresh successor before further work" };
222
+ if (this.compactionActive)
223
+ return { ok: false, error: "Compaction is already in progress for this session" };
224
+ if (this.manualCompactionInFlight)
225
+ return { ok: false, error: "Codex compaction already in flight" };
226
+ this.compactionActive = true;
227
+ const controller = new AbortController();
228
+ const began = performance.now();
229
+ const timeoutMs = Math.min(180_000, Math.max(1, this.opts.compactionTimeoutMs ?? 120_000));
230
+ const hardTimeoutMs = Math.min(180_000, timeoutMs * 1.5);
231
+ this.traceLifecycle("compaction-started", { timeoutMs });
232
+ const deadline = new OperationDeadline("Codex compaction", timeoutMs, hardTimeoutMs);
202
233
  try {
203
- await this.ensureThread();
234
+ await deadline.run(() => this.ensureThread());
204
235
  this.eventQueue.push({ kind: "session-transition", transition: "compacting" });
205
236
  const usageRevision = this.usageRevision;
206
237
  // A pre-compaction observation must never be mistaken for proof that the
@@ -208,36 +239,71 @@ export class CodexAdapter {
208
239
  // usage notification for the compacted thread; require it alongside the
209
240
  // explicit contextCompaction completion item.
210
241
  this.usage = undefined;
242
+ let compactionItemId;
243
+ const progress = this.waitFor("item/started", params => {
244
+ const item = params.item;
245
+ if (params.threadId !== this._sessionId || item?.type !== "contextCompaction" || typeof item.id !== "string")
246
+ return false;
247
+ compactionItemId = item.id;
248
+ deadline.extendForCorrelatedProgress();
249
+ this.traceLifecycle("compaction-deadline-extended", { timeoutMs: hardTimeoutMs });
250
+ return true;
251
+ }, hardTimeoutMs, false, controller.signal);
252
+ void progress.catch(() => { });
211
253
  const done = this.waitFor("item/completed", (params) => {
212
254
  const item = params.item;
213
- return params.threadId === this._sessionId && item?.type === "contextCompaction";
214
- }, 30_000);
255
+ return params.threadId === this._sessionId && item?.type === "contextCompaction" && (!compactionItemId || item.id === compactionItemId);
256
+ }, hardTimeoutMs, false, controller.signal);
215
257
  const postCompactUsage = this.waitFor("thread/tokenUsage/updated", (params) => {
216
- return (params.threadId === undefined || params.threadId === this._sessionId)
258
+ return params.threadId === this._sessionId
217
259
  && this.usageRevision > usageRevision
218
260
  && this.usage !== undefined;
219
- }, 30_000);
261
+ }, hardTimeoutMs, false, controller.signal);
220
262
  this.manualCompactionInFlight = true;
221
263
  try {
222
- await Promise.all([
223
- this.request("thread/compact/start", { threadId: this._sessionId }),
224
- done,
225
- postCompactUsage,
226
- ]);
264
+ const acknowledgementFailure = this.request("thread/compact/start", { threadId: this._sessionId }).then(() => { this.traceLifecycle("compaction-acknowledged"); return new Promise(() => { }); }, error => {
265
+ // A missing acknowledgement is not failure evidence. Explicit
266
+ // completion plus fresh occupancy is sufficient to reconcile it.
267
+ if (/deadline|timed out/i.test(String(error)))
268
+ return new Promise(() => { });
269
+ throw error;
270
+ });
271
+ await deadline.run(() => Promise.race([Promise.all([done, postCompactUsage]), acknowledgementFailure]));
227
272
  }
228
273
  finally {
229
274
  this.manualCompactionInFlight = false;
230
275
  }
231
276
  this.eventQueue.push({ kind: "session-transition", transition: "compacted" });
277
+ this.traceLifecycle("compaction-completed", { elapsedMs: performance.now() - began });
232
278
  return { ok: true };
233
279
  }
234
280
  catch (error) {
235
281
  if (error instanceof SessionUnavailableError)
236
282
  return { ok: false, error: error.message, failure: error.failure };
237
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
283
+ this.compactionUncertain = true;
284
+ this.usage = undefined;
285
+ this.traceLifecycle("compaction-failed", { elapsedMs: performance.now() - began });
286
+ const detail = error instanceof Error ? error.message : String(error);
287
+ if (/deadline|timed out/i.test(detail)) {
288
+ // Waiting expired, not the provider operation. Quarantine this adapter;
289
+ // the host must reconcile it instead of automatically starting a writer.
290
+ const failure = new SessionUnavailableError({ runtime: "codex", phase: "turn", dispatchState: "unknown", executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: detail });
291
+ this.closed = true;
292
+ this.disconnect(failure);
293
+ this.eventQueue.close();
294
+ return { ok: false, error: detail, failure: failure.failure };
295
+ }
296
+ return { ok: false, error: detail };
297
+ }
298
+ finally {
299
+ controller.abort();
300
+ this.compactionActive = false;
238
301
  }
239
302
  }
240
303
  async prepareAutoCompaction(thresholdPercent = this.opts.autoCompactThresholdPercent) {
304
+ return new OperationDeadline("Codex auto-compaction preparation", this.opts.preparationTimeoutMs ?? 120_000).run(() => this.prepareAutoCompactionInternal(thresholdPercent), () => this.disconnect(new Error("Codex preparation expired")));
305
+ }
306
+ async prepareAutoCompactionInternal(thresholdPercent = this.opts.autoCompactThresholdPercent) {
241
307
  if (thresholdPercent === undefined)
242
308
  return;
243
309
  const threshold = validThreshold(thresholdPercent);
@@ -334,15 +400,35 @@ export class CodexAdapter {
334
400
  return { version: 1, status: "unknown", checkedAt, reason: "probe-failed", detail: error instanceof Error ? error.message : String(error), sessionRef: this._sessionRef };
335
401
  }
336
402
  }
403
+ observeEvents(listener) { return this.eventQueue.observe(listener); }
337
404
  events() { return this.eventQueue; }
338
405
  async close() {
339
406
  if (this.closed)
340
407
  return;
341
408
  this.closed = true;
409
+ const child = this.process;
410
+ let onExit;
411
+ const exited = child && child.exitCode === null ? new Promise(resolve => { onExit = resolve; child.once("exit", resolve); }) : Promise.resolve();
342
412
  this.disconnect(new Error("app-server closed"));
343
413
  this.eventQueue.close();
414
+ try {
415
+ try {
416
+ await new OperationDeadline("Codex shutdown", this.opts.shutdownTimeoutMs ?? 10_000).run(() => exited);
417
+ }
418
+ catch {
419
+ child?.kill("SIGKILL");
420
+ await new OperationDeadline("Codex forced shutdown", 5_000).run(() => exited);
421
+ }
422
+ }
423
+ finally {
424
+ if (onExit)
425
+ child?.off("exit", onExit);
426
+ }
344
427
  }
345
428
  async ensureThread() {
429
+ return new OperationDeadline("Codex preparation", this.opts.preparationTimeoutMs ?? 120_000).run(() => this.ensureThreadInternal(), () => this.disconnect(new Error("Codex preparation deadline exceeded")));
430
+ }
431
+ async ensureThreadInternal() {
346
432
  if (this._sessionRef && !this.threadAttached) {
347
433
  if (this._sessionRef.source === "legacy-inferred") {
348
434
  const availability = { version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped", detail: "legacy Codex thread IDs cannot be proven exact without an observed scoped binding", sessionRef: this._sessionRef };
@@ -463,7 +549,7 @@ export class CodexAdapter {
463
549
  }
464
550
  request(method, params) {
465
551
  const id = this.nextId++;
466
- return new Promise((resolve, reject) => {
552
+ return new OperationDeadline(`Codex RPC ${method}`, this.opts.rpcTimeoutMs ?? 60_000).run(() => new Promise((resolve, reject) => {
467
553
  this.pending.set(id, { resolve, reject });
468
554
  try {
469
555
  this.write({ id, method, params });
@@ -472,7 +558,7 @@ export class CodexAdapter {
472
558
  this.pending.delete(id);
473
559
  reject(error);
474
560
  }
475
- });
561
+ }), () => { this.pending.delete(id); });
476
562
  }
477
563
  write(message) {
478
564
  if (!this.process?.stdin.writable)
@@ -566,7 +652,9 @@ export class CodexAdapter {
566
652
  const tokenUsage = params.tokenUsage;
567
653
  const total = tokenUsage?.total;
568
654
  const last = tokenUsage?.last;
569
- const used = Number(total?.totalTokens);
655
+ if (this._sessionId && params.threadId !== this._sessionId)
656
+ return;
657
+ const used = optionalNonNegative(last?.totalTokens);
570
658
  const maximum = Number(tokenUsage?.modelContextWindow);
571
659
  const observedAt = new Date().toISOString();
572
660
  const totalInput = optionalNonNegative(total?.inputTokens);
@@ -575,11 +663,14 @@ export class CodexAdapter {
575
663
  if (totalInput !== undefined || totalOutput !== undefined || sessionTotal !== undefined) {
576
664
  this.providerSessionUsage = { inputTokens: totalInput, outputTokens: totalOutput, totalTokens: sessionTotal, observedAt, source: "provider" };
577
665
  }
666
+ if ((totalInput !== undefined && this.turnUsageBaseline?.inputTokens !== undefined && totalInput < this.turnUsageBaseline.inputTokens) || (totalOutput !== undefined && this.turnUsageBaseline?.outputTokens !== undefined && totalOutput < this.turnUsageBaseline.outputTokens))
667
+ this.turnUsageBaseline = undefined;
578
668
  this.lastTurnTokens = {
579
- inputTokens: optionalNonNegative(last?.inputTokens),
580
- outputTokens: optionalNonNegative(last?.outputTokens),
669
+ inputTokens: cumulativeDelta(this.turnUsageBaseline?.inputTokens, totalInput),
670
+ outputTokens: cumulativeDelta(this.turnUsageBaseline?.outputTokens, totalOutput),
581
671
  };
582
- if (Number.isFinite(used) && used >= 0 && Number.isFinite(maximum) && maximum > 0) {
672
+ this.usage = undefined;
673
+ if (used !== undefined && Number.isFinite(maximum) && maximum > 0) {
583
674
  this.usage = { used, maximum, percentage: used / maximum * 100, observedAt, source: "provider-event" };
584
675
  this.usageRevision += 1;
585
676
  this.eventQueue.push({ kind: "context-usage", ...this.usage });
@@ -617,16 +708,29 @@ export class CodexAdapter {
617
708
  ticketId: this.opts.ticketId ?? inherited.ticketId, deliveryUnitId: this.opts.deliveryUnitId ?? inherited.deliveryUnitId,
618
709
  parentSpanId: this.activeProviderTurnSpanId ?? inherited.parentSpanId, providerTurnId: this.activeProviderTurnId ?? inherited.providerTurnId };
619
710
  }
620
- waitFor(method, predicate, timeoutMs, resetOnProviderActivity = false) {
711
+ waitFor(method, predicate, timeoutMs, resetOnProviderActivity = false, signal) {
621
712
  return new Promise((resolve, reject) => {
622
713
  let timeout;
714
+ const began = performance.now();
623
715
  const clear = () => { if (timeout)
624
- clearTimeout(timeout); timeout = undefined; };
716
+ clearTimeout(timeout); timeout = undefined; signal?.removeEventListener("abort", abort); };
625
717
  const waiter = {
626
718
  predicate,
627
- resolve: (params) => { clear(); resolve(params); },
719
+ resolve: (params) => { clear(); this.traceLifecycle("waiter-completed", { method, elapsedMs: performance.now() - began }); resolve(params); },
628
720
  reject: (error) => { clear(); reject(error); },
629
721
  };
722
+ const abort = () => {
723
+ this.notificationWaiters.set(method, (this.notificationWaiters.get(method) ?? []).filter(candidate => candidate !== waiter));
724
+ clear();
725
+ this.traceLifecycle("waiter-cancelled", { method });
726
+ reject(new Error(`Codex waiter cancelled: ${method}`));
727
+ };
728
+ if (signal?.aborted) {
729
+ abort();
730
+ return;
731
+ }
732
+ signal?.addEventListener("abort", abort, { once: true });
733
+ this.traceLifecycle("waiter-registered", { method, timeoutMs });
630
734
  const waiters = this.notificationWaiters.get(method) ?? [];
631
735
  waiters.push(waiter);
632
736
  this.notificationWaiters.set(method, waiters);
@@ -635,7 +739,9 @@ export class CodexAdapter {
635
739
  timeout = setTimeout(() => {
636
740
  const active = this.notificationWaiters.get(method) ?? [];
637
741
  this.notificationWaiters.set(method, active.filter((candidate) => candidate !== waiter));
638
- reject(new Error(`Codex provider was silent for ${Math.round(timeoutMs / 60_000)} minutes while waiting for ${method}; the turn may have been dispatched and will not be retried automatically`));
742
+ clear();
743
+ this.traceLifecycle("waiter-timeout", { method, timeoutMs, elapsedMs: performance.now() - began });
744
+ reject(new Error(`Codex provider wait timed out after ${timeoutMs} ms while waiting for ${method}; the turn may have been dispatched and will not be retried automatically`));
639
745
  }, timeoutMs);
640
746
  };
641
747
  if (resetOnProviderActivity)
@@ -644,6 +750,12 @@ export class CodexAdapter {
644
750
  }
645
751
  });
646
752
  }
753
+ traceLifecycle(phase, data = {}) {
754
+ try {
755
+ this.opts.onLifecycleTrace?.({ phase, at: new Date().toISOString(), sessionId: this._sessionId, ...data });
756
+ }
757
+ catch { /* diagnostics cannot change execution */ }
758
+ }
647
759
  touchWaiters() {
648
760
  for (const waiters of this.notificationWaiters.values())
649
761
  for (const waiter of waiters)
@@ -689,6 +801,8 @@ export class CodexAdapter {
689
801
  }
690
802
  }
691
803
  function optionalNonNegative(value) {
804
+ if (value === null || value === undefined || value === "")
805
+ return undefined;
692
806
  const number = Number(value);
693
807
  return Number.isFinite(number) && number >= 0 ? number : undefined;
694
808
  }
@@ -759,3 +873,6 @@ function loadSkillMarkdown(cwd, skill) {
759
873
  return undefined;
760
874
  }
761
875
  }
876
+ function cumulativeDelta(before, after) {
877
+ return before !== undefined && after !== undefined && after >= before ? after - before : undefined;
878
+ }
@@ -25,7 +25,7 @@ export declare class RecoveringAdapter implements BuilderAdapter {
25
25
  private closed;
26
26
  constructor(opts: RecoveringAdapterOptions);
27
27
  get agent(): "claude" | "codex";
28
- sendTurn(text: string): Promise<TurnResult>;
28
+ sendTurn(text: string, policy?: Parameters<BuilderAdapter["sendTurn"]>[1]): Promise<TurnResult>;
29
29
  sessionId(): string | undefined;
30
30
  sessionRef(): ProviderSessionRefV1 | undefined;
31
31
  prepareSession(): Promise<ProviderSessionRefV1>;
@@ -41,6 +41,8 @@ export declare class RecoveringAdapter implements BuilderAdapter {
41
41
  contextUsage(): Promise<ContextUsage | undefined>;
42
42
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
43
43
  events(): AsyncIterable<BuilderEvent>;
44
+ observeEvents(listener: (event: BuilderEvent) => void): () => void;
45
+ acceptHandbackTurn(turn: TurnResult): void;
44
46
  close(): Promise<void>;
45
47
  private replaceAdapter;
46
48
  private forwardEvents;
@@ -19,9 +19,9 @@ export class RecoveringAdapter {
19
19
  get agent() {
20
20
  return this.adapter.agent;
21
21
  }
22
- async sendTurn(text) {
22
+ async sendTurn(text, policy) {
23
23
  while (true) {
24
- const result = await this.adapter.sendTurn(text);
24
+ const result = await this.adapter.sendTurn(text, policy);
25
25
  const sessionRef = this.adapter.sessionRef?.();
26
26
  if (sessionRef && this.opts.onSessionRef) {
27
27
  try {
@@ -31,11 +31,11 @@ export class RecoveringAdapter {
31
31
  this.eventQueue.push({ kind: "error", message: `failed to persist scoped provider session binding: ${error instanceof Error ? error.message : String(error)}` });
32
32
  }
33
33
  }
34
- if (!this.opts.enabled || !result.isError || !result.failure)
34
+ if (policy?.handback || !this.opts.enabled || !result.isError || !result.failure)
35
35
  return result;
36
36
  // The host cannot know whether a missing exact session received this
37
37
  // instruction. Never replay it or silently switch providers.
38
- if (result.failure.category === "session-unavailable")
38
+ if (result.failure.category === "session-unavailable" || result.failure.dispatchState === "unknown")
39
39
  return result;
40
40
  const otherRuntime = this.runtime === "claude" ? "codex" : "claude";
41
41
  const choice = this.opts.choose
@@ -93,6 +93,14 @@ export class RecoveringAdapter {
93
93
  events() {
94
94
  return this.eventQueue;
95
95
  }
96
+ observeEvents(listener) {
97
+ if (!this.adapter.observeEvents)
98
+ throw new Error("wrapped provider does not expose turn observation");
99
+ // Observe synchronously at the provider, before sendTurn resolves. The async
100
+ // display pump may still have buffered events when the journal unsubscribes.
101
+ return this.adapter.observeEvents(listener);
102
+ }
103
+ acceptHandbackTurn(turn) { this.adapter.acceptHandbackTurn?.(turn); }
96
104
  async close() {
97
105
  if (this.closed)
98
106
  return;
@@ -70,6 +70,8 @@ export interface TurnResult {
70
70
  failure?: RuntimeFailure;
71
71
  /** Stable host/provider correlation ID for this turn. */
72
72
  turnId?: string;
73
+ /** Deferred handback continuity validation; no hidden provider repair. */
74
+ continuityErrors?: string[];
73
75
  /** Exact instruction requested by the immediate host caller. */
74
76
  hostInstruction?: string;
75
77
  /** Exact instruction dispatched to the provider after all wrappers. */
@@ -246,6 +248,8 @@ export interface BuilderAdapterOptions {
246
248
  allowAutoCompactionSetupTurn?: boolean;
247
249
  /** Raw-adapter observability; wrapper adapters must not persist re-emitted events. */
248
250
  observer?: RunObserver;
251
+ /** Redacted AskUserQuestion diagnostics, independent of build observability. */
252
+ onQuestionTrace?: import("../questionTrace.js").QuestionTraceSink;
249
253
  /**
250
254
  * Maximum time a dispatched provider turn may remain completely silent.
251
255
  * This is an idle timeout, not a total turn limit: every provider message
@@ -253,11 +257,33 @@ export interface BuilderAdapterOptions {
253
257
  * state, so callers must never replay the instruction automatically.
254
258
  */
255
259
  providerIdleTimeoutMs?: number;
260
+ compactionTimeoutMs?: number;
261
+ preparationTimeoutMs?: number;
262
+ rpcTimeoutMs?: number;
263
+ turnDeadlineMs?: number;
264
+ shutdownTimeoutMs?: number;
265
+ /** Metadata-only lifecycle trace; no prompts, responses, credentials, or tool inputs. */
266
+ onLifecycleTrace?: (event: {
267
+ phase: string;
268
+ at: string;
269
+ elapsedMs?: number;
270
+ sessionId?: string;
271
+ method?: string;
272
+ timeoutMs?: number;
273
+ }) => void;
256
274
  }
257
275
  export interface BuilderAdapter {
258
276
  readonly agent: "claude" | "codex";
259
277
  /** Send one instruction; resolves when that turn completes. */
260
- sendTurn(text: string): Promise<TurnResult>;
278
+ sendTurn(text: string, policy?: {
279
+ handback?: boolean;
280
+ responseOnly?: boolean;
281
+ logicalActionId?: string;
282
+ }): Promise<TurnResult>;
283
+ /** Synchronous observation through the event owner. Terminal precedes sendTurn resolution. */
284
+ observeEvents?(listener: (event: BuilderEvent) => void): () => void;
285
+ /** Commit a deferred handback checkpoint only after the host validates the turn. */
286
+ acceptHandbackTurn?(turn: TurnResult): void;
261
287
  /** Current session id, once known — used for resume. */
262
288
  sessionId(): string | undefined;
263
289
  /** Current location-scoped session reference, once known. */
package/dist/agentRun.js CHANGED
@@ -178,6 +178,8 @@ export async function createRoleBuilder(opts) {
178
178
  configRoot: opts.projectDir,
179
179
  runtimeExecutable,
180
180
  runtimePhase: phaseForRole(opts.role),
181
+ onQuestionTrace: (trace) => log.write("question-round-trip", { ...trace }),
182
+ onLifecycleTrace: (trace) => log.write("provider-lifecycle", { ...trace, role: opts.role }),
181
183
  model,
182
184
  ...(scopedRef ? { resumeSessionRef: scopedRef } : {}),
183
185
  sessionRole: configurableRole(opts.role),
@@ -20,7 +20,7 @@ export declare class CurrentWorkflowGuardAdapter implements BuilderAdapter {
20
20
  private readonly expected;
21
21
  readonly agent: BuilderAdapter["agent"];
22
22
  constructor(adapter: BuilderAdapter, cwd: string, expected?: CurrentWorkflowIdentity);
23
- sendTurn(text: string): Promise<TurnResult>;
23
+ sendTurn(text: string, policy?: Parameters<BuilderAdapter["sendTurn"]>[1]): Promise<TurnResult>;
24
24
  sessionId(): string | undefined;
25
25
  sessionRef(): ProviderSessionRefV1 | undefined;
26
26
  prepareSession(): Promise<ProviderSessionRefV1>;
@@ -37,5 +37,7 @@ export declare class CurrentWorkflowGuardAdapter implements BuilderAdapter {
37
37
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
38
38
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
39
39
  events(): AsyncIterable<BuilderEvent>;
40
+ observeEvents(listener: (event: BuilderEvent) => void): () => void;
41
+ acceptHandbackTurn(turn: TurnResult): void;
40
42
  close(): Promise<void>;
41
43
  }
@@ -37,9 +37,9 @@ export class CurrentWorkflowGuardAdapter {
37
37
  this.expected = expected;
38
38
  this.agent = adapter.agent;
39
39
  }
40
- async sendTurn(text) {
40
+ async sendTurn(text, policy) {
41
41
  assertCurrentWorkflowIdentity(this.cwd, this.expected);
42
- const result = await this.adapter.sendTurn(text);
42
+ const result = await this.adapter.sendTurn(text, policy);
43
43
  assertCurrentWorkflowIdentity(this.cwd, this.expected);
44
44
  return result;
45
45
  }
@@ -66,6 +66,15 @@ export class CurrentWorkflowGuardAdapter {
66
66
  sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
67
67
  switchSettings(settings) { return this.adapter.switchSettings?.(settings) ?? Promise.resolve({ ok: false, error: "settings switch unavailable" }); }
68
68
  events() { return this.adapter.events(); }
69
+ observeEvents(listener) {
70
+ if (!this.adapter.observeEvents)
71
+ throw new Error("wrapped provider does not expose turn observation");
72
+ return this.adapter.observeEvents(listener);
73
+ }
74
+ acceptHandbackTurn(turn) {
75
+ assertCurrentWorkflowIdentity(this.cwd, this.expected);
76
+ this.adapter.acceptHandbackTurn?.(turn);
77
+ }
69
78
  close() { return this.adapter.close(); }
70
79
  }
71
80
  function currentRef(cwd) {
@@ -7,7 +7,7 @@ export function findResumableBranchSessions(foremanDir) {
7
7
  const reader = new WorkflowReader(dirname(foremanDir));
8
8
  try {
9
9
  const structured = reader.branchResumeSessions().filter(session => existsSync(session.worktreePath));
10
- if (structured.length)
10
+ if (structured.length || reader.branchResumeSessions(false).length || reader.buildRuns().length)
11
11
  return structured.sort((a, b) => a.ticket.localeCompare(b.ticket, undefined, { numeric: true }));
12
12
  }
13
13
  finally {
@@ -8,6 +8,7 @@ import { SessionUnavailableError } from "../adapters/sessionFailure.js";
8
8
  import { SessionUnavailableContinuityError } from "../continuity.js";
9
9
  import type { QaRecoveryPacket } from "../qaRecovery.js";
10
10
  export interface DeliveryUnitSession {
11
+ runId?: string;
11
12
  unitId: string;
12
13
  branch: string;
13
14
  worktreePath: string;
@@ -18,6 +19,7 @@ export interface DeliveryUnitSession {
18
19
  export type BaseWorktreePolicy = "enforce" | "warn" | "skip";
19
20
  export declare function readDeliveryUnitSession(projectDir: string, unitId: string): DeliveryUnitSession | undefined;
20
21
  export interface BranchRunnerOptions {
22
+ continueIndependentTickets?: boolean;
21
23
  projectDir: string;
22
24
  runId: string;
23
25
  plan: BranchPlan;