@leanandmean/coding-agent 0.74.1-scramjet.53 → 0.74.1-scramjet.55

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.
@@ -18,6 +18,7 @@ import { basename, dirname, resolve } from "node:path";
18
18
  // acknowledgement key (#341).
19
19
  import { generateHarnessToolCallId } from "@leanandmean/agent";
20
20
  import { clampThinkingLevel, cleanupSessionResources, flattenSystemPrompt, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@leanandmean/ai";
21
+ import { validateResponsesProviderFailure } from "@leanandmean/ai/openai-responses";
21
22
  import { theme } from "../modes/interactive/theme/theme.js";
22
23
  import { stripFrontmatter } from "../utils/frontmatter.js";
23
24
  import { sleep } from "../utils/sleep.js";
@@ -59,6 +60,33 @@ export function parseSkillBlock(text) {
59
60
  // ============================================================================
60
61
  /** Standard thinking levels */
61
62
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
63
+ function createDeferred() {
64
+ let resolve;
65
+ let reject;
66
+ const promise = new Promise((res, rej) => {
67
+ resolve = res;
68
+ reject = rej;
69
+ });
70
+ promise.catch(() => { });
71
+ return { promise, resolve, reject, settled: false };
72
+ }
73
+ function resolveDeferred(deferred, value) {
74
+ if (deferred.settled)
75
+ return false;
76
+ deferred.settled = true;
77
+ deferred.resolve(value);
78
+ return true;
79
+ }
80
+ function rejectDeferred(deferred, error) {
81
+ if (deferred.settled)
82
+ return false;
83
+ deferred.settled = true;
84
+ deferred.reject(error);
85
+ return true;
86
+ }
87
+ function settleDeferred(deferred, error) {
88
+ return error ? rejectDeferred(deferred, error) : resolveDeferred(deferred, undefined);
89
+ }
62
90
  export class AgentSession {
63
91
  agent;
64
92
  sessionManager;
@@ -92,11 +120,12 @@ export class AgentSession {
92
120
  // Branch summarization state
93
121
  _branchSummaryAbortController = undefined;
94
122
  // Retry state
95
- _retryAbortController = undefined;
96
- _retryAttempt = 0;
123
+ _retry = undefined;
97
124
  _runRetryCount = 0;
98
- _retryPromise = undefined;
99
- _retryResolve = undefined;
125
+ _activeAgentRunSettlement = undefined;
126
+ _pendingAgentRunStart = undefined;
127
+ _unsettledAgentRuns = new Set();
128
+ _unsettledRetryChains = new Set();
100
129
  // Bash execution state
101
130
  _bashAbortController = undefined;
102
131
  _pendingBashMessages = [];
@@ -268,20 +297,17 @@ export class AgentSession {
268
297
  followUp: [...this._followUpMessages],
269
298
  });
270
299
  }
271
- // Track last assistant message for auto-compaction check
272
- _lastAssistantMessage = undefined;
273
300
  /** Internal handler for agent events - shared by subscribe and reconnect */
274
301
  _handleAgentEvent = (event) => {
275
302
  this._captureOutputThroughput(event);
276
- // Create retry promise synchronously before queueing async processing.
277
- // Agent.emit() calls this handler synchronously, and prompt() calls waitForRetry()
278
- // as soon as agent.prompt() resolves. If _retryPromise is created only inside
279
- // _processAgentEvent, slow earlier queued events can delay agent_end processing
280
- // and waitForRetry() can miss the in-flight retry.
281
- this._createRetryPromiseForAgentEnd(event);
282
- this._agentEventQueue = this._agentEventQueue.then(() => this._processAgentEventTracked(event), () => this._processAgentEventTracked(event));
303
+ const runSettlement = this._captureAgentRunSettlement(event);
304
+ const harnessAck = this._harnessPersistenceAckForEvent(event);
305
+ const processing = this._agentEventQueue.then(() => this._processAgentEventTracked(event, runSettlement, harnessAck), () => this._processAgentEventTracked(event, runSettlement, harnessAck));
306
+ this._agentEventQueue = processing;
307
+ if (harnessAck)
308
+ harnessAck.persistenceTail = processing;
283
309
  // Keep queue alive if an event handler fails
284
- this._agentEventQueue.catch(() => { });
310
+ processing.catch(() => { });
285
311
  // SCRAMJET-DIVERGENCE: turn_end tool/prompt changes must settle before Agent snapshots
286
312
  // live state for the next provider request (#524).
287
313
  if (event.type === "turn_end")
@@ -315,94 +341,103 @@ export class AgentSession {
315
341
  this._outputThroughputTracker.reset();
316
342
  this._outputThroughputGeneration = undefined;
317
343
  }
318
- _createRetryPromiseForAgentEnd(event) {
319
- if (event.type !== "agent_end" || this._retryPromise) {
320
- return;
321
- }
322
- const settings = this.settingsManager.getRetrySettings();
323
- if (!settings.enabled) {
324
- return;
325
- }
326
- const lastAssistant = this._findLastAssistantInMessages(event.messages);
327
- if (!lastAssistant || !this._isRetryableError(lastAssistant)) {
328
- return;
329
- }
330
- this._retryPromise = new Promise((resolve) => {
331
- this._retryResolve = resolve;
332
- });
333
- }
334
- _findLastAssistantInMessages(messages) {
335
- for (let i = messages.length - 1; i >= 0; i--) {
336
- const message = messages[i];
337
- if (message.role === "assistant") {
338
- return message;
339
- }
340
- }
341
- return undefined;
342
- }
343
- // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). Wraps queue-side event processing
344
- // so a harness-tool invocation's persistence acknowledgement resolves only after the matching
345
- // tool-result message_end has been fully processed and persisted, and rejects if processing any of
346
- // that invocation's events throws. Rethrows to preserve the queue's existing keep-processing
347
- // behavior (the caller attaches `.catch(() => {})`).
348
- async _processAgentEventTracked(event) {
349
- const ackId = this._harnessAckIdForEvent(event);
350
- const ack = ackId !== undefined ? this._harnessPersistenceAcks.get(ackId) : undefined;
351
- // Decide from the ORIGINAL event, before _processAgentEvent runs, whether this is the
352
- // ack-resolving tool-result: a message_end hook can replace event.message in place
353
- // (_replaceMessageInPlace), so re-reading role/toolCallId after the await could miss the match
354
- // and strand the acknowledgement.
355
- const resolvesAck = ack !== undefined &&
356
- event.type === "message_end" &&
357
- event.message.role === "toolResult" &&
358
- event.message.toolCallId === ackId;
344
+ // SCRAMJET-DIVERGENCE: every Agent run owns persistence state and a one-shot settlement token (#553).
345
+ _captureAgentRunSettlement(event) {
346
+ if (event.type === "agent_start") {
347
+ // A retry continuation's run adopts the chain its originating prompt awaits; the chain stays
348
+ // bound to the retry phase until this start consumes it, however late Agent emits it.
349
+ const chain = this._retry?.phase === "continuing" ? this._retry.chain : createDeferred();
350
+ this._unsettledRetryChains.add(chain);
351
+ const run = { ...createDeferred(), chain };
352
+ this._activeAgentRunSettlement = run;
353
+ this._unsettledAgentRuns.add(run);
354
+ const reservation = this._pendingAgentRunStart;
355
+ if (reservation) {
356
+ this._pendingAgentRunStart = undefined;
357
+ reservation.run = run;
358
+ resolveDeferred(reservation, run);
359
+ }
360
+ return run;
361
+ }
362
+ const run = this._activeAgentRunSettlement;
363
+ if (event.type === "agent_end" && this._activeAgentRunSettlement === run) {
364
+ this._activeAgentRunSettlement = undefined;
365
+ }
366
+ return run;
367
+ }
368
+ _settleAgentRun(run, error) {
369
+ if (settleDeferred(run, error))
370
+ this._unsettledAgentRuns.delete(run);
371
+ }
372
+ _settleRetryChain(chain, error) {
373
+ if (settleDeferred(chain, error))
374
+ this._unsettledRetryChains.delete(chain);
375
+ }
376
+ _completeAgentRun(run, error) {
377
+ this._settleAgentRun(run, error);
378
+ this._settleRetryChain(run.chain, error);
379
+ }
380
+ _reserveAgentRunStart() {
381
+ if (this._pendingAgentRunStart)
382
+ throw new Error("An Agent prompt is already waiting to start.");
383
+ const reservation = createDeferred();
384
+ this._pendingAgentRunStart = reservation;
385
+ return reservation;
386
+ }
387
+ _rejectAgentRunStart(reservation, error) {
388
+ if (this._pendingAgentRunStart === reservation)
389
+ this._pendingAgentRunStart = undefined;
390
+ rejectDeferred(reservation, error);
391
+ }
392
+ // SCRAMJET-DIVERGENCE: harness-tool persisted settlement (#341). Synchronous ownership keeps queued
393
+ // invocation events attributed after early failures; public settlement awaits execution and the final persistence tail.
394
+ async _processAgentEventTracked(event, run, harnessAck) {
359
395
  try {
360
- await this._processAgentEvent(event);
396
+ await this._processAgentEvent(event, run, harnessAck !== undefined);
361
397
  }
362
398
  catch (err) {
363
- if (ack) {
364
- ack.reject(err instanceof Error ? err : new Error(String(err)));
399
+ const error = err instanceof Error ? err : new Error(String(err));
400
+ if (harnessAck) {
401
+ harnessAck.firstPersistenceError ??= error;
402
+ }
403
+ else if (run) {
404
+ if (event.type === "message_end" && event.message.role === "assistant") {
405
+ run.assistantPersistenceError = error;
406
+ }
407
+ this._releaseRetryForFailedRun(error);
408
+ this._completeAgentRun(run, error);
365
409
  }
366
410
  throw err;
367
411
  }
368
- // Resolving here counts as "persisted" only because SessionManager persistence is synchronous
369
- // (appendFileSync): _processAgentEvent has already written the tool-result row by the time it
370
- // returns. If _persist ever becomes async, this would resolve before persistence completes.
371
- if (resolvesAck) {
372
- ack?.resolve();
373
- }
374
412
  }
375
- // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). The tool-call id an event belongs
376
- // to for acknowledgement tracking — the synthetic assistant tool-call, the `tool_execution_*`
377
- // events, or the tool-result message. Returns undefined for events unrelated to a pending harness
378
- // invocation; a returned id that has no pending acknowledgement (an ordinary model tool call)
379
- // resolves to no-op at the call site.
380
- _harnessAckIdForEvent(event) {
413
+ _harnessPersistenceAckForEvent(event) {
414
+ let toolCallId;
381
415
  switch (event.type) {
382
416
  case "tool_execution_start":
383
417
  case "tool_execution_update":
384
418
  case "tool_execution_end":
385
- return event.toolCallId;
419
+ toolCallId = event.toolCallId;
420
+ break;
386
421
  case "message_start":
387
422
  case "message_end": {
388
423
  const message = event.message;
389
424
  if (message.role === "toolResult") {
390
- return message.toolCallId;
425
+ toolCallId = message.toolCallId;
391
426
  }
392
- if (message.role === "assistant") {
427
+ else if (message.role === "assistant") {
393
428
  for (const block of message.content) {
394
429
  if (block.type === "toolCall" && this._harnessPersistenceAcks.has(block.id)) {
395
- return block.id;
430
+ toolCallId = block.id;
431
+ break;
396
432
  }
397
433
  }
398
434
  }
399
- return undefined;
435
+ break;
400
436
  }
401
- default:
402
- return undefined;
403
437
  }
438
+ return toolCallId === undefined ? undefined : this._harnessPersistenceAcks.get(toolCallId);
404
439
  }
405
- async _processAgentEvent(event) {
440
+ async _processAgentEvent(event, run, fromHarnessInvocation = false) {
406
441
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
407
442
  // This ensures the UI sees the updated queue state
408
443
  if (event.type === "message_start" && event.message.role === "user") {
@@ -429,6 +464,8 @@ export class AgentSession {
429
464
  await this._emitExtensionEvent(event);
430
465
  // Notify all listeners
431
466
  this._emit(event);
467
+ if (this._disposed)
468
+ return;
432
469
  // Handle session persistence
433
470
  if (event.type === "message_end") {
434
471
  // Check if this is a custom message from extensions
@@ -439,8 +476,15 @@ export class AgentSession {
439
476
  else if (event.message.role === "user" ||
440
477
  event.message.role === "assistant" ||
441
478
  event.message.role === "toolResult") {
442
- // Regular LLM message - persist as SessionMessageEntry
443
- const messageEntryId = this.sessionManager.appendMessage(event.message);
479
+ // SCRAMJET-DIVERGENCE: retry settlement classifies the exact JSON-safe assistant snapshot
480
+ // that crossed the persistence boundary, never a later-mutated live Agent message (#553).
481
+ const persistedMessage = event.message.role === "assistant"
482
+ ? JSON.parse(JSON.stringify(event.message))
483
+ : event.message;
484
+ const messageEntryId = this.sessionManager.appendMessage(persistedMessage);
485
+ if (persistedMessage.role === "assistant" && !fromHarnessInvocation && run) {
486
+ run.persistedAssistantSnapshot = persistedMessage;
487
+ }
444
488
  if (event.message.role === "user") {
445
489
  const sessionEntries = this._inputSessionEntries.get(event.message);
446
490
  this._inputSessionEntries.delete(event.message);
@@ -467,47 +511,88 @@ export class AgentSession {
467
511
  }
468
512
  }
469
513
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
470
- // Track assistant message for auto-compaction (checked on agent_end)
471
- if (event.message.role === "assistant") {
472
- this._lastAssistantMessage = event.message;
473
- const assistantMsg = event.message;
474
- if (assistantMsg.stopReason !== "error") {
475
- this._overflowRecoveryAttempted = false;
476
- }
477
- // Reset retry counter immediately on successful assistant response
478
- // This prevents accumulation across multiple LLM calls within a turn
479
- if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) {
480
- this._emit({
481
- type: "auto_retry_end",
482
- success: true,
483
- attempt: this._retryAttempt,
484
- });
485
- this._retryAttempt = 0;
486
- }
514
+ if (event.message.role === "assistant" &&
515
+ event.message.stopReason !== "error" &&
516
+ event.message.stopReason !== "aborted" &&
517
+ !fromHarnessInvocation) {
518
+ this._overflowRecoveryAttempted = false;
519
+ if (this._retry)
520
+ this._retry = { ...this._retry, attempt: 0 };
487
521
  }
488
522
  }
489
- // Check auto-retry and auto-compaction after agent completes
490
- if (event.type === "agent_end" && this._lastAssistantMessage) {
491
- const msg = this._lastAssistantMessage;
492
- this._lastAssistantMessage = undefined;
493
- // Check for retryable errors first (overloaded, rate limit, server errors)
494
- if (this._isRetryableError(msg)) {
495
- const didRetry = await this._handleRetryableError(msg);
523
+ if (event.type === "agent_end") {
524
+ if (this._disposed || !run)
525
+ return;
526
+ if (run.assistantPersistenceError) {
527
+ const error = run.assistantPersistenceError;
528
+ run.assistantPersistenceError = undefined;
529
+ run.persistedAssistantSnapshot = undefined;
530
+ this._completeAgentRun(run, error);
531
+ return;
532
+ }
533
+ const msg = run.persistedAssistantSnapshot;
534
+ run.persistedAssistantSnapshot = undefined;
535
+ if (!msg) {
536
+ this._releaseRetryForFailedRun(new Error("Retry continuation ended without an assistant message."));
537
+ this._completeAgentRun(run);
538
+ return;
539
+ }
540
+ if (msg.stopReason === "aborted" && this._retry) {
541
+ this._finishCancelledRetry("cancelled_during_continuation");
542
+ this._completeAgentRun(run);
543
+ return;
544
+ }
545
+ if (msg.stopReason !== "error") {
546
+ if (this._retry) {
547
+ const attempt = this._runRetryCount;
548
+ this._finishRetry({
549
+ schemaVersion: 1,
550
+ outcome: "succeeded",
551
+ attemptsCompleted: this._bounded(attempt),
552
+ cumulativeErrors: this._bounded(attempt),
553
+ }, { type: "auto_retry_end", success: true, attempt });
554
+ }
555
+ this._completeAgentRun(run);
556
+ await this._checkCompaction(msg);
557
+ return;
558
+ }
559
+ const classification = this._classifyRetry(msg);
560
+ if (classification.kind === "context_overflow") {
561
+ this._finishRetryWithoutSuccess({
562
+ schemaVersion: 1,
563
+ outcome: "not_attempted",
564
+ reason: "context_overflow_compaction",
565
+ evidence: "context_overflow",
566
+ }, msg);
567
+ this._completeAgentRun(run);
568
+ await this._checkCompaction(msg);
569
+ return;
570
+ }
571
+ const settings = this.settingsManager.getRetrySettings();
572
+ if (!settings.enabled) {
573
+ this._finishRetryWithoutSuccess({
574
+ schemaVersion: 1,
575
+ outcome: "not_attempted",
576
+ reason: "retry_disabled",
577
+ evidence: classification.evidence,
578
+ }, msg);
579
+ this._completeAgentRun(run);
580
+ await this._checkCompaction(msg);
581
+ return;
582
+ }
583
+ if (classification.kind === "retry") {
584
+ const didRetry = await this._handleRetryableError(msg, classification.evidence, run);
496
585
  if (didRetry)
497
- return; // Retry was initiated, don't proceed to compaction
586
+ return;
498
587
  }
499
- this._resolveRetry();
588
+ else {
589
+ const { kind: _kind, ...cause } = classification;
590
+ this._finishRetryWithoutSuccess({ schemaVersion: 1, outcome: "not_attempted", ...cause }, msg);
591
+ }
592
+ this._completeAgentRun(run);
500
593
  await this._checkCompaction(msg);
501
594
  }
502
595
  }
503
- /** Resolve the pending retry promise */
504
- _resolveRetry() {
505
- if (this._retryResolve) {
506
- this._retryResolve();
507
- this._retryResolve = undefined;
508
- this._retryPromise = undefined;
509
- }
510
- }
511
596
  /** Extract text content from a message */
512
597
  _getUserMessageText(message) {
513
598
  if (message.role !== "user")
@@ -665,12 +750,49 @@ export class AgentSession {
665
750
  * Call this when completely done with the session.
666
751
  */
667
752
  dispose() {
668
- // SCRAMJET-DIVERGENCE: idempotent disposal that revokes in-flight harness-tool invocations (#341).
753
+ // SCRAMJET-DIVERGENCE: idempotent disposal revokes harness invocations and closes retry settlement (#341, #553).
669
754
  // A second call must not re-reject already-settled acknowledgements or re-run teardown.
670
755
  if (this._disposed)
671
756
  return;
672
757
  this._disposed = true;
673
758
  this._resetOutputThroughput();
759
+ const retryDisposeError = new Error("AgentSession disposed before retry settlement completed.");
760
+ const unsettledRuns = [...this._unsettledAgentRuns];
761
+ const retry = this._retry;
762
+ const unclassifiedError = unsettledRuns.find((run) => run.persistedAssistantSnapshot?.stopReason === "error")?.persistedAssistantSnapshot;
763
+ if (retry)
764
+ this.agent.abort();
765
+ try {
766
+ if (retry) {
767
+ this._appendAutoRetryRecord({
768
+ schemaVersion: 1,
769
+ outcome: "cancelled",
770
+ reason: "session_disposed",
771
+ attempt: this._bounded(retry.attempt),
772
+ cumulativeErrors: this._bounded(this._runRetryCount),
773
+ });
774
+ }
775
+ else if (unclassifiedError) {
776
+ const classification = this._classifyRetry(unclassifiedError);
777
+ this._appendAutoRetryRecord({
778
+ schemaVersion: 1,
779
+ outcome: "not_attempted",
780
+ reason: "session_disposed",
781
+ evidence: classification.kind === "context_overflow" ? "context_overflow" : classification.evidence,
782
+ });
783
+ }
784
+ }
785
+ catch (error) {
786
+ this._reportRetryFailure(error);
787
+ }
788
+ if (retry?.phase === "backoff")
789
+ retry.controller.abort();
790
+ this._retry = undefined;
791
+ this._activeAgentRunSettlement = undefined;
792
+ const pendingAgentRunStart = this._pendingAgentRunStart;
793
+ if (pendingAgentRunStart)
794
+ this._rejectAgentRunStart(pendingAgentRunStart, retryDisposeError);
795
+ const pendingRetryContinuation = retry?.phase === "continuing" ? retry.chain : undefined;
674
796
  this._extensionRunner.invalidate("This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().");
675
797
  // Reject pending persistence acknowledgements before revoking Agent-side invocations, so a caller
676
798
  // awaiting invokeHarnessTool sees rejection rather than a promise that never settles. Already-
@@ -678,10 +800,17 @@ export class AgentSession {
678
800
  // roll back arbitrary side effects.
679
801
  const disposeError = new Error("AgentSession disposed before the harness tool invocation settled.");
680
802
  for (const ack of this._harnessPersistenceAcks.values()) {
681
- ack.reject(disposeError);
803
+ this._settleHarnessPersistenceAck(ack, disposeError);
682
804
  }
683
805
  this._harnessPersistenceAcks.clear();
684
806
  this.agent.rejectUnsettledHarnessTools(disposeError);
807
+ const retryChains = new Set(this._unsettledRetryChains);
808
+ if (pendingRetryContinuation)
809
+ retryChains.add(pendingRetryContinuation);
810
+ for (const run of unsettledRuns)
811
+ this._settleAgentRun(run, retryDisposeError);
812
+ for (const chain of retryChains)
813
+ this._settleRetryChain(chain, retryDisposeError);
685
814
  this._disconnectFromAgent();
686
815
  this._eventListeners = [];
687
816
  cleanupSessionResources(this.sessionId);
@@ -753,7 +882,7 @@ export class AgentSession {
753
882
  }
754
883
  /** Current retry attempt (0 if not retrying) */
755
884
  get retryAttempt() {
756
- return this._retryAttempt;
885
+ return this._retry?.attempt ?? 0;
757
886
  }
758
887
  /**
759
888
  * Get the names of currently active tools.
@@ -842,13 +971,12 @@ export class AgentSession {
842
971
  throw new Error(`Cannot invoke harness tool "${name}": no tool with that name is registered.`);
843
972
  }
844
973
  const toolCallId = this._allocateHarnessToolCallId(options?.toolCallId);
845
- // Register the acknowledgement before Agent execution can emit events, so the queue-side handler
846
- // always finds it. Await Agent execution and persistence concurrently: Agent-core failure or a
847
- // persistence failure rejects, and the tool-call-id-keyed handler resolves after the matching
848
- // tool-result is persisted.
974
+ // Register before Agent execution can emit events. The coordinator retains attribution until Agent
975
+ // execution and the invocation's final queued persistence task both settle.
849
976
  const ack = this._registerHarnessPersistenceAck(toolCallId);
977
+ void this._completeHarnessToolInvocation(this.agent.runHarnessTool(tool, args, { toolCallId }), ack);
850
978
  try {
851
- await Promise.all([this.agent.runHarnessTool(tool, args, { toolCallId }), ack.promise]);
979
+ await ack.promise;
852
980
  }
853
981
  finally {
854
982
  // Identity-checked cleanup: only drop the entry if it is still this invocation's ack (disposal
@@ -881,17 +1009,27 @@ export class AgentSession {
881
1009
  }
882
1010
  return id;
883
1011
  }
1012
+ async _completeHarnessToolInvocation(execution, ack) {
1013
+ let executionError;
1014
+ try {
1015
+ await execution;
1016
+ }
1017
+ catch (error) {
1018
+ executionError = error instanceof Error ? error : new Error(String(error));
1019
+ }
1020
+ try {
1021
+ await ack.persistenceTail;
1022
+ }
1023
+ catch { }
1024
+ this._settleHarnessPersistenceAck(ack, ack.firstPersistenceError ?? executionError);
1025
+ }
1026
+ _settleHarnessPersistenceAck(ack, error) {
1027
+ settleDeferred(ack, error);
1028
+ }
884
1029
  // SCRAMJET-DIVERGENCE: harness-tool persisted-settlement (#341). Register a deferred acknowledgement
885
- // keyed by tool-call id. The caller awaits `ack.promise` inside the same synchronous frame that
886
- // registers it, so a synchronous disposal rejection can never strand it as an unhandled rejection.
1030
+ // keyed by tool-call id before any invocation event can be emitted.
887
1031
  _registerHarnessPersistenceAck(toolCallId) {
888
- let resolve;
889
- let reject;
890
- const promise = new Promise((res, rej) => {
891
- resolve = res;
892
- reject = rej;
893
- });
894
- const ack = { promise, resolve, reject };
1032
+ const ack = { ...createDeferred(), persistenceTail: Promise.resolve() };
895
1033
  this._harnessPersistenceAcks.set(toolCallId, ack);
896
1034
  return ack;
897
1035
  }
@@ -1036,6 +1174,7 @@ export class AgentSession {
1036
1174
  return;
1037
1175
  }
1038
1176
  }
1177
+ this._assertRetryNotOwningIdleAgent();
1039
1178
  // Emit input event for extension interception (before skill/template expansion)
1040
1179
  let currentText = text;
1041
1180
  let currentImages = options?.images;
@@ -1157,9 +1296,7 @@ export class AgentSession {
1157
1296
  }
1158
1297
  acceptInput?.();
1159
1298
  preflightResult?.(true);
1160
- this._runRetryCount = 0;
1161
- await this.agent.prompt(messages);
1162
- await this.waitForRetry();
1299
+ await this._runAgentPrompt(messages);
1163
1300
  }
1164
1301
  /**
1165
1302
  * Try to execute an extension command. Returns true if command was found and executed.
@@ -1337,7 +1474,7 @@ export class AgentSession {
1337
1474
  }
1338
1475
  }
1339
1476
  else if (options?.triggerTurn) {
1340
- await this.agent.prompt(appMessage);
1477
+ await this._runAgentPrompt(appMessage);
1341
1478
  }
1342
1479
  else {
1343
1480
  this.agent.state.messages.push(appMessage);
@@ -2312,128 +2449,311 @@ export class AgentSession {
2312
2449
  // =========================================================================
2313
2450
  // Auto-Retry
2314
2451
  // =========================================================================
2315
- /**
2316
- * Check if an error is retryable (overloaded, rate limit, server errors).
2317
- * Context overflow errors are NOT retryable (handled by compaction instead).
2318
- */
2319
- _isRetryableError(message) {
2320
- if (message.stopReason !== "error" || !message.errorMessage)
2321
- return false;
2322
- // Context overflow is handled by compaction, not retry
2452
+ // SCRAMJET-DIVERGENCE: persisted post-extension snapshots authoritatively classify and settle retries (#553).
2453
+ _classifyRetry(message) {
2323
2454
  const contextWindow = this.model?.contextWindow ?? 0;
2324
- if (isContextOverflow(message, Math.min(contextWindow, this.model?.maxInputTokens ?? Infinity)))
2325
- return false;
2326
- const err = message.errorMessage;
2327
- // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded
2328
- return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(err);
2455
+ if (isContextOverflow(message, Math.min(contextWindow, this.model?.maxInputTokens ?? Infinity))) {
2456
+ return { kind: "context_overflow" };
2457
+ }
2458
+ const structured = validateResponsesProviderFailure(message.diagnostics);
2459
+ if (structured.status === "valid") {
2460
+ if (structured.retryDisposition === "transient")
2461
+ return { kind: "retry", evidence: "provider_failure" };
2462
+ return {
2463
+ kind: "do_not_retry",
2464
+ reason: structured.retryDisposition === "non_transient" ? "structured_non_transient" : "structured_unknown",
2465
+ evidence: "provider_failure",
2466
+ };
2467
+ }
2468
+ if (structured.status === "malformed") {
2469
+ return { kind: "do_not_retry", reason: "malformed_provider_diagnostic", evidence: "none" };
2470
+ }
2471
+ if (structured.status === "duplicate") {
2472
+ return { kind: "do_not_retry", reason: "duplicate_provider_diagnostic", evidence: "none" };
2473
+ }
2474
+ if (!message.errorMessage) {
2475
+ return { kind: "do_not_retry", reason: "missing_error_evidence", evidence: "none" };
2476
+ }
2477
+ if (this._isLegacyRetryableError(message.errorMessage))
2478
+ return { kind: "retry", evidence: "legacy_text" };
2479
+ return { kind: "do_not_retry", reason: "legacy_non_retryable", evidence: "legacy_text" };
2480
+ }
2481
+ _isLegacyRetryableError(errorMessage) {
2482
+ return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(errorMessage);
2483
+ }
2484
+ _bounded(value) {
2485
+ return Math.min(65_535, Math.max(0, Number.isFinite(value) ? Math.trunc(value) : 65_535));
2486
+ }
2487
+ _appendAutoRetryRecord(record) {
2488
+ this.sessionManager.appendCustomEntry("coding-agent:auto-retry", record);
2329
2489
  }
2330
2490
  /**
2331
- * Handle retryable errors with exponential backoff.
2332
- * @returns true if retry was initiated, false if max retries exceeded or disabled
2491
+ * Persist a terminal record, emit `auto_retry_end` when a retry phase was active, and clear the phase.
2492
+ * The phase is released before persistence is attempted so a persistence failure can never strand it.
2333
2493
  */
2334
- async _handleRetryableError(message) {
2335
- const settings = this.settingsManager.getRetrySettings();
2336
- if (!settings.enabled) {
2337
- this._resolveRetry();
2338
- return false;
2494
+ _finishRetry(record, end) {
2495
+ const retry = this._retry;
2496
+ this._retry = undefined;
2497
+ try {
2498
+ this._appendAutoRetryRecord(record);
2339
2499
  }
2340
- // Retry promise is created synchronously in _handleAgentEvent for agent_end.
2341
- // Keep a defensive fallback here in case a future refactor bypasses that path.
2342
- if (!this._retryPromise) {
2343
- this._retryPromise = new Promise((resolve) => {
2344
- this._retryResolve = resolve;
2345
- });
2500
+ catch (error) {
2501
+ if (retry) {
2502
+ this._emit({
2503
+ type: "auto_retry_end",
2504
+ success: false,
2505
+ attempt: end?.attempt ?? retry.attempt,
2506
+ finalError: "Retry outcome persistence failed",
2507
+ });
2508
+ }
2509
+ throw this._reportRetryFailure(error);
2510
+ }
2511
+ if (retry && end)
2512
+ this._emit(end);
2513
+ }
2514
+ _finishRetryWithoutSuccess(record, message) {
2515
+ const retry = this._retry;
2516
+ if (retry?.attempt === 0) {
2517
+ // The continuation already produced a successful message, so the retry recovered and this
2518
+ // later failure is a fresh, non-retried error; close the retry as succeeded first.
2519
+ const attempt = this._runRetryCount;
2520
+ this._finishRetry({
2521
+ schemaVersion: 1,
2522
+ outcome: "succeeded",
2523
+ attemptsCompleted: this._bounded(attempt),
2524
+ cumulativeErrors: this._bounded(attempt),
2525
+ }, { type: "auto_retry_end", success: true, attempt });
2526
+ this._finishRetry(record);
2527
+ return;
2346
2528
  }
2347
- this._retryAttempt++;
2529
+ this._finishRetry(record, retry
2530
+ ? { type: "auto_retry_end", success: false, attempt: retry.attempt, finalError: message.errorMessage }
2531
+ : undefined);
2532
+ }
2533
+ /** A run that ends without a classifiable snapshot cannot continue the retry; close it so nothing stays wedged. */
2534
+ _releaseRetryForFailedRun(error) {
2535
+ const retry = this._retry;
2536
+ if (!retry)
2537
+ return;
2538
+ try {
2539
+ this._finishRetry({
2540
+ schemaVersion: 1,
2541
+ outcome: "failed",
2542
+ reason: "run_failed",
2543
+ attempt: this._bounded(retry.attempt),
2544
+ cumulativeErrors: this._bounded(this._runRetryCount),
2545
+ }, { type: "auto_retry_end", success: false, attempt: retry.attempt, finalError: error.message });
2546
+ }
2547
+ catch {
2548
+ // Already reported and the phase is released; the run settles with its own error.
2549
+ }
2550
+ }
2551
+ _reportRetryFailure(error) {
2552
+ const normalized = error instanceof Error ? error : new Error(String(error));
2553
+ this._extensionRunner.emitError({
2554
+ extensionPath: "agent-session:auto-retry",
2555
+ event: "retry_settlement",
2556
+ error: normalized.message,
2557
+ stack: normalized.stack,
2558
+ });
2559
+ return normalized;
2560
+ }
2561
+ async _handleRetryableError(message, evidence, run) {
2562
+ const settings = this.settingsManager.getRetrySettings();
2563
+ const attempt = (this._retry?.attempt ?? 0) + 1;
2348
2564
  this._runRetryCount++;
2349
2565
  const cumulativeCap = settings.maxRetries * 2;
2566
+ if (attempt > settings.maxRetries) {
2567
+ const attemptsCompleted = attempt - 1;
2568
+ this._finishRetry({
2569
+ schemaVersion: 1,
2570
+ outcome: "exhausted",
2571
+ reason: "attempt_limit",
2572
+ attemptsCompleted: this._bounded(attemptsCompleted),
2573
+ maxAttempts: this._bounded(settings.maxRetries),
2574
+ cumulativeErrors: this._bounded(this._runRetryCount),
2575
+ }, { type: "auto_retry_end", success: false, attempt: attemptsCompleted, finalError: message.errorMessage });
2576
+ return false;
2577
+ }
2350
2578
  if (this._runRetryCount > cumulativeCap) {
2351
- this._emit({
2579
+ const attemptsCompleted = this._runRetryCount - 1;
2580
+ this._finishRetry({
2581
+ schemaVersion: 1,
2582
+ outcome: "exhausted",
2583
+ reason: "cumulative_limit",
2584
+ attemptsCompleted: this._bounded(attemptsCompleted),
2585
+ maxAttempts: this._bounded(settings.maxRetries),
2586
+ cumulativeErrors: this._bounded(this._runRetryCount),
2587
+ }, {
2352
2588
  type: "auto_retry_end",
2353
2589
  success: false,
2354
- attempt: this._runRetryCount - 1,
2355
- finalError: `Repeated retry failures (${this._runRetryCount - 1} total attempts this prompt). ${message.errorMessage ?? ""}`.trim(),
2590
+ attempt: attemptsCompleted,
2591
+ finalError: `Repeated retry failures (${attemptsCompleted} total attempts this prompt). ${message.errorMessage ?? ""}`.trim(),
2356
2592
  });
2357
- this._retryAttempt = 0;
2358
- this._resolveRetry();
2359
2593
  return false;
2360
2594
  }
2361
- if (this._retryAttempt > settings.maxRetries) {
2362
- // Max retries exceeded, emit final failure and reset
2363
- this._emit({
2364
- type: "auto_retry_end",
2365
- success: false,
2366
- attempt: this._retryAttempt - 1,
2367
- finalError: message.errorMessage,
2595
+ const delayMs = settings.baseDelayMs * 2 ** (attempt - 1);
2596
+ const previous = this._retry;
2597
+ this._retry = undefined;
2598
+ try {
2599
+ this._appendAutoRetryRecord({
2600
+ schemaVersion: 1,
2601
+ outcome: "scheduled",
2602
+ evidence,
2603
+ attempt: this._bounded(attempt),
2604
+ maxAttempts: this._bounded(settings.maxRetries),
2605
+ cumulativeErrors: this._bounded(this._runRetryCount),
2606
+ delayMs: this._bounded(delayMs),
2368
2607
  });
2369
- this._retryAttempt = 0;
2370
- this._resolveRetry(); // Resolve so waitForRetry() completes
2371
- return false;
2372
2608
  }
2373
- const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
2609
+ catch (error) {
2610
+ if (previous) {
2611
+ this._emit({
2612
+ type: "auto_retry_end",
2613
+ success: false,
2614
+ attempt: previous.attempt,
2615
+ finalError: "Retry outcome persistence failed",
2616
+ });
2617
+ }
2618
+ throw this._reportRetryFailure(error);
2619
+ }
2620
+ const controller = new AbortController();
2621
+ const backoff = { phase: "backoff", attempt, controller, run };
2622
+ this._retry = backoff;
2374
2623
  this._emit({
2375
2624
  type: "auto_retry_start",
2376
- attempt: this._retryAttempt,
2625
+ attempt,
2377
2626
  maxAttempts: settings.maxRetries,
2378
2627
  delayMs,
2379
2628
  errorMessage: message.errorMessage || "Unknown error",
2380
2629
  cumulativeErrors: this._runRetryCount,
2381
2630
  });
2382
- // Remove error message from agent state (keep in session for history)
2631
+ if (controller.signal.aborted || this._disposed) {
2632
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2633
+ }
2383
2634
  const messages = this.agent.state.messages;
2384
2635
  if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2385
2636
  this.agent.state.messages = messages.slice(0, -1);
2386
2637
  }
2387
- // Wait with exponential backoff (abortable)
2388
- this._retryAbortController = new AbortController();
2389
2638
  try {
2390
- await sleep(delayMs, this._retryAbortController.signal);
2639
+ await sleep(delayMs, controller.signal);
2640
+ await sleep(0, controller.signal);
2391
2641
  }
2392
2642
  catch {
2393
- // Aborted during sleep - emit end event so UI can clean up
2394
- const attempt = this._retryAttempt;
2395
- this._retryAttempt = 0;
2396
- this._retryAbortController = undefined;
2397
- this._emit({
2398
- type: "auto_retry_end",
2399
- success: false,
2400
- attempt,
2401
- finalError: "Retry cancelled",
2402
- });
2403
- this._resolveRetry();
2404
- return false;
2405
- }
2406
- this._retryAbortController = undefined;
2407
- // Retry via continue() - use setTimeout to break out of event handler chain
2408
- setTimeout(() => {
2409
- this.agent.continue().catch(() => {
2410
- // Retry failed - will be caught by next agent_end
2411
- });
2412
- }, 0);
2643
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2644
+ }
2645
+ if (controller.signal.aborted || this._disposed) {
2646
+ return this._finishCancelledRetry("cancelled_during_backoff", backoff);
2647
+ }
2648
+ // Hand the chain to the continuation before `continue()`; the run that eventually emits
2649
+ // `agent_start` adopts it (see `_captureAgentRunSettlement`), however long Agent defers that start.
2650
+ const continuing = { phase: "continuing", attempt, chain: run.chain };
2651
+ this._retry = continuing;
2652
+ const continuation = this.agent.continue();
2653
+ this._settleAgentRun(run);
2654
+ continuation.catch((error) => {
2655
+ if (this._disposed || this._retry !== continuing)
2656
+ return;
2657
+ const continuationError = this._reportRetryFailure(error);
2658
+ let settlementError = continuationError;
2659
+ try {
2660
+ this._finishRetry({
2661
+ schemaVersion: 1,
2662
+ outcome: "failed",
2663
+ reason: "continuation_rejected",
2664
+ attempt: this._bounded(attempt),
2665
+ cumulativeErrors: this._bounded(this._runRetryCount),
2666
+ }, { type: "auto_retry_end", success: false, attempt, finalError: continuationError.message });
2667
+ }
2668
+ catch (persistenceError) {
2669
+ settlementError = new AggregateError([continuationError, persistenceError], "Retry continuation and outcome persistence both failed.", { cause: continuationError });
2670
+ }
2671
+ this._settleRetryChain(run.chain, settlementError);
2672
+ });
2413
2673
  return true;
2414
2674
  }
2675
+ _finishCancelledRetry(reason, expected) {
2676
+ if (this._disposed) {
2677
+ this._retry = undefined;
2678
+ throw new Error("AgentSession disposed before retry settlement completed.");
2679
+ }
2680
+ const retry = this._retry;
2681
+ if (expected && retry !== expected)
2682
+ return false;
2683
+ const attempt = retry?.attempt ?? 0;
2684
+ this._finishRetry({
2685
+ schemaVersion: 1,
2686
+ outcome: "cancelled",
2687
+ reason,
2688
+ attempt: this._bounded(attempt),
2689
+ cumulativeErrors: this._bounded(this._runRetryCount),
2690
+ }, { type: "auto_retry_end", success: false, attempt, finalError: "Retry cancelled" });
2691
+ return false;
2692
+ }
2415
2693
  /**
2416
2694
  * Cancel in-progress retry.
2417
2695
  */
2418
2696
  abortRetry() {
2419
- this._retryAbortController?.abort();
2420
- // Note: _retryAttempt is reset in the catch block of _autoRetry
2421
- this._resolveRetry();
2422
- }
2423
- /**
2424
- * Wait for any in-progress retry to complete.
2425
- * Returns immediately if no retry is in progress.
2426
- */
2427
- async waitForRetry() {
2428
- if (!this._retryPromise) {
2697
+ const retry = this._retry;
2698
+ if (!retry)
2429
2699
  return;
2700
+ if (retry.phase === "backoff")
2701
+ retry.controller.abort();
2702
+ this.agent.abort();
2703
+ }
2704
+ /** Agent's own `activeRun` guard is false during backoff; the retry phase owns the idle Agent then. */
2705
+ _assertRetryNotOwningIdleAgent() {
2706
+ if (this._retry && !this.isStreaming) {
2707
+ throw new Error("Automatic retry is in progress. Cancel the retry before starting another prompt.");
2708
+ }
2709
+ }
2710
+ async _runAgentPrompt(messages) {
2711
+ this._assertRetryNotOwningIdleAgent();
2712
+ const start = this._reserveAgentRunStart();
2713
+ this._runRetryCount = 0;
2714
+ let promptFailure;
2715
+ try {
2716
+ await this.agent.prompt(messages);
2430
2717
  }
2431
- await this._retryPromise;
2718
+ catch (error) {
2719
+ promptFailure = { error };
2720
+ }
2721
+ if (!start.run && this._pendingAgentRunStart === start) {
2722
+ const startError = promptFailure
2723
+ ? promptFailure.error instanceof Error
2724
+ ? promptFailure.error
2725
+ : new Error(String(promptFailure.error))
2726
+ : new Error("Agent prompt completed before agent_start.");
2727
+ this._rejectAgentRunStart(start, startError);
2728
+ if (promptFailure)
2729
+ throw promptFailure.error;
2730
+ }
2731
+ let settlementFailure;
2732
+ try {
2733
+ const settlement = await start.promise;
2734
+ await this.waitForRetry(settlement);
2735
+ }
2736
+ catch (error) {
2737
+ settlementFailure = { error };
2738
+ }
2739
+ if (promptFailure && settlementFailure) {
2740
+ throw new AggregateError([promptFailure.error, settlementFailure.error], "Agent prompt and retry settlement both failed.", { cause: promptFailure.error });
2741
+ }
2742
+ if (promptFailure)
2743
+ throw promptFailure.error;
2744
+ if (settlementFailure)
2745
+ throw settlementFailure.error;
2746
+ }
2747
+ async waitForRetry(settlement) {
2748
+ if (!settlement)
2749
+ return;
2750
+ await settlement.promise;
2751
+ await settlement.chain.promise;
2432
2752
  await this.agent.waitForIdle();
2433
2753
  }
2434
2754
  /** Whether auto-retry is currently in progress */
2435
2755
  get isRetrying() {
2436
- return this._retryPromise !== undefined;
2756
+ return this._retry !== undefined;
2437
2757
  }
2438
2758
  /** Whether auto-retry is enabled */
2439
2759
  get autoRetryEnabled() {