cc-viewer 1.9.16 → 1.9.18
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.
- package/cli.js +17 -1
- package/dist/assets/{App-CYdOioAz.js → App-COyZwmVt.js} +2 -2
- package/dist/assets/{MdxEditorPanel-CuCAe1OG.js → MdxEditorPanel-BsHDgZCY.js} +1 -1
- package/dist/assets/Mobile-B1H_PrNl.js +1 -0
- package/dist/assets/{ProxyStatsModal-Dhj4EVnh.js → ProxyStatsModal-CSQNNwpU.js} +1 -1
- package/dist/assets/index-CPn0gvr6.js +2 -0
- package/dist/assets/seqResourceLoaders-Cm1JnNsu.js +2 -0
- package/dist/assets/{seqResourceLoaders-B-5xGs9q.css → seqResourceLoaders-DZA-JHK0.css} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/file-access-policy.js +108 -3
- package/server/lib/os-open.js +66 -0
- package/server/lib/plugin-loader.js +34 -2
- package/server/lib/sdk-manager.js +450 -45
- package/server/lib/target-scope.js +174 -0
- package/server/proxy.js +52 -8
- package/server/routes/files-content.js +80 -133
- package/server/routes/files-fs.js +8 -54
- package/server/routes/target-files-fs.js +332 -0
- package/server/server.js +59 -12
- package/dist/assets/Mobile-CfNYOgRa.js +0 -1
- package/dist/assets/index-sgW8MlD3.js +0 -2
- package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +0 -2
|
@@ -162,12 +162,33 @@ export function listSdkSessions() {
|
|
|
162
162
|
|
|
163
163
|
registerSdkSessionLister(listSdkSessions);
|
|
164
164
|
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
// watchdog entirely; 25min covers long tool runs (big builds) with headroom.
|
|
169
|
-
const TURN_WATCHDOG_MS = 25 * 60 * 1000;
|
|
165
|
+
// The turn can legally wait on approval ceilings: perm/plan = 5min, AskUserQuestion =
|
|
166
|
+
// 24h ("GUI effectively has no timeout" is a product contract). A pending approval
|
|
167
|
+
// exempts the turn from probing entirely (the child is parked awaiting our response).
|
|
170
168
|
const INTERRUPT_GRACE_MS = 10 * 1000;
|
|
169
|
+
// Activity-probe tuning: how long a turn may produce NOTHING before we start pinging
|
|
170
|
+
// the child's control plane, how often we re-ping, and how many consecutive unanswered
|
|
171
|
+
// probes convict the child as wedged. A legal long turn (multi-hour tool, user away) is
|
|
172
|
+
// silent yet answers probes, so it is never convicted on silence alone. The probe must
|
|
173
|
+
// be a REAL control round-trip (getContextUsage — supportedCommands/initializationResult
|
|
174
|
+
// are cached init promises in the SDK and resolve even against a wedged event loop).
|
|
175
|
+
const ACTIVITY_PROBE_IDLE_MS = 10 * 60 * 1000;
|
|
176
|
+
const ACTIVITY_PROBE_INTERVAL_MS = 3 * 60 * 1000;
|
|
177
|
+
const ACTIVITY_PROBE_MAX_FAILURES = 2;
|
|
178
|
+
// HTTP statuses the CLI/ccv treat as retryable API errors (drives the manual [retry]
|
|
179
|
+
// affordance). error_status === null (a network error with no HTTP response) is also
|
|
180
|
+
// retryable; auth (401/403) and billing failures are not — retrying them is pointless.
|
|
181
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504, 529]);
|
|
182
|
+
// Synthetic continue instruction pushed by the manual [retry] affordance. Marked with a
|
|
183
|
+
// sentinel the web UI hides from the chat bubble (the model still reads the instruction).
|
|
184
|
+
// It does NOT resend the user's original message — that is already in the history.
|
|
185
|
+
const RETRY_CONTINUE_PROMPT =
|
|
186
|
+
'[cc-viewer:retry] The previous response was interrupted by an API error. ' +
|
|
187
|
+
'Continue the same task from where it stopped. Do not repeat completed work; ' +
|
|
188
|
+
'finish the remaining steps and reply concisely.';
|
|
189
|
+
// How often to re-broadcast sdk-session-state while a turn is active (keeps the client's
|
|
190
|
+
// Stop button/spinner alive past its 30s stale sweep and re-seeds it after a view switch).
|
|
191
|
+
const STATE_HEARTBEAT_MS = 10 * 1000;
|
|
171
192
|
// getContextUsage control requests have no SDK-side timeout — bound the wait so a live-but-
|
|
172
193
|
// unresponsive child can't wedge the in-flight gate forever.
|
|
173
194
|
const CTX_USAGE_TIMEOUT_MS = 10 * 1000;
|
|
@@ -184,6 +205,26 @@ let _onTurnEnd = null; // SDK mode has no Stop hook (ensureHooks() skipped) —
|
|
|
184
205
|
let _onQueryError = null; // query-level failure → (message: string, meta: {cwd, sessionId, instanceKey}) => void; cli.js forwards it as a scoped sdk-error WS frame (dialogue row + toast)
|
|
185
206
|
let _broadcastWs = null;
|
|
186
207
|
let _runWaterfallHook = null;
|
|
208
|
+
// Durable process-level wiring registered ONCE by cli.js. Unlike the mutable callbacks
|
|
209
|
+
// above (which stopSession clears), this survives a workspace stop→relaunch so a launch
|
|
210
|
+
// route that omits the callbacks still restores them (see _adoptProcessWiring). Tests
|
|
211
|
+
// never register it, so stopSession's "clear the wiring" contract for tests is unchanged.
|
|
212
|
+
let _processWiring = null;
|
|
213
|
+
export function setSdkProcessWiring(w) {
|
|
214
|
+
_processWiring = (w == null) ? null : {
|
|
215
|
+
onTurnEnd: w.onTurnEnd || null,
|
|
216
|
+
onQueryError: w.onQueryError || null,
|
|
217
|
+
broadcastWs: w.broadcastWs || null,
|
|
218
|
+
runWaterfallHook: w.runWaterfallHook || null,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function _adoptProcessWiring() {
|
|
222
|
+
if (!_processWiring) return;
|
|
223
|
+
if (!_onTurnEnd) _onTurnEnd = _processWiring.onTurnEnd;
|
|
224
|
+
if (!_onQueryError) _onQueryError = _processWiring.onQueryError;
|
|
225
|
+
if (!_broadcastWs) _broadcastWs = _processWiring.broadcastWs;
|
|
226
|
+
if (!_runWaterfallHook) _runWaterfallHook = _processWiring.runWaterfallHook;
|
|
227
|
+
}
|
|
187
228
|
|
|
188
229
|
// Process-level SDK LAUNCH-CONFIG template (2026-10-12). cli.js registers it once at boot
|
|
189
230
|
// (the env carries the loopback-proxy URL + the per-cwd header is added per session). The
|
|
@@ -360,10 +401,25 @@ function _createSession(key, cwd) {
|
|
|
360
401
|
// CLI-side turn may still be running; without the sticky flag a
|
|
361
402
|
// second Stop would silently no-op).
|
|
362
403
|
let _settleGraceTimer = null; // backstop after interrupt: force-settle if no result arrives
|
|
404
|
+
let _lostResultTimer = null; // lost-result backstop handle (cleared on turn settle/teardown)
|
|
363
405
|
let _lateResultTombstones = 0; // results to discard (already force-settled via grace/watchdog)
|
|
364
406
|
let _consecutiveDeaths = 0; // backstop against respawn storms in a broken environment
|
|
365
|
-
|
|
366
|
-
|
|
407
|
+
// Activity probe (replaces the old fixed 25min silence watchdog): re-armed on every
|
|
408
|
+
// incoming SDK message. When a turn stays silent past ACTIVITY_PROBE_IDLE_MS it pings
|
|
409
|
+
// the CLI's control plane (getContextUsage — a REAL round-trip; the init-cached
|
|
410
|
+
// supportedCommands/initializationResult would resolve even against a wedged child).
|
|
411
|
+
// Only consecutive unanswered probes mean the child is genuinely wedged (a legal
|
|
412
|
+
// multi-hour tool turn keeps its event loop responsive and answers). This never kills
|
|
413
|
+
// a long turn for being merely quiet.
|
|
414
|
+
let _activityProbe = null;
|
|
415
|
+
let _probeFailures = 0;
|
|
416
|
+
let _probeEpoch = 0; // bumped on any message/interrupt → invalidates an in-flight probe
|
|
417
|
+
// Suspect generation: set when an interrupt grace expires with no result (the CLI never
|
|
418
|
+
// answered our Stop) or a probe convicts the child. The wedged query is torn down LAZILY
|
|
419
|
+
// — on the next push rather than at conviction time — so we never SIGTERM a child that
|
|
420
|
+
// may just be slow, and the rebuild resumes _sessionId for continuity.
|
|
421
|
+
let _suspectGeneration = false;
|
|
422
|
+
let _lastFailedTurn = null; // { turnId, retryable, apiStatus, attempt, maxRetries, code, ts } — drives the manual [retry] affordance
|
|
367
423
|
let _sessionId = null;
|
|
368
424
|
let _initAnnouncedSid = ''; // sessionId whose slash-command surface was already announced (reset on full reset)
|
|
369
425
|
let _lastInitSnapshot = null; // last sdk-init payload for WS reconnect replay
|
|
@@ -380,6 +436,7 @@ function _createSession(key, cwd) {
|
|
|
380
436
|
// injected by cli.js) > push-running / result-idle fallbacks below. Retained for WS
|
|
381
437
|
// reconnect replay like _lastInitSnapshot; reset alongside it on switch/reset.
|
|
382
438
|
let _lastSessionState = null;
|
|
439
|
+
let _stateHeartbeat = null; // setInterval re-broadcasting sdk-session-state while a turn is active
|
|
383
440
|
let _lastContextUsage = null; // last sdk-context-usage frame for WS reconnect replay
|
|
384
441
|
let _ctxUsageFetching = false; // one getContextUsage control request in flight
|
|
385
442
|
let _ctxUsageQueued = false; // a turn-end/compact arrived while in flight → rerun once (coalesce)
|
|
@@ -450,10 +507,15 @@ function _createSession(key, cwd) {
|
|
|
450
507
|
// (an interrupted turn still synthesizes an error_during_execution result). All
|
|
451
508
|
// settle paths (result / grace force-settle / teardown settleAll) go through
|
|
452
509
|
// _settleTurn, which is idempotent via rec.settled.
|
|
510
|
+
let _turnSeq = 0;
|
|
453
511
|
function _beginTurn() {
|
|
454
512
|
let resolve;
|
|
455
513
|
const promise = new Promise((r) => { resolve = r; });
|
|
456
|
-
const rec = {
|
|
514
|
+
const rec = {
|
|
515
|
+
id: 'turn_' + (++_turnSeq).toString(36) + '_' + Math.random().toString(36).slice(2, 8),
|
|
516
|
+
resolve, interrupted: false, settled: false,
|
|
517
|
+
apiRetry: { count: 0, attempt: 0, maxRetries: 0, lastStatus: null, lastError: null },
|
|
518
|
+
};
|
|
457
519
|
_pendingTurns.push(rec);
|
|
458
520
|
return { rec, promise };
|
|
459
521
|
}
|
|
@@ -471,38 +533,132 @@ function _createSession(key, cwd) {
|
|
|
471
533
|
_queryBusy = false;
|
|
472
534
|
_turnInFlight = false;
|
|
473
535
|
_clearTurnWatchdog();
|
|
536
|
+
_clearLostResultBackstop();
|
|
474
537
|
}
|
|
475
538
|
|
|
476
539
|
function _clearGraceTimer() {
|
|
477
540
|
if (_settleGraceTimer) { clearTimeout(_settleGraceTimer); _settleGraceTimer = null; }
|
|
478
541
|
}
|
|
479
542
|
|
|
480
|
-
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
543
|
+
/** Activity probe (replaces the old fixed 25min silence watchdog). Re-armed on every
|
|
544
|
+
* incoming SDK message, so it only fires after ACTIVITY_PROBE_IDLE_MS of total silence.
|
|
545
|
+
* Instead of force-killing the turn for being quiet (which would murder a legal
|
|
546
|
+
* multi-hour tool run), it pings the CLI's control plane: a live child answers, a wedged
|
|
547
|
+
* one does not. Only ACTIVITY_PROBE_MAX_FAILURES consecutive unanswered probes convict
|
|
548
|
+
* the child and trigger recovery. Pending approvals also prove liveness (the child is
|
|
549
|
+
* parked awaiting our control response), so probes are not even sent then.
|
|
550
|
+
*
|
|
551
|
+
* `_probeFailures` deliberately survives re-arms: _clearTurnWatchdog only cancels the
|
|
552
|
+
* pending timer, and the counter is reset by a successful/answered probe (or a full
|
|
553
|
+
* teardown/reset) — otherwise conviction at MAX_FAILURES would be unreachable. */
|
|
486
554
|
function _armTurnWatchdog() {
|
|
487
555
|
_clearTurnWatchdog();
|
|
488
556
|
if (_pendingTurns.length === 0) return;
|
|
489
|
-
|
|
490
|
-
|
|
557
|
+
_activityProbe = setTimeout(() => {
|
|
558
|
+
_activityProbe = null;
|
|
491
559
|
if (_pendingTurns.length === 0) return;
|
|
492
|
-
if (_pendingApprovals.size > 0) {
|
|
493
|
-
|
|
560
|
+
if (_pendingApprovals.size > 0) { _armTurnWatchdog(); return; } // approval pending = alive
|
|
561
|
+
_runActivityProbe();
|
|
562
|
+
}, _probeFailures === 0 ? ACTIVITY_PROBE_IDLE_MS : ACTIVITY_PROBE_INTERVAL_MS);
|
|
563
|
+
if (_activityProbe.unref) _activityProbe.unref();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function _clearTurnWatchdog() {
|
|
567
|
+
if (_activityProbe) { clearTimeout(_activityProbe); _activityProbe = null; }
|
|
568
|
+
// Any arm/clear is triggered by observed activity (a message re-arms on every frame,
|
|
569
|
+
// a settle clears) — invalidate an in-flight probe and reset consecutive-failure
|
|
570
|
+
// counting so a slow probe on the PREVIOUS quiet stretch cannot convict a live turn.
|
|
571
|
+
_probeEpoch++;
|
|
572
|
+
if (_pendingTurns.length === 0) _probeFailures = 0;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Ping the child's control plane once with a REAL control round-trip (getContextUsage;
|
|
576
|
+
* supportedCommands/initializationResult resolve from the cached init promise and prove
|
|
577
|
+
* nothing). An answer — resolve OR reject — means the event loop is responsive: reset
|
|
578
|
+
* the failure count and keep waiting silently. No answer within the timeout counts one
|
|
579
|
+
* failure; ACTIVITY_PROBE_MAX_FAILURES consecutive failures convict the child as wedged.
|
|
580
|
+
* Both outcomes re-check the generation + FIFO: a probe started on query A must never
|
|
581
|
+
* convict generation B, and a turn that completed mid-probe must not be "recovered".
|
|
582
|
+
* A probe that never resolves leaks one in-flight control request per conviction —
|
|
583
|
+
* bounded, and the pending reject on teardown (Query.cleanup) is swallowed by `done`. */
|
|
584
|
+
function _runActivityProbe() {
|
|
585
|
+
const q = _activeQuery;
|
|
586
|
+
if (!q || typeof q.getContextUsage !== 'function') { _armTurnWatchdog(); return; }
|
|
587
|
+
// Bind the verdict to the TURN probed AND the probe epoch: any message/interrupt
|
|
588
|
+
// observed since this probe started proves the child is alive, so its timeout must be
|
|
589
|
+
// discarded rather than charged. (a new turn as head also invalidates the verdict.)
|
|
590
|
+
const probeRec = _pendingTurns[0] || null;
|
|
591
|
+
const epoch = _probeEpoch;
|
|
592
|
+
let timer = null;
|
|
593
|
+
let done = false;
|
|
594
|
+
// NOTE: own .then/.catch handlers instead of Promise.race — under node:test mocked
|
|
595
|
+
// timers a Promise.race([neverSettlingPromise, mockedTimerPromise]) never runs its
|
|
596
|
+
// reactions (race subscribes to the pending promise first; the mocked timer's later
|
|
597
|
+
// settle does not flush the race's capability). Plain handlers are immune.
|
|
598
|
+
timer = setTimeout(() => {
|
|
599
|
+
if (done) return;
|
|
600
|
+
done = true;
|
|
601
|
+
timer = null;
|
|
602
|
+
finish('timeout');
|
|
603
|
+
}, CTX_USAGE_TIMEOUT_MS);
|
|
604
|
+
if (timer.unref) timer.unref();
|
|
605
|
+
let probeCall;
|
|
606
|
+
try { probeCall = q.getContextUsage(); }
|
|
607
|
+
catch { finish('answered'); return; } // a sync throw still proves the event loop runs
|
|
608
|
+
if (!probeCall || typeof probeCall.then !== 'function') { finish('answered'); return; } // non-Promise impl = responsive
|
|
609
|
+
probeCall.then(
|
|
610
|
+
() => { if (done) return; done = true; if (timer) { clearTimeout(timer); timer = null; } finish('answered'); },
|
|
611
|
+
() => { if (done) return; done = true; if (timer) { clearTimeout(timer); timer = null; } finish('answered'); },
|
|
612
|
+
);
|
|
613
|
+
function finish(outcome) {
|
|
614
|
+
// Stale generation / settled-or-switched turn / activity since the probe started /
|
|
615
|
+
// a user interrupt / a parked approval: the probe's verdict no longer applies.
|
|
616
|
+
if (_activeQuery !== q || _pendingTurns.length === 0 || _pendingTurns[0] !== probeRec) return;
|
|
617
|
+
if (epoch !== _probeEpoch) return; // activity observed mid-probe — the child is alive
|
|
618
|
+
if (probeRec && probeRec.interrupted) return;
|
|
619
|
+
if (_pendingApprovals.size > 0) return;
|
|
620
|
+
if (outcome === 'timeout') {
|
|
621
|
+
_probeFailures += 1;
|
|
622
|
+
if (_probeFailures < ACTIVITY_PROBE_MAX_FAILURES) { _armTurnWatchdog(); return; }
|
|
623
|
+
console.warn('[sdk-manager] turn unresponsive to control probes — recovering the wedged query');
|
|
624
|
+
_recoverFromStuckTurn(_pendingTurns[0] || null);
|
|
494
625
|
return;
|
|
495
626
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}, TURN_WATCHDOG_MS);
|
|
501
|
-
if (_turnWatchdog.unref) _turnWatchdog.unref();
|
|
627
|
+
// resolve OR reject both prove the control plane (event loop) is responsive.
|
|
628
|
+
_probeFailures = 0;
|
|
629
|
+
_armTurnWatchdog(); // still no turn output — schedule the next idle probe
|
|
630
|
+
}
|
|
502
631
|
}
|
|
503
632
|
|
|
504
|
-
|
|
505
|
-
|
|
633
|
+
/** Recover from a genuinely wedged query (proven by control probes, or an interrupt the
|
|
634
|
+
* CLI never answered). Settles turn promises, announces idle, records a retryable failure
|
|
635
|
+
* so the UI can offer a manual [retry], and marks the generation suspect. The teardown
|
|
636
|
+
* itself is LAZY — deferred to the next push (_startTurn consults _suspectGeneration) —
|
|
637
|
+
* so we never SIGTERM a merely-slow child mid-side-effect; the rebuild resumes
|
|
638
|
+
* _sessionId for continuity. */
|
|
639
|
+
function _recoverFromStuckTurn(stalledRec) {
|
|
640
|
+
_suspectGeneration = true;
|
|
641
|
+
// Cancel any parked approvals: they belong to the (now suspect) generation's canUseTool,
|
|
642
|
+
// and leaving them would replay a ghost modal on attach AND suppress probing on the
|
|
643
|
+
// rebuilt generation (size > 0 = "alive"). They time out / deny as their own path.
|
|
644
|
+
_cancelAllApprovals();
|
|
645
|
+
// Tombstone the result that a merely-slow child may still emit before the lazy
|
|
646
|
+
// teardown — otherwise it would fire a spurious turn_end for an already-failed turn.
|
|
647
|
+
_lateResultTombstones++;
|
|
648
|
+
_settleAllTurns();
|
|
649
|
+
_announceSessionState('idle');
|
|
650
|
+
// Retry is only possible if a session id was captured to resume — a first turn that
|
|
651
|
+
// wedges before any init frame has nothing to continue, so don't offer [retry].
|
|
652
|
+
const retryable = !!(_sessionId || _launchResume);
|
|
653
|
+
_lastFailedTurn = {
|
|
654
|
+
turnId: stalledRec?.id || null, retryable, apiStatus: null,
|
|
655
|
+
attempt: stalledRec?.apiRetry?.attempt || 0, maxRetries: stalledRec?.apiRetry?.maxRetries || 0,
|
|
656
|
+
code: 'turn-stuck', ts: Date.now(),
|
|
657
|
+
};
|
|
658
|
+
_notifyQueryError(new Error('The turn became unresponsive; the SDK session will restart on the next message.'), {
|
|
659
|
+
code: 'turn-stuck', retryable, turnId: stalledRec?.id || null,
|
|
660
|
+
});
|
|
661
|
+
_maybeDrain(); // a queued backlog must not stall behind the recovered turn
|
|
506
662
|
}
|
|
507
663
|
|
|
508
664
|
/**
|
|
@@ -536,7 +692,18 @@ function _createSession(key, cwd) {
|
|
|
536
692
|
// A fresh user-initiated turn re-arms automatic draining of parked (Stop-kept) items.
|
|
537
693
|
_suppressDrain = false;
|
|
538
694
|
_consecutiveDeaths = 0; // explicit user action re-arms the death-drain backstop
|
|
539
|
-
const { promise } = await _startTurn(text);
|
|
695
|
+
const { started, promise, notified } = await _startTurn(text);
|
|
696
|
+
// A pre-push delivery failure (teardown raced / query closed) would otherwise leave
|
|
697
|
+
// the user with a cleared composer and no signal. _startTurn already settled the turn
|
|
698
|
+
// promise (so `promise` won't hang); reject HERE with a code so server.js can route a
|
|
699
|
+
// directed sdk-send-failed to the initiator. Construction failures are excluded
|
|
700
|
+
// (notified:true) — they already fired _notifyQueryError (a second toast would
|
|
701
|
+
// double-report).
|
|
702
|
+
if (!started && !notified) {
|
|
703
|
+
const err = new Error('message not delivered to the SDK session');
|
|
704
|
+
err.code = 'not-delivered';
|
|
705
|
+
throw err;
|
|
706
|
+
}
|
|
540
707
|
return promise;
|
|
541
708
|
}
|
|
542
709
|
|
|
@@ -569,6 +736,16 @@ function _createSession(key, cwd) {
|
|
|
569
736
|
* rejects; a later turn failure must NOT put an already-pushed message back.
|
|
570
737
|
*/
|
|
571
738
|
async function _startTurn(text, dispatch = null) {
|
|
739
|
+
// A convicted/suspect generation is torn down HERE — lazily, just before the next
|
|
740
|
+
// push — rather than at conviction time. Deferring avoids SIGTERM-ing a merely-slow
|
|
741
|
+
// child mid-side-effect; rebuilding now lets _ensureResidentQuery resume _sessionId.
|
|
742
|
+
// This MUST run before _beginTurn(): teardown settles every pending turn record, so a
|
|
743
|
+
// record created first would be settled (and its message silently dropped) by its own
|
|
744
|
+
// teardown, leaving _queryBusy wedged with an empty FIFO.
|
|
745
|
+
if (_suspectGeneration) {
|
|
746
|
+
_suspectGeneration = false;
|
|
747
|
+
_teardownResident();
|
|
748
|
+
}
|
|
572
749
|
const { rec, promise } = _beginTurn();
|
|
573
750
|
if (dispatch) rec.dispatchItem = dispatch;
|
|
574
751
|
_queryBusy = true;
|
|
@@ -580,7 +757,9 @@ function _createSession(key, cwd) {
|
|
|
580
757
|
_settleTurn(rec);
|
|
581
758
|
_queryBusy = false;
|
|
582
759
|
_notifyQueryError(err);
|
|
583
|
-
|
|
760
|
+
// notified:true tells _sendUserMessage NOT to also reject not-delivered — the user
|
|
761
|
+
// already got the query-error toast; a second rejection would double-report.
|
|
762
|
+
return { started: false, promise, turnId: rec.id, notified: true };
|
|
584
763
|
}
|
|
585
764
|
// Teardown/stop raced with construction — the settle already happened; don't push.
|
|
586
765
|
if (rec.settled || (dispatch && _dispatching !== dispatch)) {
|
|
@@ -589,13 +768,13 @@ function _createSession(key, cwd) {
|
|
|
589
768
|
_settleTurn(rec);
|
|
590
769
|
if (_dispatching !== dispatch) _queryBusy = false;
|
|
591
770
|
}
|
|
592
|
-
return { started: false, promise };
|
|
771
|
+
return { started: false, promise, turnId: rec.id };
|
|
593
772
|
}
|
|
594
773
|
if (!_activeQuery || !_inputQueue || (typeof _inputQueue.isClosed === 'function' && _inputQueue.isClosed()) || (dispatch && _dispatching !== dispatch)) {
|
|
595
774
|
_pendingTurns = _pendingTurns.filter((turn) => turn !== rec);
|
|
596
775
|
_settleTurn(rec);
|
|
597
776
|
_queryBusy = false;
|
|
598
|
-
return { started: false, promise };
|
|
777
|
+
return { started: false, promise, turnId: rec.id };
|
|
599
778
|
}
|
|
600
779
|
// Typed /clear is detected at EXECUTION time (never at enqueue time): a clear queued
|
|
601
780
|
// behind a busy turn must not wipe the running turn's usage panel. Invalidate the
|
|
@@ -613,7 +792,7 @@ function _createSession(key, cwd) {
|
|
|
613
792
|
_settleTurn(rec);
|
|
614
793
|
_queryBusy = false;
|
|
615
794
|
reportSwallowed('sdk-manager.queue-push', err);
|
|
616
|
-
return { started: false, promise };
|
|
795
|
+
return { started: false, promise, turnId: rec.id };
|
|
617
796
|
}
|
|
618
797
|
_turnInFlight = true;
|
|
619
798
|
// Turn-level loading fallback: light the spinner the moment the turn is pushed.
|
|
@@ -622,7 +801,7 @@ function _createSession(key, cwd) {
|
|
|
622
801
|
// the only "on" signal, paired with the result-idle broadcast in _processMessage.
|
|
623
802
|
_announceSessionState('running');
|
|
624
803
|
_armTurnWatchdog();
|
|
625
|
-
return { started: true, promise };
|
|
804
|
+
return { started: true, promise, turnId: rec.id };
|
|
626
805
|
}
|
|
627
806
|
|
|
628
807
|
/**
|
|
@@ -883,6 +1062,10 @@ function _createSession(key, cwd) {
|
|
|
883
1062
|
async function _drainResidentIterator(query, inputQueue) {
|
|
884
1063
|
try {
|
|
885
1064
|
for await (const msg of query) {
|
|
1065
|
+
// Generation guard: after a teardown/switch, a stale generation's late frames must
|
|
1066
|
+
// NOT be processed — they belong to the dead child and would mis-settle the NEW
|
|
1067
|
+
// generation's pending turns. Bail out and let _onResidentDeath dismiss it as stale.
|
|
1068
|
+
if (_activeQuery !== query) break;
|
|
886
1069
|
// A message-processing failure (e.g. a broadcast callback throwing) must NOT be
|
|
887
1070
|
// mistaken for session death — the child is still alive; only the iterator's
|
|
888
1071
|
// end/throw is a death signal.
|
|
@@ -891,7 +1074,11 @@ function _createSession(key, cwd) {
|
|
|
891
1074
|
} catch (msgErr) {
|
|
892
1075
|
reportSwallowed('sdk-manager.process-message', msgErr);
|
|
893
1076
|
}
|
|
894
|
-
|
|
1077
|
+
// Any message proves the child is alive: reset consecutive-failure counting too,
|
|
1078
|
+
// so a probe that timed out on the PREVIOUS quiet stretch cannot convict a turn
|
|
1079
|
+
// that has since demonstrably produced output.
|
|
1080
|
+
_probeFailures = 0;
|
|
1081
|
+
_armTurnWatchdog(); // reset the activity probe on observed activity
|
|
895
1082
|
}
|
|
896
1083
|
_onResidentDeath(query, inputQueue, null);
|
|
897
1084
|
} catch (err) {
|
|
@@ -909,6 +1096,10 @@ function _createSession(key, cwd) {
|
|
|
909
1096
|
if (_activeQuery !== query) return; // stale generation — teardown already moved on
|
|
910
1097
|
_activeQuery = null;
|
|
911
1098
|
_inputQueue = null;
|
|
1099
|
+
// The (possibly wedged) child exited on its own — the suspicion dies with it, so the
|
|
1100
|
+
// next push must not run a pointless teardown against an already-dead generation.
|
|
1101
|
+
_suspectGeneration = false;
|
|
1102
|
+
_probeFailures = 0;
|
|
912
1103
|
if (_ctxUsageClearPending) {
|
|
913
1104
|
// A child that exits while applying /clear must not resume the cleared session on the
|
|
914
1105
|
// next message. Start fresh, allow the next init/read, and invalidate older controls.
|
|
@@ -919,13 +1110,30 @@ function _createSession(key, cwd) {
|
|
|
919
1110
|
}
|
|
920
1111
|
_clearGraceTimer();
|
|
921
1112
|
_clearTurnWatchdog();
|
|
1113
|
+
_clearStateHeartbeat();
|
|
922
1114
|
_lateResultTombstones = 0;
|
|
1115
|
+
const hadTurns = _pendingTurns.length > 0;
|
|
1116
|
+
const headTurn = _pendingTurns[0] || null;
|
|
923
1117
|
// Session died mid-turn: no result will arrive, so without this the spinner would
|
|
924
|
-
// hang
|
|
1118
|
+
// hang forever. The turn is unambiguously over.
|
|
925
1119
|
_announceSessionState('idle');
|
|
926
1120
|
if (err && err.name !== 'AbortError') {
|
|
927
1121
|
console.error('[SDK] Resident query error:', err.message);
|
|
928
1122
|
_notifyQueryError(err);
|
|
1123
|
+
} else if (!err && hadTurns) {
|
|
1124
|
+
// Clean exit with a turn still in flight is itself a failure — the user would
|
|
1125
|
+
// otherwise see only "idle" with no error. If the CLI exhausted API retries
|
|
1126
|
+
// before dying, keep it retryable so the manual [retry] affordance appears.
|
|
1127
|
+
const retried = (headTurn?.apiRetry?.count || 0) > 0;
|
|
1128
|
+
if (retried) {
|
|
1129
|
+
const status = headTurn.apiRetry.lastStatus;
|
|
1130
|
+
_lastFailedTurn = {
|
|
1131
|
+
turnId: headTurn.id, retryable: (status == null || RETRYABLE_STATUS.has(status)) && !!(_sessionId || _launchResume),
|
|
1132
|
+
apiStatus: status, attempt: headTurn.apiRetry.attempt, maxRetries: headTurn.apiRetry.maxRetries,
|
|
1133
|
+
code: 'session-ended', ts: Date.now(),
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
_notifyQueryError(new Error('Agent SDK session ended before the turn completed'), { code: 'session-ended', retryable: retried && !!(_sessionId || _launchResume), turnId: headTurn?.id || null });
|
|
929
1137
|
}
|
|
930
1138
|
_settleAllTurns();
|
|
931
1139
|
// A dispatching item waiting on an interrupt unwind never reached the SDK. Death
|
|
@@ -974,7 +1182,13 @@ function _createSession(key, cwd) {
|
|
|
974
1182
|
_initSkills = null; // re-captured from the next init frame
|
|
975
1183
|
_clearGraceTimer();
|
|
976
1184
|
_clearTurnWatchdog();
|
|
1185
|
+
_probeFailures = 0;
|
|
1186
|
+
_clearStateHeartbeat();
|
|
977
1187
|
_lateResultTombstones = 0;
|
|
1188
|
+
// NOTE: _lastFailedTurn deliberately survives teardown — the lazy rebuild path
|
|
1189
|
+
// (_startTurn's suspect-generation teardown) must keep the recorded failure so the
|
|
1190
|
+
// manual [retry] affordance stays available. User-facing session boundaries clear it
|
|
1191
|
+
// instead: _resetFullState / _switchToSession (which funnel close/teardown/stop).
|
|
978
1192
|
_settleAllTurns();
|
|
979
1193
|
if (inputQueue) inputQueue.close();
|
|
980
1194
|
if (query) {
|
|
@@ -984,10 +1198,11 @@ function _createSession(key, cwd) {
|
|
|
984
1198
|
}
|
|
985
1199
|
|
|
986
1200
|
/** Query-level error signal; the routing envelope always comes from this
|
|
987
|
-
* session's closure (callback throw must never break the caller).
|
|
988
|
-
|
|
1201
|
+
* session's closure (callback throw must never break the caller). `extra` carries
|
|
1202
|
+
* failure metadata (code/retryable/turnId/apiStatus) through to the sdk-error frame. */
|
|
1203
|
+
function _notifyQueryError(err, extra = {}) {
|
|
989
1204
|
if (!_onQueryError) return;
|
|
990
|
-
const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey };
|
|
1205
|
+
const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey, ...extra };
|
|
991
1206
|
try { _onQueryError(String(err?.message || err), context); }
|
|
992
1207
|
catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
|
|
993
1208
|
}
|
|
@@ -1078,6 +1293,36 @@ function _createSession(key, cwd) {
|
|
|
1078
1293
|
// _broadcast carries the routing envelope (cwd + sessionId + instanceKey) — every
|
|
1079
1294
|
// frame needs it so concurrent sessions' clients filter correctly.
|
|
1080
1295
|
_broadcast({ type: 'sdk-session-state', state });
|
|
1296
|
+
_syncStateHeartbeat();
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// The in-flight turn is the FIFO HEAD (the result branch shifts from the head).
|
|
1300
|
+
const _currentTurnId = () => (_pendingTurns.length ? _pendingTurns[0].id : null);
|
|
1301
|
+
|
|
1302
|
+
/** While a turn is running/requires_action, re-broadcast the session state every
|
|
1303
|
+
* STATE_HEARTBEAT_MS so clients (a) keep their Stop button / spinner alive past the
|
|
1304
|
+
* frontend's 30s no-signal stale sweep, and (b) can re-derive it after a view switch.
|
|
1305
|
+
* The heartbeat does NOT re-arm the activity probe (silence semantics unchanged) and
|
|
1306
|
+
* is cleared on idle/teardown/reset/switch/death. */
|
|
1307
|
+
function _syncStateHeartbeat() {
|
|
1308
|
+
const active = _lastSessionState === 'running' || _lastSessionState === 'requires_action';
|
|
1309
|
+
if (active && !_stateHeartbeat) {
|
|
1310
|
+
_stateHeartbeat = setInterval(() => {
|
|
1311
|
+
if (_lastSessionState !== 'running' && _lastSessionState !== 'requires_action') return;
|
|
1312
|
+
_broadcast({
|
|
1313
|
+
type: 'sdk-session-state', state: _lastSessionState, heartbeat: true,
|
|
1314
|
+
turnId: _currentTurnId(), pendingApprovals: _pendingApprovals.size,
|
|
1315
|
+
});
|
|
1316
|
+
}, STATE_HEARTBEAT_MS);
|
|
1317
|
+
if (_stateHeartbeat.unref) _stateHeartbeat.unref();
|
|
1318
|
+
} else if (!active && _stateHeartbeat) {
|
|
1319
|
+
clearInterval(_stateHeartbeat);
|
|
1320
|
+
_stateHeartbeat = null;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function _clearStateHeartbeat() {
|
|
1325
|
+
if (_stateHeartbeat) { clearInterval(_stateHeartbeat); _stateHeartbeat = null; }
|
|
1081
1326
|
}
|
|
1082
1327
|
|
|
1083
1328
|
/**
|
|
@@ -1097,6 +1342,39 @@ function _createSession(key, cwd) {
|
|
|
1097
1342
|
// push/result fallbacks keep the spinner correct at result granularity.
|
|
1098
1343
|
if (msg.subtype === 'session_state_changed') {
|
|
1099
1344
|
_announceSessionState(msg.state);
|
|
1345
|
+
// A CLI-emitted running/requires_action proves the head turn is genuinely
|
|
1346
|
+
// in-flight on the child (vs a freshly-drained turn whose own CLI frames have
|
|
1347
|
+
// not arrived yet). Lost-result only arms for such a turn — a LATE idle frame
|
|
1348
|
+
// from the previous turn must not convict the just-pushed one.
|
|
1349
|
+
if ((msg.state === 'running' || msg.state === 'requires_action') && _pendingTurns[0]) _pendingTurns[0].cliStateSeen = true;
|
|
1350
|
+
// Lost-result backstop: the CLI says the turn settled (idle) but no result
|
|
1351
|
+
// message arrived for our pending FIFO. Without this _queryBusy would wedge
|
|
1352
|
+
// forever now that the 25min silence watchdog is gone. Give the result a short
|
|
1353
|
+
// grace to arrive first (frame ordering within a turn end is not guaranteed),
|
|
1354
|
+
// then settle as a retryable failure.
|
|
1355
|
+
if (msg.state === 'idle' && _pendingTurns.length > 0 && _pendingTurns[0].cliStateSeen) _armLostResultBackstop();
|
|
1356
|
+
} else if (msg.subtype === 'api_retry') {
|
|
1357
|
+
// The CLI's own retry of a retryable API error (502/429/5xx/network). It carries
|
|
1358
|
+
// no turn id, so we attribute it to the head pending turn — approximate but
|
|
1359
|
+
// correct in the common single-turn case. Skipped while a tombstone is pending
|
|
1360
|
+
// (a late retry of an already-interrupted turn must not relight the bar).
|
|
1361
|
+
if (_lateResultTombstones === 0) {
|
|
1362
|
+
const rec = _pendingTurns.length ? _pendingTurns[0] : null;
|
|
1363
|
+
if (rec) {
|
|
1364
|
+
rec.apiRetry.count += 1;
|
|
1365
|
+
rec.apiRetry.attempt = Number(msg.attempt) || rec.apiRetry.count;
|
|
1366
|
+
rec.apiRetry.maxRetries = Number(msg.max_retries) || 0;
|
|
1367
|
+
rec.apiRetry.lastStatus = (typeof msg.error_status === 'number') ? msg.error_status : null;
|
|
1368
|
+
rec.apiRetry.lastError = msg.error || null;
|
|
1369
|
+
_broadcast({
|
|
1370
|
+
type: 'sdk-retry-state', phase: 'retrying', turnId: rec.id,
|
|
1371
|
+
attempt: rec.apiRetry.attempt, maxRetries: rec.apiRetry.maxRetries,
|
|
1372
|
+
retryDelayMs: Number(msg.retry_delay_ms) || 0,
|
|
1373
|
+
errorStatus: rec.apiRetry.lastStatus, error: rec.apiRetry.lastError || null,
|
|
1374
|
+
ts: Date.now(),
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1100
1378
|
} else if (msg.subtype === 'init') {
|
|
1101
1379
|
// /clear may reinitialize instead of returning a normal result. The new session is
|
|
1102
1380
|
// now authoritative, so context reads may resume (without resuming the old session).
|
|
@@ -1187,9 +1465,33 @@ function _createSession(key, cwd) {
|
|
|
1187
1465
|
break;
|
|
1188
1466
|
}
|
|
1189
1467
|
const rec = _pendingTurns.shift();
|
|
1468
|
+
// 502/retry state machine. An error result after observed CLI retries records a
|
|
1469
|
+
// retryable _lastFailedTurn (drives the manual [retry] affordance). ANY success
|
|
1470
|
+
// unconditionally closes a recorded failure — including a manual retry that
|
|
1471
|
+
// succeeds on its first API attempt (apiRetry.count === 0) — and clears
|
|
1472
|
+
// _lastFailedTurn; `retryOf` carries the FAILED turn's id (rec.retryOf when this
|
|
1473
|
+
// turn is itself a manual retry) because the frontend matches cleanup by it.
|
|
1474
|
+
if (rec) {
|
|
1475
|
+
const isError = msg.is_error === true || (msg.subtype && msg.subtype !== 'success');
|
|
1476
|
+
if (isError && !rec.interrupted) {
|
|
1477
|
+
const status = rec.apiRetry.lastStatus;
|
|
1478
|
+
const retryable = rec.apiRetry.count > 0 && (status == null || RETRYABLE_STATUS.has(status));
|
|
1479
|
+
_lastFailedTurn = {
|
|
1480
|
+
turnId: rec.id, retryable, apiStatus: status,
|
|
1481
|
+
attempt: rec.apiRetry.attempt, maxRetries: rec.apiRetry.maxRetries, code: null, ts: Date.now(),
|
|
1482
|
+
};
|
|
1483
|
+
} else if (!isError && _lastFailedTurn && (rec.apiRetry.count > 0 || rec.retryOf === _lastFailedTurn.turnId)) {
|
|
1484
|
+
// recovered only when there IS a recorded failure AND this turn closes it
|
|
1485
|
+
// (a manual retry of it — even on its first attempt — or a turn that itself
|
|
1486
|
+
// saw CLI retries). A clean success (no _lastFailedTurn) emits nothing; an
|
|
1487
|
+
// unrelated later success neither clears the affordance nor spams the frame.
|
|
1488
|
+
_broadcast({ type: 'sdk-retry-state', phase: 'recovered', turnId: rec.id, retryOf: rec.retryOf || _lastFailedTurn.turnId, ts: Date.now() });
|
|
1489
|
+
_lastFailedTurn = null;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1190
1492
|
// An interrupted turn's result is expected to be an error (turn aborted) — don't
|
|
1191
1493
|
// toast it. Turn-end still fires, matching the PTY Stop-hook semantics.
|
|
1192
|
-
if (rec && !rec.interrupted) _notifyTurnError(msg);
|
|
1494
|
+
if (rec && !rec.interrupted) _notifyTurnError(msg, rec);
|
|
1193
1495
|
// SDK turn-end signal. Equivalent to Claude Code's Stop hook
|
|
1194
1496
|
// in CLI mode — fires once per user-prompt response when the whole chain
|
|
1195
1497
|
// (assistant text + all tool calls + final reply) completes. SDK mode
|
|
@@ -1231,14 +1533,17 @@ function _createSession(key, cwd) {
|
|
|
1231
1533
|
* never signals it, so a dead/errored turn would otherwise look like a hang. The
|
|
1232
1534
|
* sdk-error frame renders as a persistent dialogue row plus a toast client-side.
|
|
1233
1535
|
*/
|
|
1234
|
-
function _notifyTurnError(resultMsg) {
|
|
1536
|
+
function _notifyTurnError(resultMsg, rec = null) {
|
|
1235
1537
|
const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
|
|
1236
1538
|
if (!isError) return;
|
|
1237
1539
|
const errs = Array.isArray(resultMsg.errors) && resultMsg.errors.length
|
|
1238
1540
|
? resultMsg.errors.join('; ')
|
|
1239
1541
|
: (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
|
|
1542
|
+
const retry = rec?.apiRetry || null;
|
|
1543
|
+
const status = retry?.lastStatus ?? null;
|
|
1544
|
+
const retryable = !!retry?.count && (status == null || RETRYABLE_STATUS.has(status));
|
|
1240
1545
|
if (_broadcastWs) {
|
|
1241
|
-
try { _broadcastWs({ type: 'sdk-error', message: String(errs), cwd: _cwd, sessionId: _sessionId || null, instanceKey }); } catch (err) { console.warn('[sdk-manager] sdk-error broadcast threw:', err?.message); }
|
|
1546
|
+
try { _broadcastWs({ type: 'sdk-error', message: String(errs), turnId: rec?.id || null, retryable, apiStatus: status, attempt: retry?.attempt || 0, maxRetries: retry?.maxRetries || 0, cwd: _cwd, sessionId: _sessionId || null, instanceKey }); } catch (err) { console.warn('[sdk-manager] sdk-error broadcast threw:', err?.message); }
|
|
1242
1547
|
}
|
|
1243
1548
|
}
|
|
1244
1549
|
|
|
@@ -1831,20 +2136,24 @@ function _createSession(key, cwd) {
|
|
|
1831
2136
|
return cancelled;
|
|
1832
2137
|
}
|
|
1833
2138
|
|
|
1834
|
-
/** Backstop: if no result shows up within the grace window after an interrupt,
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
2139
|
+
/** Backstop: if no result shows up within the grace window after an interrupt, settle
|
|
2140
|
+
* the head turn (keeping _queryBusy from wedging forever) and mark the generation
|
|
2141
|
+
* suspect — the CLI never answered our Stop, so the child may be wedged. The actual
|
|
2142
|
+
* teardown is deferred to the next push (_startTurn) so we never SIGTERM a child that
|
|
2143
|
+
* was merely slow to unwind; the rebuild resumes _sessionId. A tombstone discards the
|
|
2144
|
+
* late result if it ever arrives. */
|
|
1837
2145
|
function _armSettleGrace() {
|
|
1838
2146
|
_clearGraceTimer();
|
|
1839
2147
|
_settleGraceTimer = setTimeout(() => {
|
|
1840
2148
|
_settleGraceTimer = null;
|
|
1841
2149
|
const rec = _pendingTurns[0];
|
|
1842
2150
|
if (!rec || !rec.interrupted) return;
|
|
1843
|
-
console.warn('[sdk-manager] no result within interrupt grace —
|
|
2151
|
+
console.warn('[sdk-manager] no result within interrupt grace — settling the turn and marking the generation suspect');
|
|
1844
2152
|
_lateResultTombstones++;
|
|
1845
2153
|
_pendingTurns.shift();
|
|
1846
2154
|
_settleTurn(rec);
|
|
1847
2155
|
_queryBusy = false;
|
|
2156
|
+
_suspectGeneration = true;
|
|
1848
2157
|
// The CLI never answered the interrupt — no result is coming, so clear the
|
|
1849
2158
|
// spinner here rather than waiting on a dead signal.
|
|
1850
2159
|
_announceSessionState('idle');
|
|
@@ -1854,6 +2163,46 @@ function _createSession(key, cwd) {
|
|
|
1854
2163
|
if (_settleGraceTimer.unref) _settleGraceTimer.unref();
|
|
1855
2164
|
}
|
|
1856
2165
|
|
|
2166
|
+
/** Lost-result backstop: the CLI announced session_state_changed('idle') (turn over,
|
|
2167
|
+
* tools included) but never produced a result message for our pending FIFO. Give the
|
|
2168
|
+
* result a short grace to arrive (frame ordering at turn end is not guaranteed), then
|
|
2169
|
+
* settle as a retryable failure — without this _queryBusy would wedge forever now that
|
|
2170
|
+
* the fixed silence watchdog is gone. */
|
|
2171
|
+
function _armLostResultBackstop() {
|
|
2172
|
+
const GRACE_MS = 5000;
|
|
2173
|
+
// Capture the pending turn at arm time and re-verify identity at fire time: a stale
|
|
2174
|
+
// backstop from turn A must never settle turn B (which may be parked on the SAME idle
|
|
2175
|
+
// state). The interrupt-grace path owns user-cancelled turns — skip those here.
|
|
2176
|
+
const armedRec = _pendingTurns[0] || null;
|
|
2177
|
+
_lostResultTimer = setTimeout(() => {
|
|
2178
|
+
_lostResultTimer = null;
|
|
2179
|
+
const rec = _pendingTurns[0];
|
|
2180
|
+
if (!rec || rec !== armedRec || rec.interrupted) return; // stale arm / different turn / user-cancelled
|
|
2181
|
+
if (_queryBusy === false) return;
|
|
2182
|
+
if (_lastSessionState !== 'idle') return; // a newer turn started running — not lost
|
|
2183
|
+
console.warn('[sdk-manager] session idle but a turn result never arrived — settling as lost result');
|
|
2184
|
+
// Tombstone the result that may still arrive: without it the late result would
|
|
2185
|
+
// consume the NEXT turn's FIFO record (settling it early and mispairing turn-end).
|
|
2186
|
+
_lateResultTombstones++;
|
|
2187
|
+
_settleAllTurns();
|
|
2188
|
+
const retryable = !!(_sessionId || _launchResume);
|
|
2189
|
+
_lastFailedTurn = {
|
|
2190
|
+
turnId: rec.id || null, retryable, apiStatus: rec.apiRetry?.lastStatus ?? null,
|
|
2191
|
+
attempt: rec.apiRetry?.attempt || 0, maxRetries: rec.apiRetry?.maxRetries || 0,
|
|
2192
|
+
code: 'turn-stuck', ts: Date.now(),
|
|
2193
|
+
};
|
|
2194
|
+
_notifyQueryError(new Error('The turn ended without a result; you can retry it.'), {
|
|
2195
|
+
code: 'turn-stuck', retryable, turnId: rec.id || null,
|
|
2196
|
+
});
|
|
2197
|
+
_maybeDrain();
|
|
2198
|
+
}, GRACE_MS);
|
|
2199
|
+
if (_lostResultTimer.unref) _lostResultTimer.unref();
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function _clearLostResultBackstop() {
|
|
2203
|
+
if (_lostResultTimer) { clearTimeout(_lostResultTimer); _lostResultTimer = null; }
|
|
2204
|
+
}
|
|
2205
|
+
|
|
1857
2206
|
/**
|
|
1858
2207
|
* Interrupt the current turn (user clicked the Stop button) while KEEPING the
|
|
1859
2208
|
* session alive: the resident query is a streaming-input session, so interrupt()
|
|
@@ -1988,6 +2337,35 @@ function _createSession(key, cwd) {
|
|
|
1988
2337
|
_broadcastQueueState();
|
|
1989
2338
|
}
|
|
1990
2339
|
|
|
2340
|
+
/**
|
|
2341
|
+
* Manual [retry] of the last failed turn. The original user message is still in the
|
|
2342
|
+
* session history (the CLI closed the failed turn with a synthetic API-error entry and
|
|
2343
|
+
* stayed alive), so we do NOT resend it — we push a tiny synthetic continue instruction
|
|
2344
|
+
* that makes the model redo the interrupted step with full context. Refuses with a
|
|
2345
|
+
* reason unless there is a retryable recorded failure and the session is idle.
|
|
2346
|
+
*/
|
|
2347
|
+
async function _retryInterruptedTurn(turnId = null) {
|
|
2348
|
+
if (_queryBusy || _dispatchingStarting || _dispatching) return { ok: false, reason: 'busy' };
|
|
2349
|
+
if (_switching) return { ok: false, reason: 'busy' }; // a session switch must not race a retry push
|
|
2350
|
+
if (!_lastFailedTurn) return { ok: false, reason: 'nothing-to-retry' };
|
|
2351
|
+
if (turnId && _lastFailedTurn.turnId && turnId !== _lastFailedTurn.turnId) return { ok: false, reason: 'stale' };
|
|
2352
|
+
if (_lastFailedTurn.retryable === false) return { ok: false, reason: 'not-retryable' };
|
|
2353
|
+
if (!_sessionId && !_launchResume) return { ok: false, reason: 'no-session' };
|
|
2354
|
+
const retryOf = _lastFailedTurn.turnId; // snapshot BEFORE the await (a new turn mutates state)
|
|
2355
|
+
_suppressDrain = false;
|
|
2356
|
+
const { started, promise, turnId: newTurnId } = await _startTurn(RETRY_CONTINUE_PROMPT);
|
|
2357
|
+
if (!started) return { ok: false, reason: 'not-delivered' };
|
|
2358
|
+
// Stamp the lineage on the new turn's record so the result branch's `recovered`
|
|
2359
|
+
// frame can name the FAILED turn (the frontend matches cleanup by retryOf).
|
|
2360
|
+
// Look the new turn up by id (not index) — a fast result could have shifted the FIFO
|
|
2361
|
+
// in the microtask gap, and the retryOf stamp must land on the retry turn itself.
|
|
2362
|
+
const newRec = _pendingTurns.find(r => r.id === newTurnId) || null;
|
|
2363
|
+
if (newRec) newRec.retryOf = retryOf;
|
|
2364
|
+
_broadcast({ type: 'sdk-retry-state', phase: 'recovering', turnId: newTurnId, retryOf, ts: Date.now() });
|
|
2365
|
+
promise.catch((err) => reportSwallowed('sdk-manager.retry-turn', err));
|
|
2366
|
+
return { ok: true, turnId: newTurnId };
|
|
2367
|
+
}
|
|
2368
|
+
|
|
1991
2369
|
/**
|
|
1992
2370
|
* Switch the session to a different session id (web "resume session" entry — SDK-mode
|
|
1993
2371
|
* counterpart of the PTY /resume injection). options.resume only applies at query
|
|
@@ -2015,6 +2393,9 @@ function _createSession(key, cwd) {
|
|
|
2015
2393
|
_lastInitSnapshot = null; // don't replay the OLD session's sdk-init to the new one
|
|
2016
2394
|
_lastSkillsSnapshot = null; // same for the skill surface
|
|
2017
2395
|
_lastSessionState = null; // same for the session-state snapshot — new session starts idle
|
|
2396
|
+
_clearStateHeartbeat();
|
|
2397
|
+
_suspectGeneration = false;
|
|
2398
|
+
_lastFailedTurn = null; // the failure belongs to the outgoing session — never retry it into this one
|
|
2018
2399
|
_clearContextUsage('session-switched');
|
|
2019
2400
|
_clearMcpStatus('session-switched');
|
|
2020
2401
|
_clearQueued(); // broadcasts the emptied queue-state
|
|
@@ -2042,11 +2423,14 @@ function _createSession(key, cwd) {
|
|
|
2042
2423
|
// legit idle). Skip the broadcast when nothing was ever announced.
|
|
2043
2424
|
if (_lastSessionState && _lastSessionState !== 'idle') _announceSessionState('idle');
|
|
2044
2425
|
_lastSessionState = null;
|
|
2426
|
+
_clearStateHeartbeat();
|
|
2045
2427
|
_clearContextUsage('session-switched');
|
|
2046
2428
|
_clearMcpStatus('session-switched');
|
|
2047
2429
|
_pendingTurns = [];
|
|
2048
2430
|
_lateResultTombstones = 0;
|
|
2049
2431
|
_consecutiveDeaths = 0;
|
|
2432
|
+
_suspectGeneration = false;
|
|
2433
|
+
_lastFailedTurn = null;
|
|
2050
2434
|
_clearGraceTimer();
|
|
2051
2435
|
_clearTurnWatchdog();
|
|
2052
2436
|
const hadQueued = _messageQueue.length > 0 || !!_dispatching;
|
|
@@ -2099,7 +2483,7 @@ function _createSession(key, cwd) {
|
|
|
2099
2483
|
getSdkInitSnapshot: () => _lastInitSnapshot,
|
|
2100
2484
|
getSessionStateSnapshot: () => {
|
|
2101
2485
|
if (!_lastSessionState) return null;
|
|
2102
|
-
return { type: 'sdk-session-state', state: _lastSessionState, sessionId: _sessionId || null, cwd: _cwd };
|
|
2486
|
+
return { type: 'sdk-session-state', state: _lastSessionState, sessionId: _sessionId || null, cwd: _cwd, instanceKey, turnId: _currentTurnId(), pendingApprovals: _pendingApprovals.size };
|
|
2103
2487
|
},
|
|
2104
2488
|
getContextUsageSnapshot: () => _lastContextUsage,
|
|
2105
2489
|
getMcpStatusSnapshot: () => _lastMcpStatus,
|
|
@@ -2127,6 +2511,7 @@ function _createSession(key, cwd) {
|
|
|
2127
2511
|
},
|
|
2128
2512
|
switchToSession: _switchToSession,
|
|
2129
2513
|
getSessionId: () => _sessionId,
|
|
2514
|
+
retryInterruptedTurn: _retryInterruptedTurn,
|
|
2130
2515
|
runEphemeralTurn: _runEphemeralTurn,
|
|
2131
2516
|
close: () => {
|
|
2132
2517
|
for (const { id, kind } of _cancelAllApprovals()) {
|
|
@@ -2253,6 +2638,10 @@ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broa
|
|
|
2253
2638
|
if (onQueryError !== undefined) _onQueryError = onQueryError || null;
|
|
2254
2639
|
if (broadcastWs !== undefined) _broadcastWs = broadcastWs;
|
|
2255
2640
|
if (runWaterfallHook !== undefined) _runWaterfallHook = runWaterfallHook || null;
|
|
2641
|
+
// Restore any callback stopSession() cleared from the durable process wiring (cli.js's
|
|
2642
|
+
// registration). A workspace stop→relaunch omits these callbacks, so without this every
|
|
2643
|
+
// sdk-* frame would silently stop broadcasting after returning to the workspace list.
|
|
2644
|
+
_adoptProcessWiring();
|
|
2256
2645
|
// Register this workspace's session (per-cwd, re-using the record on re-init) and make
|
|
2257
2646
|
// it active. init() adopts the launch config and tears down ONLY this session's prior
|
|
2258
2647
|
// generation (tests / hot reload) — other workspaces' sessions are untouched.
|
|
@@ -2546,6 +2935,22 @@ export function getSessionId(anchor) {
|
|
|
2546
2935
|
return sess ? sess.getSessionId() : null;
|
|
2547
2936
|
}
|
|
2548
2937
|
|
|
2938
|
+
/**
|
|
2939
|
+
* Manual [retry] of the last failed turn on the active (or anchored) session. Pushes a
|
|
2940
|
+
* synthetic continue instruction instead of resending the user's original message.
|
|
2941
|
+
* Returns the session's { ok, reason? , turnId? } verdict.
|
|
2942
|
+
*/
|
|
2943
|
+
export async function retryInterruptedTurn(turnId, anchor) {
|
|
2944
|
+
if (!_query) return { ok: false, reason: 'unavailable' };
|
|
2945
|
+
const sess = anchor ? _sessionForAnchor(anchor) : _active();
|
|
2946
|
+
if (!sess) return { ok: false, reason: 'not-found' };
|
|
2947
|
+
const r = await sess.retryInterruptedTurn(turnId);
|
|
2948
|
+
// Only a SUCCESSFUL retry re-routes the process-wide active session — a rejected retry
|
|
2949
|
+
// (busy/stale/no-session) must not steal the active anchor from the foreground view.
|
|
2950
|
+
if (r && r.ok && anchor && sess.key !== _activeKey) _activeKey = sess.key;
|
|
2951
|
+
return r;
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2549
2954
|
/**
|
|
2550
2955
|
* The last sdk-init snapshot for WS reconnect replay. No anchor → active session;
|
|
2551
2956
|
* with an anchor → that session. Returns null if no init has been announced yet
|