cc-viewer 1.9.17 → 1.9.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +51 -1
- package/dist/assets/{App-BlNbziAQ.js → App-5GFhfhAM.js} +2 -2
- package/dist/assets/{MdxEditorPanel-C9cr-WBH.js → MdxEditorPanel-C71H-8ZP.js} +1 -1
- package/dist/assets/{Mobile-CDQPYYQo.js → Mobile-CAqle3TP.js} +1 -1
- package/dist/assets/{ProxyStatsModal-BEqrTR-F.js → ProxyStatsModal-B0ySgEsq.js} +1 -1
- package/dist/assets/{index-B4J-uwYG.js → index-C4_-ImaE.js} +2 -2
- package/dist/assets/{seqResourceLoaders-B-5xGs9q.css → seqResourceLoaders-C8O3LxzJ.css} +1 -1
- package/dist/assets/seqResourceLoaders-CXYXv_fq.js +2 -0
- package/dist/index.html +1 -1
- package/dist/voice-packs/default/agentLoopStart.wav +0 -0
- package/dist/voice-packs/default/pack.json +11 -0
- package/dist/voice-packs/sanguo/agentLoopStart.wav +0 -0
- package/dist/voice-packs/sanguo/pack.json +11 -0
- package/node_modules/@ccv/core/src/voice-pack-events.js +12 -3
- package/package.json +2 -2
- package/server/lib/attach-buffer.js +164 -0
- package/server/lib/plugin-loader.js +36 -2
- package/server/lib/plugin-runtime.js +12 -2
- package/server/lib/sdk-manager.js +727 -79
- package/server/lib/v2/live-feed.js +108 -7
- package/server/lib/v2/meta-rows.js +35 -4
- package/server/proxy.js +52 -8
- package/server/routes/events.js +64 -1
- package/server/routes/files-content.js +24 -1
- package/server/server.js +131 -11
- package/dist/assets/seqResourceLoaders-DaZ8fZXu.js +0 -2
|
@@ -162,12 +162,39 @@ 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;
|
|
192
|
+
// Message-level activity coalescing: every non-boundary SDK message (assistant/user/
|
|
193
|
+
// tool_progress/system task_*/status/api_retry/compact) refreshes turn liveness, but
|
|
194
|
+
// rebroadcasting per message would flood the terminal WS on busy turns. Activity frames
|
|
195
|
+
// reuse the sdk-session-state shape (heartbeat+activity flags) so clients need no new
|
|
196
|
+
// handler, and are capped at one per ACTIVITY_MIN_INTERVAL_MS per turn.
|
|
197
|
+
const ACTIVITY_MIN_INTERVAL_MS = 1000;
|
|
171
198
|
// getContextUsage control requests have no SDK-side timeout — bound the wait so a live-but-
|
|
172
199
|
// unresponsive child can't wedge the in-flight gate forever.
|
|
173
200
|
const CTX_USAGE_TIMEOUT_MS = 10 * 1000;
|
|
@@ -184,6 +211,35 @@ let _onTurnEnd = null; // SDK mode has no Stop hook (ensureHooks() skipped) —
|
|
|
184
211
|
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
212
|
let _broadcastWs = null;
|
|
186
213
|
let _runWaterfallHook = null;
|
|
214
|
+
// Authoritative per-turn loop boundaries (voice/plugin/SSE agent_loop_*). Unlike the
|
|
215
|
+
// debounced turn_end, these fire exactly once per accepted turn at push time (start) and
|
|
216
|
+
// at the turn's real settlement (end) — see _emitAgentLoopStart/_emitAgentLoopEnd.
|
|
217
|
+
let _onAgentLoopStart = null;
|
|
218
|
+
let _onAgentLoopEnd = null;
|
|
219
|
+
// Durable process-level wiring registered ONCE by cli.js. Unlike the mutable callbacks
|
|
220
|
+
// above (which stopSession clears), this survives a workspace stop→relaunch so a launch
|
|
221
|
+
// route that omits the callbacks still restores them (see _adoptProcessWiring). Tests
|
|
222
|
+
// never register it, so stopSession's "clear the wiring" contract for tests is unchanged.
|
|
223
|
+
let _processWiring = null;
|
|
224
|
+
export function setSdkProcessWiring(w) {
|
|
225
|
+
_processWiring = (w == null) ? null : {
|
|
226
|
+
onTurnEnd: w.onTurnEnd || null,
|
|
227
|
+
onQueryError: w.onQueryError || null,
|
|
228
|
+
broadcastWs: w.broadcastWs || null,
|
|
229
|
+
runWaterfallHook: w.runWaterfallHook || null,
|
|
230
|
+
onAgentLoopStart: w.onAgentLoopStart || null,
|
|
231
|
+
onAgentLoopEnd: w.onAgentLoopEnd || null,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function _adoptProcessWiring() {
|
|
235
|
+
if (!_processWiring) return;
|
|
236
|
+
if (!_onTurnEnd) _onTurnEnd = _processWiring.onTurnEnd;
|
|
237
|
+
if (!_onQueryError) _onQueryError = _processWiring.onQueryError;
|
|
238
|
+
if (!_broadcastWs) _broadcastWs = _processWiring.broadcastWs;
|
|
239
|
+
if (!_runWaterfallHook) _runWaterfallHook = _processWiring.runWaterfallHook;
|
|
240
|
+
if (!_onAgentLoopStart) _onAgentLoopStart = _processWiring.onAgentLoopStart;
|
|
241
|
+
if (!_onAgentLoopEnd) _onAgentLoopEnd = _processWiring.onAgentLoopEnd;
|
|
242
|
+
}
|
|
187
243
|
|
|
188
244
|
// Process-level SDK LAUNCH-CONFIG template (2026-10-12). cli.js registers it once at boot
|
|
189
245
|
// (the env carries the loopback-proxy URL + the per-cwd header is added per session). The
|
|
@@ -360,10 +416,41 @@ function _createSession(key, cwd) {
|
|
|
360
416
|
// CLI-side turn may still be running; without the sticky flag a
|
|
361
417
|
// second Stop would silently no-op).
|
|
362
418
|
let _settleGraceTimer = null; // backstop after interrupt: force-settle if no result arrives
|
|
363
|
-
let
|
|
419
|
+
let _lostResultTimer = null; // lost-result backstop handle (cleared on turn settle/teardown)
|
|
420
|
+
// Generation guard for late results (replaces the old _lateResultTombstones counter).
|
|
421
|
+
// Result frames carry no turn id, so a force-settled turn's late result is
|
|
422
|
+
// indistinguishable from the NEXT turn's result on arrival order alone. The counter
|
|
423
|
+
// solved this by counting "results still owed", but it only reset on teardown/reset —
|
|
424
|
+
// so when the owed result NEVER arrived (the lost-result case), the count outlived its
|
|
425
|
+
// turn and swallowed the NEXT turn's result (no shift, no turn_end → that turn then
|
|
426
|
+
// force-settled as turn-stuck too). The guard instead binds the debt to the force-settled
|
|
427
|
+
// turn's epoch: a result is discarded only while it provably belongs to the dead
|
|
428
|
+
// generation. Once a NEWER turn is in flight, its own result pairs normally — the stale
|
|
429
|
+
// debt is dropped (the old result may never come, and must not tax the live turn).
|
|
430
|
+
let _turnEpoch = 0; // advanced per _beginTurn; rec.epoch snapshots it
|
|
431
|
+
let _lateResultEpoch = 0; // epoch of the force-settled turn awaiting a late result (0 = none)
|
|
364
432
|
let _consecutiveDeaths = 0; // backstop against respawn storms in a broken environment
|
|
365
|
-
|
|
366
|
-
|
|
433
|
+
// Activity probe (replaces the old fixed 25min silence watchdog): re-armed on every
|
|
434
|
+
// incoming SDK message. When a turn stays silent past ACTIVITY_PROBE_IDLE_MS it pings
|
|
435
|
+
// the CLI's control plane (getContextUsage — a REAL round-trip; the init-cached
|
|
436
|
+
// supportedCommands/initializationResult would resolve even against a wedged child).
|
|
437
|
+
// Only consecutive unanswered probes mean the child is genuinely wedged (a legal
|
|
438
|
+
// multi-hour tool turn keeps its event loop responsive and answers). This never kills
|
|
439
|
+
// a long turn for being merely quiet.
|
|
440
|
+
let _activityProbe = null;
|
|
441
|
+
let _probeFailures = 0;
|
|
442
|
+
let _probeEpoch = 0; // bumped on any message/interrupt → invalidates an in-flight probe
|
|
443
|
+
// Monotonic inbound-message counter, bumped ONCE per received SDK message (not by
|
|
444
|
+
// watchdog arms/clears like _probeEpoch). The lost-result backstop snapshots it at
|
|
445
|
+
// arm time: any message arriving afterwards proves the head turn is producing work,
|
|
446
|
+
// so a mis-armed conviction (uncorrelatable-idle hazard) disarms instead of firing.
|
|
447
|
+
let _inboundMsgSeq = 0;
|
|
448
|
+
// Suspect generation: set when an interrupt grace expires with no result (the CLI never
|
|
449
|
+
// answered our Stop) or a probe convicts the child. The wedged query is torn down LAZILY
|
|
450
|
+
// — on the next push rather than at conviction time — so we never SIGTERM a child that
|
|
451
|
+
// may just be slow, and the rebuild resumes _sessionId for continuity.
|
|
452
|
+
let _suspectGeneration = false;
|
|
453
|
+
let _lastFailedTurn = null; // { turnId, retryable, apiStatus, attempt, maxRetries, code, ts } — drives the manual [retry] affordance
|
|
367
454
|
let _sessionId = null;
|
|
368
455
|
let _initAnnouncedSid = ''; // sessionId whose slash-command surface was already announced (reset on full reset)
|
|
369
456
|
let _lastInitSnapshot = null; // last sdk-init payload for WS reconnect replay
|
|
@@ -380,6 +467,13 @@ function _createSession(key, cwd) {
|
|
|
380
467
|
// injected by cli.js) > push-running / result-idle fallbacks below. Retained for WS
|
|
381
468
|
// reconnect replay like _lastInitSnapshot; reset alongside it on switch/reset.
|
|
382
469
|
let _lastSessionState = null;
|
|
470
|
+
let _stateHeartbeat = null; // setInterval re-broadcasting sdk-session-state while a turn is active
|
|
471
|
+
// Message-activity coalescer state (see ACTIVITY_MIN_INTERVAL_MS): pending throttled
|
|
472
|
+
// activity frame plus the wall-clock turn stamp used as the wire-level turnStartedAt.
|
|
473
|
+
let _lastActivityAt = 0;
|
|
474
|
+
let _activityPending = null; // { kind, rec }
|
|
475
|
+
let _activityTimer = null;
|
|
476
|
+
let _lastTurnStartedAt = 0; // wire turnStartedAt for frames emitted with no head turn (idle settles)
|
|
383
477
|
let _lastContextUsage = null; // last sdk-context-usage frame for WS reconnect replay
|
|
384
478
|
let _ctxUsageFetching = false; // one getContextUsage control request in flight
|
|
385
479
|
let _ctxUsageQueued = false; // a turn-end/compact arrived while in flight → rerun once (coalesce)
|
|
@@ -450,11 +544,20 @@ function _createSession(key, cwd) {
|
|
|
450
544
|
// (an interrupted turn still synthesizes an error_during_execution result). All
|
|
451
545
|
// settle paths (result / grace force-settle / teardown settleAll) go through
|
|
452
546
|
// _settleTurn, which is idempotent via rec.settled.
|
|
547
|
+
let _turnSeq = 0;
|
|
453
548
|
function _beginTurn() {
|
|
454
549
|
let resolve;
|
|
455
550
|
const promise = new Promise((r) => { resolve = r; });
|
|
456
|
-
const rec = {
|
|
551
|
+
const rec = {
|
|
552
|
+
id: 'turn_' + (++_turnSeq).toString(36) + '_' + Math.random().toString(36).slice(2, 8),
|
|
553
|
+
resolve, interrupted: false, settled: false,
|
|
554
|
+
epoch: ++_turnEpoch, // generation this turn belongs to — the late-result guard keys off it
|
|
555
|
+
startedAt: Date.now(), // wall-clock turn stamp — the wire-level turnStartedAt (restart-safe, unlike the counter epoch)
|
|
556
|
+
loopStarted: false, loopEnded: false, // idempotence for the agent_loop boundary callbacks
|
|
557
|
+
apiRetry: { count: 0, attempt: 0, maxRetries: 0, lastStatus: null, lastError: null },
|
|
558
|
+
};
|
|
457
559
|
_pendingTurns.push(rec);
|
|
560
|
+
_lastTurnStartedAt = rec.startedAt;
|
|
458
561
|
return { rec, promise };
|
|
459
562
|
}
|
|
460
563
|
|
|
@@ -471,38 +574,157 @@ function _createSession(key, cwd) {
|
|
|
471
574
|
_queryBusy = false;
|
|
472
575
|
_turnInFlight = false;
|
|
473
576
|
_clearTurnWatchdog();
|
|
577
|
+
_clearLostResultBackstop();
|
|
474
578
|
}
|
|
475
579
|
|
|
476
580
|
function _clearGraceTimer() {
|
|
477
581
|
if (_settleGraceTimer) { clearTimeout(_settleGraceTimer); _settleGraceTimer = null; }
|
|
478
582
|
}
|
|
479
583
|
|
|
480
|
-
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
584
|
+
/** Activity probe (replaces the old fixed 25min silence watchdog). Re-armed on every
|
|
585
|
+
* incoming SDK message, so it only fires after ACTIVITY_PROBE_IDLE_MS of total silence.
|
|
586
|
+
* Instead of force-killing the turn for being quiet (which would murder a legal
|
|
587
|
+
* multi-hour tool run), it pings the CLI's control plane: a live child answers, a wedged
|
|
588
|
+
* one does not. Only ACTIVITY_PROBE_MAX_FAILURES consecutive unanswered probes convict
|
|
589
|
+
* the child and trigger recovery. Pending approvals also prove liveness (the child is
|
|
590
|
+
* parked awaiting our control response), so probes are not even sent then.
|
|
591
|
+
*
|
|
592
|
+
* `_probeFailures` deliberately survives re-arms: _clearTurnWatchdog only cancels the
|
|
593
|
+
* pending timer, and the counter is reset by a successful/answered probe (or a full
|
|
594
|
+
* teardown/reset) — otherwise conviction at MAX_FAILURES would be unreachable. */
|
|
486
595
|
function _armTurnWatchdog() {
|
|
596
|
+
// NOTE: cancel + bump the epoch FIRST (any arm is triggered by observed activity
|
|
597
|
+
// and must invalidate an in-flight probe), then decide whether a timer is needed.
|
|
487
598
|
_clearTurnWatchdog();
|
|
488
599
|
if (_pendingTurns.length === 0) return;
|
|
489
|
-
|
|
490
|
-
|
|
600
|
+
if (_pendingApprovals.size > 0) {
|
|
601
|
+
// Approval pending = the child is parked awaiting our control response, which
|
|
602
|
+
// itself proves liveness — do not even probe. No timer is armed here; probing
|
|
603
|
+
// resumes via _rearmWatchdogAfterApprovalSettled when the last approval settles.
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
_activityProbe = setTimeout(() => {
|
|
607
|
+
_activityProbe = null;
|
|
491
608
|
if (_pendingTurns.length === 0) return;
|
|
492
|
-
if (_pendingApprovals.size > 0) {
|
|
493
|
-
|
|
609
|
+
if (_pendingApprovals.size > 0) { _armTurnWatchdog(); return; } // approval parked mid-wait — stand down
|
|
610
|
+
_runActivityProbe();
|
|
611
|
+
}, _probeFailures === 0 ? ACTIVITY_PROBE_IDLE_MS : ACTIVITY_PROBE_INTERVAL_MS);
|
|
612
|
+
if (_activityProbe.unref) _activityProbe.unref();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Re-arm the activity probe after the LAST pending approval settles (answered,
|
|
616
|
+
* cancelled, or timed out). _armTurnWatchdog deliberately arms NO timer while an
|
|
617
|
+
* approval is parked (the park itself proves liveness), so without this hook a
|
|
618
|
+
* child that wedges AFTER the approval clears would never be probed again — the
|
|
619
|
+
* watchdog would be permanently disarmed for the rest of the turn. Only re-arm
|
|
620
|
+
* when the approval map just emptied; a still-pending sibling approval keeps the
|
|
621
|
+
* park (and its liveness guarantee) intact. */
|
|
622
|
+
function _rearmWatchdogAfterApprovalSettled() {
|
|
623
|
+
if (_pendingTurns.length === 0) return;
|
|
624
|
+
if (_pendingApprovals.size > 0) return;
|
|
625
|
+
_armTurnWatchdog();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function _clearTurnWatchdog() {
|
|
629
|
+
if (_activityProbe) { clearTimeout(_activityProbe); _activityProbe = null; }
|
|
630
|
+
// Any arm/clear is triggered by observed activity (a message re-arms on every frame,
|
|
631
|
+
// a settle clears) — invalidate an in-flight probe and reset consecutive-failure
|
|
632
|
+
// counting so a slow probe on the PREVIOUS quiet stretch cannot convict a live turn.
|
|
633
|
+
_probeEpoch++;
|
|
634
|
+
if (_pendingTurns.length === 0) _probeFailures = 0;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Ping the child's control plane once with a REAL control round-trip (getContextUsage;
|
|
638
|
+
* supportedCommands/initializationResult resolve from the cached init promise and prove
|
|
639
|
+
* nothing). An answer — resolve OR reject — means the event loop is responsive: reset
|
|
640
|
+
* the failure count and keep waiting silently. No answer within the timeout counts one
|
|
641
|
+
* failure; ACTIVITY_PROBE_MAX_FAILURES consecutive failures convict the child as wedged.
|
|
642
|
+
* Both outcomes re-check the generation + FIFO: a probe started on query A must never
|
|
643
|
+
* convict generation B, and a turn that completed mid-probe must not be "recovered".
|
|
644
|
+
* A probe that never resolves leaks one in-flight control request per conviction —
|
|
645
|
+
* bounded, and the pending reject on teardown (Query.cleanup) is swallowed by `done`. */
|
|
646
|
+
function _runActivityProbe() {
|
|
647
|
+
const q = _activeQuery;
|
|
648
|
+
if (!q || typeof q.getContextUsage !== 'function') { _armTurnWatchdog(); return; }
|
|
649
|
+
// Bind the verdict to the TURN probed AND the probe epoch: any message/interrupt
|
|
650
|
+
// observed since this probe started proves the child is alive, so its timeout must be
|
|
651
|
+
// discarded rather than charged. (a new turn as head also invalidates the verdict.)
|
|
652
|
+
const probeRec = _pendingTurns[0] || null;
|
|
653
|
+
const epoch = _probeEpoch;
|
|
654
|
+
let timer = null;
|
|
655
|
+
let done = false;
|
|
656
|
+
// NOTE: own .then/.catch handlers instead of Promise.race — under node:test mocked
|
|
657
|
+
// timers a Promise.race([neverSettlingPromise, mockedTimerPromise]) never runs its
|
|
658
|
+
// reactions (race subscribes to the pending promise first; the mocked timer's later
|
|
659
|
+
// settle does not flush the race's capability). Plain handlers are immune.
|
|
660
|
+
timer = setTimeout(() => {
|
|
661
|
+
if (done) return;
|
|
662
|
+
done = true;
|
|
663
|
+
timer = null;
|
|
664
|
+
finish('timeout');
|
|
665
|
+
}, CTX_USAGE_TIMEOUT_MS);
|
|
666
|
+
if (timer.unref) timer.unref();
|
|
667
|
+
let probeCall;
|
|
668
|
+
try { probeCall = q.getContextUsage(); }
|
|
669
|
+
catch { finish('answered'); return; } // a sync throw still proves the event loop runs
|
|
670
|
+
if (!probeCall || typeof probeCall.then !== 'function') { finish('answered'); return; } // non-Promise impl = responsive
|
|
671
|
+
probeCall.then(
|
|
672
|
+
() => { if (done) return; done = true; if (timer) { clearTimeout(timer); timer = null; } finish('answered'); },
|
|
673
|
+
() => { if (done) return; done = true; if (timer) { clearTimeout(timer); timer = null; } finish('answered'); },
|
|
674
|
+
);
|
|
675
|
+
function finish(outcome) {
|
|
676
|
+
// Stale generation / settled-or-switched turn / activity since the probe started /
|
|
677
|
+
// a user interrupt / a parked approval: the probe's verdict no longer applies.
|
|
678
|
+
if (_activeQuery !== q || _pendingTurns.length === 0 || _pendingTurns[0] !== probeRec) return;
|
|
679
|
+
if (epoch !== _probeEpoch) return; // activity observed mid-probe — the child is alive
|
|
680
|
+
if (probeRec && probeRec.interrupted) return;
|
|
681
|
+
if (_pendingApprovals.size > 0) return;
|
|
682
|
+
if (outcome === 'timeout') {
|
|
683
|
+
_probeFailures += 1;
|
|
684
|
+
if (_probeFailures < ACTIVITY_PROBE_MAX_FAILURES) { _armTurnWatchdog(); return; }
|
|
685
|
+
console.warn('[sdk-manager] turn unresponsive to control probes — recovering the wedged query');
|
|
686
|
+
_recoverFromStuckTurn(_pendingTurns[0] || null);
|
|
494
687
|
return;
|
|
495
688
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}, TURN_WATCHDOG_MS);
|
|
501
|
-
if (_turnWatchdog.unref) _turnWatchdog.unref();
|
|
689
|
+
// resolve OR reject both prove the control plane (event loop) is responsive.
|
|
690
|
+
_probeFailures = 0;
|
|
691
|
+
_armTurnWatchdog(); // still no turn output — schedule the next idle probe
|
|
692
|
+
}
|
|
502
693
|
}
|
|
503
694
|
|
|
504
|
-
|
|
505
|
-
|
|
695
|
+
/** Recover from a genuinely wedged query (proven by control probes, or an interrupt the
|
|
696
|
+
* CLI never answered). Settles turn promises, announces idle, records a retryable failure
|
|
697
|
+
* so the UI can offer a manual [retry], and marks the generation suspect. The teardown
|
|
698
|
+
* itself is LAZY — deferred to the next push (_startTurn consults _suspectGeneration) —
|
|
699
|
+
* so we never SIGTERM a merely-slow child mid-side-effect; the rebuild resumes
|
|
700
|
+
* _sessionId for continuity. */
|
|
701
|
+
function _recoverFromStuckTurn(stalledRec) {
|
|
702
|
+
_suspectGeneration = true;
|
|
703
|
+
// Cancel any parked approvals: they belong to the (now suspect) generation's canUseTool,
|
|
704
|
+
// and leaving them would replay a ghost modal on attach AND suppress probing on the
|
|
705
|
+
// rebuilt generation (size > 0 = "alive"). They time out / deny as their own path.
|
|
706
|
+
_cancelAllApprovals();
|
|
707
|
+
// Mark the result that a merely-slow child may still emit before the lazy teardown —
|
|
708
|
+
// otherwise it would fire a spurious turn_end for an already-failed turn. Bound to the
|
|
709
|
+
// head turn's epoch so it cannot tax a later turn if the result never arrives.
|
|
710
|
+
if (_pendingTurns[0]) _lateResultEpoch = _pendingTurns[0].epoch;
|
|
711
|
+
const stuckRecs = [..._pendingTurns];
|
|
712
|
+
_settleAllTurns();
|
|
713
|
+
for (const r of stuckRecs) _emitAgentLoopEnd(r, 'turn-stuck');
|
|
714
|
+
_announceSessionState('idle', stalledRec || null);
|
|
715
|
+
_clearActivity();
|
|
716
|
+
// Retry is only possible if a session id was captured to resume — a first turn that
|
|
717
|
+
// wedges before any init frame has nothing to continue, so don't offer [retry].
|
|
718
|
+
const retryable = !!(_sessionId || _launchResume);
|
|
719
|
+
_lastFailedTurn = {
|
|
720
|
+
turnId: stalledRec?.id || null, retryable, apiStatus: null,
|
|
721
|
+
attempt: stalledRec?.apiRetry?.attempt || 0, maxRetries: stalledRec?.apiRetry?.maxRetries || 0,
|
|
722
|
+
code: 'turn-stuck', ts: Date.now(),
|
|
723
|
+
};
|
|
724
|
+
_notifyQueryError(new Error('The turn became unresponsive; the SDK session will restart on the next message.'), {
|
|
725
|
+
code: 'turn-stuck', retryable, turnId: stalledRec?.id || null,
|
|
726
|
+
});
|
|
727
|
+
_maybeDrain(); // a queued backlog must not stall behind the recovered turn
|
|
506
728
|
}
|
|
507
729
|
|
|
508
730
|
/**
|
|
@@ -536,7 +758,18 @@ function _createSession(key, cwd) {
|
|
|
536
758
|
// A fresh user-initiated turn re-arms automatic draining of parked (Stop-kept) items.
|
|
537
759
|
_suppressDrain = false;
|
|
538
760
|
_consecutiveDeaths = 0; // explicit user action re-arms the death-drain backstop
|
|
539
|
-
const { promise } = await _startTurn(text);
|
|
761
|
+
const { started, promise, notified } = await _startTurn(text);
|
|
762
|
+
// A pre-push delivery failure (teardown raced / query closed) would otherwise leave
|
|
763
|
+
// the user with a cleared composer and no signal. _startTurn already settled the turn
|
|
764
|
+
// promise (so `promise` won't hang); reject HERE with a code so server.js can route a
|
|
765
|
+
// directed sdk-send-failed to the initiator. Construction failures are excluded
|
|
766
|
+
// (notified:true) — they already fired _notifyQueryError (a second toast would
|
|
767
|
+
// double-report).
|
|
768
|
+
if (!started && !notified) {
|
|
769
|
+
const err = new Error('message not delivered to the SDK session');
|
|
770
|
+
err.code = 'not-delivered';
|
|
771
|
+
throw err;
|
|
772
|
+
}
|
|
540
773
|
return promise;
|
|
541
774
|
}
|
|
542
775
|
|
|
@@ -569,6 +802,16 @@ function _createSession(key, cwd) {
|
|
|
569
802
|
* rejects; a later turn failure must NOT put an already-pushed message back.
|
|
570
803
|
*/
|
|
571
804
|
async function _startTurn(text, dispatch = null) {
|
|
805
|
+
// A convicted/suspect generation is torn down HERE — lazily, just before the next
|
|
806
|
+
// push — rather than at conviction time. Deferring avoids SIGTERM-ing a merely-slow
|
|
807
|
+
// child mid-side-effect; rebuilding now lets _ensureResidentQuery resume _sessionId.
|
|
808
|
+
// This MUST run before _beginTurn(): teardown settles every pending turn record, so a
|
|
809
|
+
// record created first would be settled (and its message silently dropped) by its own
|
|
810
|
+
// teardown, leaving _queryBusy wedged with an empty FIFO.
|
|
811
|
+
if (_suspectGeneration) {
|
|
812
|
+
_suspectGeneration = false;
|
|
813
|
+
_teardownResident();
|
|
814
|
+
}
|
|
572
815
|
const { rec, promise } = _beginTurn();
|
|
573
816
|
if (dispatch) rec.dispatchItem = dispatch;
|
|
574
817
|
_queryBusy = true;
|
|
@@ -580,7 +823,9 @@ function _createSession(key, cwd) {
|
|
|
580
823
|
_settleTurn(rec);
|
|
581
824
|
_queryBusy = false;
|
|
582
825
|
_notifyQueryError(err);
|
|
583
|
-
|
|
826
|
+
// notified:true tells _sendUserMessage NOT to also reject not-delivered — the user
|
|
827
|
+
// already got the query-error toast; a second rejection would double-report.
|
|
828
|
+
return { started: false, promise, turnId: rec.id, notified: true };
|
|
584
829
|
}
|
|
585
830
|
// Teardown/stop raced with construction — the settle already happened; don't push.
|
|
586
831
|
if (rec.settled || (dispatch && _dispatching !== dispatch)) {
|
|
@@ -589,13 +834,13 @@ function _createSession(key, cwd) {
|
|
|
589
834
|
_settleTurn(rec);
|
|
590
835
|
if (_dispatching !== dispatch) _queryBusy = false;
|
|
591
836
|
}
|
|
592
|
-
return { started: false, promise };
|
|
837
|
+
return { started: false, promise, turnId: rec.id };
|
|
593
838
|
}
|
|
594
839
|
if (!_activeQuery || !_inputQueue || (typeof _inputQueue.isClosed === 'function' && _inputQueue.isClosed()) || (dispatch && _dispatching !== dispatch)) {
|
|
595
840
|
_pendingTurns = _pendingTurns.filter((turn) => turn !== rec);
|
|
596
841
|
_settleTurn(rec);
|
|
597
842
|
_queryBusy = false;
|
|
598
|
-
return { started: false, promise };
|
|
843
|
+
return { started: false, promise, turnId: rec.id };
|
|
599
844
|
}
|
|
600
845
|
// Typed /clear is detected at EXECUTION time (never at enqueue time): a clear queued
|
|
601
846
|
// behind a busy turn must not wipe the running turn's usage panel. Invalidate the
|
|
@@ -613,16 +858,19 @@ function _createSession(key, cwd) {
|
|
|
613
858
|
_settleTurn(rec);
|
|
614
859
|
_queryBusy = false;
|
|
615
860
|
reportSwallowed('sdk-manager.queue-push', err);
|
|
616
|
-
return { started: false, promise };
|
|
861
|
+
return { started: false, promise, turnId: rec.id };
|
|
617
862
|
}
|
|
618
863
|
_turnInFlight = true;
|
|
619
864
|
// Turn-level loading fallback: light the spinner the moment the turn is pushed.
|
|
620
865
|
// The CLI's own session_state_changed(running) arrives later and is idempotent
|
|
621
866
|
// against this — on CLIs that never emit it (no env gate / older build) this is
|
|
622
867
|
// the only "on" signal, paired with the result-idle broadcast in _processMessage.
|
|
623
|
-
_announceSessionState('running');
|
|
868
|
+
_announceSessionState('running', rec);
|
|
869
|
+
// Authoritative loop start (voice/plugin/SSE agent_loop_start) — fires once, only
|
|
870
|
+
// after the push is accepted (queued/undelivered messages never ring).
|
|
871
|
+
_emitAgentLoopStart(rec);
|
|
624
872
|
_armTurnWatchdog();
|
|
625
|
-
return { started: true, promise };
|
|
873
|
+
return { started: true, promise, turnId: rec.id };
|
|
626
874
|
}
|
|
627
875
|
|
|
628
876
|
/**
|
|
@@ -883,6 +1131,10 @@ function _createSession(key, cwd) {
|
|
|
883
1131
|
async function _drainResidentIterator(query, inputQueue) {
|
|
884
1132
|
try {
|
|
885
1133
|
for await (const msg of query) {
|
|
1134
|
+
// Generation guard: after a teardown/switch, a stale generation's late frames must
|
|
1135
|
+
// NOT be processed — they belong to the dead child and would mis-settle the NEW
|
|
1136
|
+
// generation's pending turns. Bail out and let _onResidentDeath dismiss it as stale.
|
|
1137
|
+
if (_activeQuery !== query) break;
|
|
886
1138
|
// A message-processing failure (e.g. a broadcast callback throwing) must NOT be
|
|
887
1139
|
// mistaken for session death — the child is still alive; only the iterator's
|
|
888
1140
|
// end/throw is a death signal.
|
|
@@ -891,7 +1143,13 @@ function _createSession(key, cwd) {
|
|
|
891
1143
|
} catch (msgErr) {
|
|
892
1144
|
reportSwallowed('sdk-manager.process-message', msgErr);
|
|
893
1145
|
}
|
|
894
|
-
|
|
1146
|
+
// Any message proves the child is alive: reset consecutive-failure counting too,
|
|
1147
|
+
// so a probe that timed out on the PREVIOUS quiet stretch cannot convict a turn
|
|
1148
|
+
// that has since demonstrably produced output.
|
|
1149
|
+
_probeFailures = 0;
|
|
1150
|
+
_inboundMsgSeq++;
|
|
1151
|
+
_armTurnWatchdog(); // reset the activity probe on observed activity
|
|
1152
|
+
_noteActivity(msg); // coalesced UI liveness (≤1/s sdk-session-state activity frame)
|
|
895
1153
|
}
|
|
896
1154
|
_onResidentDeath(query, inputQueue, null);
|
|
897
1155
|
} catch (err) {
|
|
@@ -909,6 +1167,10 @@ function _createSession(key, cwd) {
|
|
|
909
1167
|
if (_activeQuery !== query) return; // stale generation — teardown already moved on
|
|
910
1168
|
_activeQuery = null;
|
|
911
1169
|
_inputQueue = null;
|
|
1170
|
+
// The (possibly wedged) child exited on its own — the suspicion dies with it, so the
|
|
1171
|
+
// next push must not run a pointless teardown against an already-dead generation.
|
|
1172
|
+
_suspectGeneration = false;
|
|
1173
|
+
_probeFailures = 0;
|
|
912
1174
|
if (_ctxUsageClearPending) {
|
|
913
1175
|
// A child that exits while applying /clear must not resume the cleared session on the
|
|
914
1176
|
// next message. Start fresh, allow the next init/read, and invalidate older controls.
|
|
@@ -919,13 +1181,38 @@ function _createSession(key, cwd) {
|
|
|
919
1181
|
}
|
|
920
1182
|
_clearGraceTimer();
|
|
921
1183
|
_clearTurnWatchdog();
|
|
922
|
-
|
|
1184
|
+
_clearStateHeartbeat();
|
|
1185
|
+
_clearActivity();
|
|
1186
|
+
// Clear any stale late-result debt: the dead generation's frames are filtered by the
|
|
1187
|
+
// _activeQuery guard, so the owed result can never arrive — leaving the debt would
|
|
1188
|
+
// suppress the next generation's api_retry frames (and its retry affordance).
|
|
1189
|
+
_lateResultEpoch = 0;
|
|
1190
|
+
const hadTurns = _pendingTurns.length > 0;
|
|
1191
|
+
const headTurn = _pendingTurns[0] || null;
|
|
923
1192
|
// Session died mid-turn: no result will arrive, so without this the spinner would
|
|
924
|
-
// hang
|
|
925
|
-
|
|
1193
|
+
// hang forever. The turn is unambiguously over. `headTurn` stamps the idle with the
|
|
1194
|
+
// dead turn's id, and every pending turn reports its loop end (session-ended).
|
|
1195
|
+
_announceSessionState('idle', headTurn);
|
|
1196
|
+
// Only a turn that actually STARTED reports an end — a record that never reached the
|
|
1197
|
+
// push (construction raced with this death) must not emit an end with no matching start.
|
|
1198
|
+
if (hadTurns) { for (const rec of _pendingTurns) { if (rec.loopStarted) _emitAgentLoopEnd(rec, 'session-ended'); } }
|
|
926
1199
|
if (err && err.name !== 'AbortError') {
|
|
927
1200
|
console.error('[SDK] Resident query error:', err.message);
|
|
928
1201
|
_notifyQueryError(err);
|
|
1202
|
+
} else if (!err && hadTurns) {
|
|
1203
|
+
// Clean exit with a turn still in flight is itself a failure — the user would
|
|
1204
|
+
// otherwise see only "idle" with no error. If the CLI exhausted API retries
|
|
1205
|
+
// before dying, keep it retryable so the manual [retry] affordance appears.
|
|
1206
|
+
const retried = (headTurn?.apiRetry?.count || 0) > 0;
|
|
1207
|
+
if (retried) {
|
|
1208
|
+
const status = headTurn.apiRetry.lastStatus;
|
|
1209
|
+
_lastFailedTurn = {
|
|
1210
|
+
turnId: headTurn.id, retryable: (status == null || RETRYABLE_STATUS.has(status)) && !!(_sessionId || _launchResume),
|
|
1211
|
+
apiStatus: status, attempt: headTurn.apiRetry.attempt, maxRetries: headTurn.apiRetry.maxRetries,
|
|
1212
|
+
code: 'session-ended', ts: Date.now(),
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
_notifyQueryError(new Error('Agent SDK session ended before the turn completed'), { code: 'session-ended', retryable: retried && !!(_sessionId || _launchResume), turnId: headTurn?.id || null });
|
|
929
1216
|
}
|
|
930
1217
|
_settleAllTurns();
|
|
931
1218
|
// A dispatching item waiting on an interrupt unwind never reached the SDK. Death
|
|
@@ -974,7 +1261,21 @@ function _createSession(key, cwd) {
|
|
|
974
1261
|
_initSkills = null; // re-captured from the next init frame
|
|
975
1262
|
_clearGraceTimer();
|
|
976
1263
|
_clearTurnWatchdog();
|
|
977
|
-
|
|
1264
|
+
_probeFailures = 0;
|
|
1265
|
+
_clearStateHeartbeat();
|
|
1266
|
+
_clearActivity();
|
|
1267
|
+
// Teardown filters the dead generation's late frames via _activeQuery, so its owed
|
|
1268
|
+
// result can never arrive — clear the debt or it would suppress the rebuilt turn's
|
|
1269
|
+
// api_retry frames. (_lastFailedTurn survives on purpose; the debt must not.)
|
|
1270
|
+
_lateResultEpoch = 0;
|
|
1271
|
+
// NOTE: _lastFailedTurn deliberately survives teardown — the lazy rebuild path
|
|
1272
|
+
// (_startTurn's suspect-generation teardown) must keep the recorded failure so the
|
|
1273
|
+
// manual [retry] affordance stays available. User-facing session boundaries clear it
|
|
1274
|
+
// instead: _resetFullState / _switchToSession (which funnel close/teardown/stop).
|
|
1275
|
+
// Teardown is also a loop boundary: any turn that STARTED must report its end, or
|
|
1276
|
+
// SSE clients / onAgentLoopEnd plugins see a start with no end (killed workspace,
|
|
1277
|
+
// session switch mid-turn). Guard on loopStarted so a never-pushed record stays silent.
|
|
1278
|
+
for (const rec of _pendingTurns) { if (rec.loopStarted) _emitAgentLoopEnd(rec, 'session-ended'); }
|
|
978
1279
|
_settleAllTurns();
|
|
979
1280
|
if (inputQueue) inputQueue.close();
|
|
980
1281
|
if (query) {
|
|
@@ -984,10 +1285,11 @@ function _createSession(key, cwd) {
|
|
|
984
1285
|
}
|
|
985
1286
|
|
|
986
1287
|
/** Query-level error signal; the routing envelope always comes from this
|
|
987
|
-
* session's closure (callback throw must never break the caller).
|
|
988
|
-
|
|
1288
|
+
* session's closure (callback throw must never break the caller). `extra` carries
|
|
1289
|
+
* failure metadata (code/retryable/turnId/apiStatus) through to the sdk-error frame. */
|
|
1290
|
+
function _notifyQueryError(err, extra = {}) {
|
|
989
1291
|
if (!_onQueryError) return;
|
|
990
|
-
const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey };
|
|
1292
|
+
const context = { cwd: _cwd, sessionId: _sessionId || null, instanceKey, ...extra };
|
|
991
1293
|
try { _onQueryError(String(err?.message || err), context); }
|
|
992
1294
|
catch (cbErr) { console.warn('[sdk-manager] onQueryError threw:', cbErr?.message); }
|
|
993
1295
|
}
|
|
@@ -1070,14 +1372,116 @@ function _createSession(key, cwd) {
|
|
|
1070
1372
|
* Announce a turn-level session state to WS clients (spinner source in SDK mode).
|
|
1071
1373
|
* State-change guarded + retained for reconnect replay. Broadcast failures are
|
|
1072
1374
|
* swallowed inside this helper — the message loop must never die on a UI signal.
|
|
1375
|
+
*
|
|
1376
|
+
* Every frame carries turn correlation (turnId + turnStartedAt): the CLI's
|
|
1377
|
+
* session_state_changed has no turn id of its own, so a late frame from the PREVIOUS
|
|
1378
|
+
* turn (e.g. its idle arriving after a fresh push) would otherwise clear the new
|
|
1379
|
+
* turn's spinner. `rec` is the turn this state belongs to — internal settle paths
|
|
1380
|
+
* pass it explicitly because the FIFO head may already have shifted.
|
|
1073
1381
|
*/
|
|
1074
|
-
function _announceSessionState(state) {
|
|
1382
|
+
function _announceSessionState(state, rec = null) {
|
|
1075
1383
|
const valid = state === 'idle' || state === 'running' || state === 'requires_action';
|
|
1076
1384
|
if (!valid || state === _lastSessionState) return;
|
|
1077
1385
|
_lastSessionState = state;
|
|
1386
|
+
const owner = rec || _pendingTurns[0] || null;
|
|
1078
1387
|
// _broadcast carries the routing envelope (cwd + sessionId + instanceKey) — every
|
|
1079
1388
|
// frame needs it so concurrent sessions' clients filter correctly.
|
|
1080
|
-
_broadcast({
|
|
1389
|
+
_broadcast({
|
|
1390
|
+
type: 'sdk-session-state', state,
|
|
1391
|
+
turnId: owner ? owner.id : null,
|
|
1392
|
+
turnStartedAt: owner ? owner.startedAt : _lastTurnStartedAt,
|
|
1393
|
+
});
|
|
1394
|
+
_syncStateHeartbeat();
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** One-shot agent-loop boundary callbacks (voice/plugin/SSE agent_loop_*). Idempotent
|
|
1398
|
+
* per turn record — every settle path (result, interrupt grace, lost result, stuck
|
|
1399
|
+
* recovery, death) funnels through _emitAgentLoopEnd exactly once. Callback throws are
|
|
1400
|
+
* isolated so a broken host can never wedge the message loop. */
|
|
1401
|
+
function _emitAgentLoopStart(rec) {
|
|
1402
|
+
if (!rec || rec.loopStarted) return;
|
|
1403
|
+
rec.loopStarted = true;
|
|
1404
|
+
if (!_onAgentLoopStart) return;
|
|
1405
|
+
try { _onAgentLoopStart({ sessionId: _sessionId || null, ts: Date.now(), cwd: _cwd, instanceKey, turnId: rec.id, turnStartedAt: rec.startedAt }); }
|
|
1406
|
+
catch (err) { reportSwallowed('sdk-manager.agent-loop-start', err); }
|
|
1407
|
+
}
|
|
1408
|
+
function _emitAgentLoopEnd(rec, reason) {
|
|
1409
|
+
if (!rec || rec.loopEnded) return;
|
|
1410
|
+
rec.loopEnded = true;
|
|
1411
|
+
if (!_onAgentLoopEnd) return;
|
|
1412
|
+
try { _onAgentLoopEnd({ sessionId: _sessionId || null, ts: Date.now(), cwd: _cwd, instanceKey, turnId: rec.id, turnStartedAt: rec.startedAt, reason }); }
|
|
1413
|
+
catch (err) { reportSwallowed('sdk-manager.agent-loop-end', err); }
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** Coalesced message-level liveness: any non-boundary SDK message (assistant/user/
|
|
1417
|
+
* tool_progress / system task_* frames / status / api_retry / compact …) proves the
|
|
1418
|
+
* loop is alive mid-turn — including tool/compaction/hook phases the proxy's per-API
|
|
1419
|
+
* streaming_status cannot see. Frames reuse the sdk-session-state shape with
|
|
1420
|
+
* heartbeat+activity flags (no new client handler) and are capped at one per
|
|
1421
|
+
* ACTIVITY_MIN_INTERVAL_MS. */
|
|
1422
|
+
function _noteActivity(msg) {
|
|
1423
|
+
if (_pendingTurns.length === 0 || !msg) return;
|
|
1424
|
+
const t = msg.type;
|
|
1425
|
+
if (t === 'result') return; // boundary, not progress
|
|
1426
|
+
if (t === 'system' && msg.subtype === 'session_state_changed') return; // state frame, not activity
|
|
1427
|
+
const rec = _pendingTurns[0];
|
|
1428
|
+
const kind = t === 'system' ? `system:${msg.subtype || ''}`
|
|
1429
|
+
: t === 'stream_event' ? `stream:${(msg.event && msg.event.type) || ''}` : t;
|
|
1430
|
+
_activityPending = { kind, rec };
|
|
1431
|
+
const wait = Math.max(0, ACTIVITY_MIN_INTERVAL_MS - (Date.now() - _lastActivityAt));
|
|
1432
|
+
if (wait === 0) _flushActivity();
|
|
1433
|
+
else if (!_activityTimer) {
|
|
1434
|
+
_activityTimer = setTimeout(_flushActivity, wait);
|
|
1435
|
+
if (_activityTimer.unref) _activityTimer.unref();
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
function _flushActivity() {
|
|
1439
|
+
if (_activityTimer) { clearTimeout(_activityTimer); _activityTimer = null; }
|
|
1440
|
+
const p = _activityPending; _activityPending = null;
|
|
1441
|
+
if (!p || p.rec.settled || _pendingTurns[0] !== p.rec) return;
|
|
1442
|
+
if (_lastSessionState !== 'running' && _lastSessionState !== 'requires_action') return;
|
|
1443
|
+
_lastActivityAt = Date.now();
|
|
1444
|
+
_broadcast({
|
|
1445
|
+
type: 'sdk-session-state', state: _lastSessionState, heartbeat: true, activity: true,
|
|
1446
|
+
kind: p.kind, turnId: p.rec.id, turnStartedAt: p.rec.startedAt,
|
|
1447
|
+
pendingApprovals: _pendingApprovals.size,
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
function _clearActivity() {
|
|
1451
|
+
if (_activityTimer) { clearTimeout(_activityTimer); _activityTimer = null; }
|
|
1452
|
+
_activityPending = null;
|
|
1453
|
+
_lastActivityAt = 0;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// The in-flight turn is the FIFO HEAD (the result branch shifts from the head).
|
|
1457
|
+
const _currentTurnId = () => (_pendingTurns.length ? _pendingTurns[0].id : null);
|
|
1458
|
+
|
|
1459
|
+
/** While a turn is running/requires_action, re-broadcast the session state every
|
|
1460
|
+
* STATE_HEARTBEAT_MS so clients (a) keep their Stop button / spinner alive past the
|
|
1461
|
+
* frontend's 30s no-signal stale sweep, and (b) can re-derive it after a view switch.
|
|
1462
|
+
* The heartbeat does NOT re-arm the activity probe (silence semantics unchanged) and
|
|
1463
|
+
* is cleared on idle/teardown/reset/switch/death. */
|
|
1464
|
+
function _syncStateHeartbeat() {
|
|
1465
|
+
const active = _lastSessionState === 'running' || _lastSessionState === 'requires_action';
|
|
1466
|
+
if (active && !_stateHeartbeat) {
|
|
1467
|
+
_stateHeartbeat = setInterval(() => {
|
|
1468
|
+
if (_lastSessionState !== 'running' && _lastSessionState !== 'requires_action') return;
|
|
1469
|
+
const head = _pendingTurns[0] || null;
|
|
1470
|
+
_broadcast({
|
|
1471
|
+
type: 'sdk-session-state', state: _lastSessionState, heartbeat: true,
|
|
1472
|
+
turnId: _currentTurnId(), turnStartedAt: head ? head.startedAt : _lastTurnStartedAt,
|
|
1473
|
+
pendingApprovals: _pendingApprovals.size,
|
|
1474
|
+
});
|
|
1475
|
+
}, STATE_HEARTBEAT_MS);
|
|
1476
|
+
if (_stateHeartbeat.unref) _stateHeartbeat.unref();
|
|
1477
|
+
} else if (!active && _stateHeartbeat) {
|
|
1478
|
+
clearInterval(_stateHeartbeat);
|
|
1479
|
+
_stateHeartbeat = null;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function _clearStateHeartbeat() {
|
|
1484
|
+
if (_stateHeartbeat) { clearInterval(_stateHeartbeat); _stateHeartbeat = null; }
|
|
1081
1485
|
}
|
|
1082
1486
|
|
|
1083
1487
|
/**
|
|
@@ -1096,7 +1500,58 @@ function _createSession(key, cwd) {
|
|
|
1096
1500
|
// which cli.js injects into the child env; on CLIs that never emit it the
|
|
1097
1501
|
// push/result fallbacks keep the spinner correct at result granularity.
|
|
1098
1502
|
if (msg.subtype === 'session_state_changed') {
|
|
1099
|
-
|
|
1503
|
+
// Late-idle suppression (LOAD-BEARING gate): session_state_changed carries no
|
|
1504
|
+
// turn id, so an idle that settles the PREVIOUS turn can arrive after a fresh
|
|
1505
|
+
// push. If the head turn has never been seen running/requires_action by the CLI,
|
|
1506
|
+
// this idle cannot belong to it — drop it, or it would kill the new turn's
|
|
1507
|
+
// spinner. The frontend's turnStartedAt guard is NOT a substitute here: a stale
|
|
1508
|
+
// idle processed AFTER the new push is stamped with the NEW head's epoch, so the
|
|
1509
|
+
// client cannot tell it apart — only this server-side cliStateSeen gate can (the
|
|
1510
|
+
// client guard only helps against transport-level reordering of older-stamped
|
|
1511
|
+
// frames). Residual hazard (new turn's running lands before the old idle): the
|
|
1512
|
+
// gate passes it; the lost-result backstop's armedSeq check below disarms the
|
|
1513
|
+
// resulting mis-armed conviction when the head turn is still producing messages.
|
|
1514
|
+
const head = _pendingTurns[0] || null;
|
|
1515
|
+
const staleIdle = msg.state === 'idle' && head && !head.cliStateSeen;
|
|
1516
|
+
if (!staleIdle) {
|
|
1517
|
+
_announceSessionState(msg.state, head);
|
|
1518
|
+
// A CLI-emitted running/requires_action proves the head turn is genuinely
|
|
1519
|
+
// in-flight on the child (vs a freshly-drained turn whose own CLI frames have
|
|
1520
|
+
// not arrived yet). Lost-result only arms for such a turn — a LATE idle frame
|
|
1521
|
+
// from the previous turn must not convict the just-pushed one.
|
|
1522
|
+
if ((msg.state === 'running' || msg.state === 'requires_action') && head) head.cliStateSeen = true;
|
|
1523
|
+
// Lost-result backstop: the CLI says the turn settled (idle) but no result
|
|
1524
|
+
// message arrived for our pending FIFO. Without this _queryBusy would wedge
|
|
1525
|
+
// forever now that the 25min silence watchdog is gone. Give the result a short
|
|
1526
|
+
// grace to arrive first (frame ordering within a turn end is not guaranteed),
|
|
1527
|
+
// then settle as a retryable failure.
|
|
1528
|
+
if (msg.state === 'idle' && head && head.cliStateSeen) _armLostResultBackstop();
|
|
1529
|
+
}
|
|
1530
|
+
} else if (msg.subtype === 'api_retry') {
|
|
1531
|
+
// The CLI's own retry of a retryable API error (502/429/5xx/network). It carries
|
|
1532
|
+
// no turn id, so we attribute it to the head pending turn — approximate but
|
|
1533
|
+
// correct in the common single-turn case. Skipped only while a late-result debt
|
|
1534
|
+
// is pending AND no live turn could own the frame (a late retry of an
|
|
1535
|
+
// already-interrupted turn must not relight the bar). The gate mirrors the
|
|
1536
|
+
// result branch's orphan rule: a stale debt must never suppress a live turn's
|
|
1537
|
+
// own retry frames, and must not outlive the teardown/reset that orphaned it.
|
|
1538
|
+
if (_lateResultEpoch === 0 || _pendingTurns.length > 0) {
|
|
1539
|
+
const rec = _pendingTurns.length ? _pendingTurns[0] : null;
|
|
1540
|
+
if (rec) {
|
|
1541
|
+
rec.apiRetry.count += 1;
|
|
1542
|
+
rec.apiRetry.attempt = Number(msg.attempt) || rec.apiRetry.count;
|
|
1543
|
+
rec.apiRetry.maxRetries = Number(msg.max_retries) || 0;
|
|
1544
|
+
rec.apiRetry.lastStatus = (typeof msg.error_status === 'number') ? msg.error_status : null;
|
|
1545
|
+
rec.apiRetry.lastError = msg.error || null;
|
|
1546
|
+
_broadcast({
|
|
1547
|
+
type: 'sdk-retry-state', phase: 'retrying', turnId: rec.id,
|
|
1548
|
+
attempt: rec.apiRetry.attempt, maxRetries: rec.apiRetry.maxRetries,
|
|
1549
|
+
retryDelayMs: Number(msg.retry_delay_ms) || 0,
|
|
1550
|
+
errorStatus: rec.apiRetry.lastStatus, error: rec.apiRetry.lastError || null,
|
|
1551
|
+
ts: Date.now(),
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1100
1555
|
} else if (msg.subtype === 'init') {
|
|
1101
1556
|
// /clear may reinitialize instead of returning a normal result. The new session is
|
|
1102
1557
|
// now authoritative, so context reads may resume (without resuming the old session).
|
|
@@ -1165,36 +1620,88 @@ function _createSession(key, cwd) {
|
|
|
1165
1620
|
|
|
1166
1621
|
case 'result': {
|
|
1167
1622
|
if (msg.session_id) _sessionId = msg.session_id;
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1623
|
+
// Background-task wake results are NOT turn boundaries: while local_agent /
|
|
1624
|
+
// local_workflow tasks are active the CLI holds the turn's real result back, and
|
|
1625
|
+
// each answered task notification can yield a synthetic result (num_turns: 0,
|
|
1626
|
+
// empty result). Treating one as end-of-turn fires a phantom turn_end (and would
|
|
1627
|
+
// ring the voice pack / clear the spinner mid-loop), so skip it entirely.
|
|
1628
|
+
if (msg.origin && msg.origin.kind === 'task-notification') break;
|
|
1629
|
+
// Generation guard for late results. Result frames carry no turn id, so a result
|
|
1630
|
+
// that arrives AFTER its turn was force-settled is indistinguishable from the NEXT
|
|
1631
|
+
// turn's result on arrival order alone. The guard resolves this by generation:
|
|
1632
|
+
// a force-settled turn is REMOVED from the FIFO (shift / settleAll), so when a
|
|
1633
|
+
// result arrives and the FIFO is EMPTY, nothing live could own it — it is the dead
|
|
1634
|
+
// generation's late result and is discarded (no turn_end, no error attribution).
|
|
1635
|
+
//
|
|
1636
|
+
// Deliberate trade-off (NOT a guarantee): when a NEWER turn is already in flight,
|
|
1637
|
+
// an arriving result is paired with it (`head.epoch > _lateResultEpoch`), because
|
|
1638
|
+
// swallowing the live turn's own result is the worse failure (that was the original
|
|
1639
|
+
// bug — the stuck turn got mis-settled as turn-stuck and re-ran). The cost is that
|
|
1640
|
+
// a genuinely-late result of the dead generation CAN be mis-paired to the live turn
|
|
1641
|
+
// here (an early/extra turn_end, or a wrong error toast attributed to it) — accepted
|
|
1642
|
+
// because the live turn must not lose its own result. This window is narrow: the
|
|
1643
|
+
// late result must arrive after the backstop AND after a newer turn has started.
|
|
1644
|
+
if (_lateResultEpoch !== 0) {
|
|
1645
|
+
const head = _pendingTurns[0] || null;
|
|
1646
|
+
if (!head) {
|
|
1647
|
+
// No live turn could own this result — it is the dead generation's late result.
|
|
1648
|
+
_lateResultEpoch = 0;
|
|
1649
|
+
_clearGraceTimer();
|
|
1650
|
+
_queryBusy = false;
|
|
1651
|
+
_turnInFlight = false;
|
|
1652
|
+
// The dead generation is over — but a NEWER turn may already be in flight
|
|
1653
|
+
// (Stop → grace settle → user re-sent → late result of the old turn arrives).
|
|
1654
|
+
// Only announce idle when no turn is pending, else we'd kill turn 2's spinner.
|
|
1655
|
+
if (_pendingTurns.length === 0) {
|
|
1656
|
+
_announceSessionState('idle');
|
|
1657
|
+
_clearTurnWatchdog();
|
|
1658
|
+
_clearActivity();
|
|
1659
|
+
}
|
|
1660
|
+
_maybeDrain();
|
|
1661
|
+
break;
|
|
1185
1662
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1663
|
+
// A NEWER generation is in flight: this result is that live turn's own, so fall
|
|
1664
|
+
// through to pair it normally — and drop the never-arriving late result's debt.
|
|
1665
|
+
_lateResultEpoch = 0;
|
|
1188
1666
|
}
|
|
1189
1667
|
const rec = _pendingTurns.shift();
|
|
1668
|
+
// 502/retry state machine. An error result after observed CLI retries records a
|
|
1669
|
+
// retryable _lastFailedTurn (drives the manual [retry] affordance). ANY success
|
|
1670
|
+
// unconditionally closes a recorded failure — including a manual retry that
|
|
1671
|
+
// succeeds on its first API attempt (apiRetry.count === 0) — and clears
|
|
1672
|
+
// _lastFailedTurn; `retryOf` carries the FAILED turn's id (rec.retryOf when this
|
|
1673
|
+
// turn is itself a manual retry) because the frontend matches cleanup by it.
|
|
1674
|
+
if (rec) {
|
|
1675
|
+
const isError = msg.is_error === true || (msg.subtype && msg.subtype !== 'success');
|
|
1676
|
+
if (isError && !rec.interrupted) {
|
|
1677
|
+
const status = rec.apiRetry.lastStatus;
|
|
1678
|
+
const retryable = rec.apiRetry.count > 0 && (status == null || RETRYABLE_STATUS.has(status));
|
|
1679
|
+
_lastFailedTurn = {
|
|
1680
|
+
turnId: rec.id, retryable, apiStatus: status,
|
|
1681
|
+
attempt: rec.apiRetry.attempt, maxRetries: rec.apiRetry.maxRetries, code: null, ts: Date.now(),
|
|
1682
|
+
};
|
|
1683
|
+
} else if (!isError && _lastFailedTurn && (rec.apiRetry.count > 0 || rec.retryOf === _lastFailedTurn.turnId)) {
|
|
1684
|
+
// recovered only when there IS a recorded failure AND this turn closes it
|
|
1685
|
+
// (a manual retry of it — even on its first attempt — or a turn that itself
|
|
1686
|
+
// saw CLI retries). A clean success (no _lastFailedTurn) emits nothing; an
|
|
1687
|
+
// unrelated later success neither clears the affordance nor spams the frame.
|
|
1688
|
+
_broadcast({ type: 'sdk-retry-state', phase: 'recovered', turnId: rec.id, retryOf: rec.retryOf || _lastFailedTurn.turnId, ts: Date.now() });
|
|
1689
|
+
_lastFailedTurn = null;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1190
1692
|
// An interrupted turn's result is expected to be an error (turn aborted) — don't
|
|
1191
1693
|
// toast it. Turn-end still fires, matching the PTY Stop-hook semantics.
|
|
1192
|
-
if (rec && !rec.interrupted) _notifyTurnError(msg);
|
|
1694
|
+
if (rec && !rec.interrupted) _notifyTurnError(msg, rec);
|
|
1193
1695
|
// SDK turn-end signal. Equivalent to Claude Code's Stop hook
|
|
1194
1696
|
// in CLI mode — fires once per user-prompt response when the whole chain
|
|
1195
1697
|
// (assistant text + all tool calls + final reply) completes. SDK mode
|
|
1196
1698
|
// doesn't go through ensureHooks() so this in-process callback is the
|
|
1197
|
-
// only way the renderer learns the turn is over.
|
|
1699
|
+
// only way the renderer learns the turn is over. NOTE: deliberately NOT
|
|
1700
|
+
// gated on `rec` — the late-result trade-off (see the generation-guard
|
|
1701
|
+
// comment above) pairs a dead turn's late result with the live turn, and
|
|
1702
|
+
// the live turn's OWN result can then arrive with an empty FIFO; phantom
|
|
1703
|
+
// ringing from background tasks is prevented by the task-notification
|
|
1704
|
+
// filter above, not by requiring a FIFO record.
|
|
1198
1705
|
if (_onTurnEnd) {
|
|
1199
1706
|
// Per-workspace (2026-10): carry the session's own cwd so the server
|
|
1200
1707
|
// can route turn_end to THIS workspace's SSE scope (SDK mode has no
|
|
@@ -1203,13 +1710,23 @@ function _createSession(key, cwd) {
|
|
|
1203
1710
|
catch (err) { console.warn('[sdk-manager] onTurnEnd threw:', err?.message); }
|
|
1204
1711
|
}
|
|
1205
1712
|
if (rec) _settleTurn(rec);
|
|
1713
|
+
// Authoritative loop end for THIS turn — immediate, per-turn (turnId/turnStartedAt
|
|
1714
|
+
// correlated), unlike the debounced turn_end above. `is_error`/non-success
|
|
1715
|
+
// subtypes distinguish error ends; interrupted turns report their own reason.
|
|
1716
|
+
if (rec) {
|
|
1717
|
+
const errored = msg.is_error === true || (msg.subtype && msg.subtype !== 'success');
|
|
1718
|
+
_emitAgentLoopEnd(rec, rec.interrupted ? 'interrupted' : (errored ? 'error' : 'success'));
|
|
1719
|
+
}
|
|
1206
1720
|
_clearGraceTimer();
|
|
1721
|
+
_clearActivity();
|
|
1207
1722
|
_queryBusy = false;
|
|
1208
1723
|
_turnInFlight = false;
|
|
1209
1724
|
// Turn-level spinner clear at result time — immediate, unlike the debounced
|
|
1210
1725
|
// `turn_end` SSE (voice-pack semantics). Idempotent against a real
|
|
1211
|
-
// session_state_changed(idle) arriving before/after.
|
|
1212
|
-
|
|
1726
|
+
// session_state_changed(idle) arriving before/after. `rec` is passed explicitly:
|
|
1727
|
+
// the FIFO head has already shifted, so the default head lookup would stamp this
|
|
1728
|
+
// idle with the NEXT turn's id.
|
|
1729
|
+
_announceSessionState('idle', rec);
|
|
1213
1730
|
if (_pendingTurns.length === 0) _clearTurnWatchdog();
|
|
1214
1731
|
_maybeDrain();
|
|
1215
1732
|
// Refresh the context-usage panel AFTER the turn fully settles (fire-and-forget,
|
|
@@ -1231,14 +1748,17 @@ function _createSession(key, cwd) {
|
|
|
1231
1748
|
* never signals it, so a dead/errored turn would otherwise look like a hang. The
|
|
1232
1749
|
* sdk-error frame renders as a persistent dialogue row plus a toast client-side.
|
|
1233
1750
|
*/
|
|
1234
|
-
function _notifyTurnError(resultMsg) {
|
|
1751
|
+
function _notifyTurnError(resultMsg, rec = null) {
|
|
1235
1752
|
const isError = resultMsg.is_error === true || (resultMsg.subtype && resultMsg.subtype !== 'success');
|
|
1236
1753
|
if (!isError) return;
|
|
1237
1754
|
const errs = Array.isArray(resultMsg.errors) && resultMsg.errors.length
|
|
1238
1755
|
? resultMsg.errors.join('; ')
|
|
1239
1756
|
: (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
|
|
1757
|
+
const retry = rec?.apiRetry || null;
|
|
1758
|
+
const status = retry?.lastStatus ?? null;
|
|
1759
|
+
const retryable = !!retry?.count && (status == null || RETRYABLE_STATUS.has(status));
|
|
1240
1760
|
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); }
|
|
1761
|
+
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
1762
|
}
|
|
1243
1763
|
}
|
|
1244
1764
|
|
|
@@ -1683,6 +2203,7 @@ function _createSession(key, cwd) {
|
|
|
1683
2203
|
// Close the modal on every client — a silent deny would leave it hanging forever.
|
|
1684
2204
|
_broadcastApprovalDismiss(kind, id, 'timeout');
|
|
1685
2205
|
resolve(null);
|
|
2206
|
+
_rearmWatchdogAfterApprovalSettled();
|
|
1686
2207
|
}, timeoutMs);
|
|
1687
2208
|
const entry = {
|
|
1688
2209
|
kind, // 'ask' | 'plan' | 'perm' — lets cancelApproval distinguish types so an
|
|
@@ -1701,6 +2222,7 @@ function _createSession(key, cwd) {
|
|
|
1701
2222
|
if (cur?.auto) cancelAutoApproveState(cur.auto);
|
|
1702
2223
|
_pendingApprovals.delete(id);
|
|
1703
2224
|
resolve(value);
|
|
2225
|
+
_rearmWatchdogAfterApprovalSettled();
|
|
1704
2226
|
},
|
|
1705
2227
|
};
|
|
1706
2228
|
_pendingApprovals.set(id, entry);
|
|
@@ -1831,29 +2353,90 @@ function _createSession(key, cwd) {
|
|
|
1831
2353
|
return cancelled;
|
|
1832
2354
|
}
|
|
1833
2355
|
|
|
1834
|
-
/** Backstop: if no result shows up within the grace window after an interrupt,
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
2356
|
+
/** Backstop: if no result shows up within the grace window after an interrupt, settle
|
|
2357
|
+
* the head turn (keeping _queryBusy from wedging forever) and mark the generation
|
|
2358
|
+
* suspect — the CLI never answered our Stop, so the child may be wedged. The actual
|
|
2359
|
+
* teardown is deferred to the next push (_startTurn) so we never SIGTERM a child that
|
|
2360
|
+
* was merely slow to unwind; the rebuild resumes _sessionId. A tombstone discards the
|
|
2361
|
+
* late result if it ever arrives. */
|
|
1837
2362
|
function _armSettleGrace() {
|
|
1838
2363
|
_clearGraceTimer();
|
|
1839
2364
|
_settleGraceTimer = setTimeout(() => {
|
|
1840
2365
|
_settleGraceTimer = null;
|
|
1841
2366
|
const rec = _pendingTurns[0];
|
|
1842
2367
|
if (!rec || !rec.interrupted) return;
|
|
1843
|
-
console.warn('[sdk-manager] no result within interrupt grace —
|
|
1844
|
-
|
|
2368
|
+
console.warn('[sdk-manager] no result within interrupt grace — settling the turn and marking the generation suspect');
|
|
2369
|
+
_lateResultEpoch = rec.epoch; // discard this generation's late result, never a later turn's
|
|
1845
2370
|
_pendingTurns.shift();
|
|
1846
2371
|
_settleTurn(rec);
|
|
2372
|
+
_emitAgentLoopEnd(rec, 'interrupt-timeout');
|
|
1847
2373
|
_queryBusy = false;
|
|
2374
|
+
_suspectGeneration = true;
|
|
1848
2375
|
// The CLI never answered the interrupt — no result is coming, so clear the
|
|
1849
2376
|
// spinner here rather than waiting on a dead signal.
|
|
1850
|
-
_announceSessionState('idle');
|
|
2377
|
+
_announceSessionState('idle', rec);
|
|
2378
|
+
_clearActivity();
|
|
1851
2379
|
if (_pendingTurns.length === 0) _clearTurnWatchdog();
|
|
1852
2380
|
_maybeDrain();
|
|
1853
2381
|
}, INTERRUPT_GRACE_MS);
|
|
1854
2382
|
if (_settleGraceTimer.unref) _settleGraceTimer.unref();
|
|
1855
2383
|
}
|
|
1856
2384
|
|
|
2385
|
+
/** Lost-result backstop: the CLI announced session_state_changed('idle') (turn over,
|
|
2386
|
+
* tools included) but never produced a result message for our pending FIFO. Give the
|
|
2387
|
+
* result a short grace to arrive (frame ordering at turn end is not guaranteed), then
|
|
2388
|
+
* settle as a retryable failure — without this _queryBusy would wedge forever now that
|
|
2389
|
+
* the fixed silence watchdog is gone. */
|
|
2390
|
+
function _armLostResultBackstop() {
|
|
2391
|
+
const GRACE_MS = 5000;
|
|
2392
|
+
// Capture the pending turn at arm time and re-verify identity at fire time: a stale
|
|
2393
|
+
// backstop from turn A must never settle turn B (which may be parked on the SAME idle
|
|
2394
|
+
// state). The interrupt-grace path owns user-cancelled turns — skip those here.
|
|
2395
|
+
const armedRec = _pendingTurns[0] || null;
|
|
2396
|
+
const armedSeq = _inboundMsgSeq; // any message arriving after arming bumps this
|
|
2397
|
+
_lostResultTimer = setTimeout(() => {
|
|
2398
|
+
_lostResultTimer = null;
|
|
2399
|
+
const rec = _pendingTurns[0];
|
|
2400
|
+
if (!rec || rec !== armedRec || rec.interrupted) return; // stale arm / different turn / user-cancelled
|
|
2401
|
+
if (_queryBusy === false) return;
|
|
2402
|
+
if (_lastSessionState !== 'idle') return; // a newer turn started running — not lost
|
|
2403
|
+
// Mitigation for the uncorrelatable-idle hazard: if the accepted idle actually
|
|
2404
|
+
// belonged to the PREVIOUS turn (the new turn's `running` landed first, so
|
|
2405
|
+
// cliStateSeen was already true and the gate passed it), the head turn may still
|
|
2406
|
+
// be alive. A message arriving after arming proves the head turn is producing
|
|
2407
|
+
// work — not lost. Disarm rather than convict it as turn-stuck. (armedSeq is read
|
|
2408
|
+
// inside _processMessage, BEFORE the drain loop increments the seq for the very
|
|
2409
|
+
// idle that armed us — so exactly ONE bump is expected from the arming message;
|
|
2410
|
+
// anything beyond it is post-arm activity.)
|
|
2411
|
+
if (_inboundMsgSeq > armedSeq + 1) return;
|
|
2412
|
+
console.warn('[sdk-manager] session idle but a turn result never arrived — settling as lost result');
|
|
2413
|
+
// Mark the head turn's epoch as owing a result: without it a late result would
|
|
2414
|
+
// consume the NEXT turn's FIFO record (settling it early and mispairing turn-end).
|
|
2415
|
+
// Binding to the epoch (not a count) is the fix for the swallowed-next-result bug —
|
|
2416
|
+
// this result never arriving must not let the debt outlive its generation.
|
|
2417
|
+
if (_pendingTurns[0]) _lateResultEpoch = _pendingTurns[0].epoch;
|
|
2418
|
+
const lostRecs = [..._pendingTurns];
|
|
2419
|
+
_settleAllTurns();
|
|
2420
|
+
_clearActivity();
|
|
2421
|
+
for (const r of lostRecs) _emitAgentLoopEnd(r, 'lost-result');
|
|
2422
|
+
const retryable = !!(_sessionId || _launchResume);
|
|
2423
|
+
_lastFailedTurn = {
|
|
2424
|
+
turnId: rec.id || null, retryable, apiStatus: rec.apiRetry?.lastStatus ?? null,
|
|
2425
|
+
attempt: rec.apiRetry?.attempt || 0, maxRetries: rec.apiRetry?.maxRetries || 0,
|
|
2426
|
+
code: 'turn-stuck', ts: Date.now(),
|
|
2427
|
+
};
|
|
2428
|
+
_notifyQueryError(new Error('The turn ended without a result; you can retry it.'), {
|
|
2429
|
+
code: 'turn-stuck', retryable, turnId: rec.id || null,
|
|
2430
|
+
});
|
|
2431
|
+
_maybeDrain();
|
|
2432
|
+
}, GRACE_MS);
|
|
2433
|
+
if (_lostResultTimer.unref) _lostResultTimer.unref();
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
function _clearLostResultBackstop() {
|
|
2437
|
+
if (_lostResultTimer) { clearTimeout(_lostResultTimer); _lostResultTimer = null; }
|
|
2438
|
+
}
|
|
2439
|
+
|
|
1857
2440
|
/**
|
|
1858
2441
|
* Interrupt the current turn (user clicked the Stop button) while KEEPING the
|
|
1859
2442
|
* session alive: the resident query is a streaming-input session, so interrupt()
|
|
@@ -1988,6 +2571,35 @@ function _createSession(key, cwd) {
|
|
|
1988
2571
|
_broadcastQueueState();
|
|
1989
2572
|
}
|
|
1990
2573
|
|
|
2574
|
+
/**
|
|
2575
|
+
* Manual [retry] of the last failed turn. The original user message is still in the
|
|
2576
|
+
* session history (the CLI closed the failed turn with a synthetic API-error entry and
|
|
2577
|
+
* stayed alive), so we do NOT resend it — we push a tiny synthetic continue instruction
|
|
2578
|
+
* that makes the model redo the interrupted step with full context. Refuses with a
|
|
2579
|
+
* reason unless there is a retryable recorded failure and the session is idle.
|
|
2580
|
+
*/
|
|
2581
|
+
async function _retryInterruptedTurn(turnId = null) {
|
|
2582
|
+
if (_queryBusy || _dispatchingStarting || _dispatching) return { ok: false, reason: 'busy' };
|
|
2583
|
+
if (_switching) return { ok: false, reason: 'busy' }; // a session switch must not race a retry push
|
|
2584
|
+
if (!_lastFailedTurn) return { ok: false, reason: 'nothing-to-retry' };
|
|
2585
|
+
if (turnId && _lastFailedTurn.turnId && turnId !== _lastFailedTurn.turnId) return { ok: false, reason: 'stale' };
|
|
2586
|
+
if (_lastFailedTurn.retryable === false) return { ok: false, reason: 'not-retryable' };
|
|
2587
|
+
if (!_sessionId && !_launchResume) return { ok: false, reason: 'no-session' };
|
|
2588
|
+
const retryOf = _lastFailedTurn.turnId; // snapshot BEFORE the await (a new turn mutates state)
|
|
2589
|
+
_suppressDrain = false;
|
|
2590
|
+
const { started, promise, turnId: newTurnId } = await _startTurn(RETRY_CONTINUE_PROMPT);
|
|
2591
|
+
if (!started) return { ok: false, reason: 'not-delivered' };
|
|
2592
|
+
// Stamp the lineage on the new turn's record so the result branch's `recovered`
|
|
2593
|
+
// frame can name the FAILED turn (the frontend matches cleanup by retryOf).
|
|
2594
|
+
// Look the new turn up by id (not index) — a fast result could have shifted the FIFO
|
|
2595
|
+
// in the microtask gap, and the retryOf stamp must land on the retry turn itself.
|
|
2596
|
+
const newRec = _pendingTurns.find(r => r.id === newTurnId) || null;
|
|
2597
|
+
if (newRec) newRec.retryOf = retryOf;
|
|
2598
|
+
_broadcast({ type: 'sdk-retry-state', phase: 'recovering', turnId: newTurnId, retryOf, ts: Date.now() });
|
|
2599
|
+
promise.catch((err) => reportSwallowed('sdk-manager.retry-turn', err));
|
|
2600
|
+
return { ok: true, turnId: newTurnId };
|
|
2601
|
+
}
|
|
2602
|
+
|
|
1991
2603
|
/**
|
|
1992
2604
|
* Switch the session to a different session id (web "resume session" entry — SDK-mode
|
|
1993
2605
|
* counterpart of the PTY /resume injection). options.resume only applies at query
|
|
@@ -2015,6 +2627,9 @@ function _createSession(key, cwd) {
|
|
|
2015
2627
|
_lastInitSnapshot = null; // don't replay the OLD session's sdk-init to the new one
|
|
2016
2628
|
_lastSkillsSnapshot = null; // same for the skill surface
|
|
2017
2629
|
_lastSessionState = null; // same for the session-state snapshot — new session starts idle
|
|
2630
|
+
_clearStateHeartbeat();
|
|
2631
|
+
_suspectGeneration = false;
|
|
2632
|
+
_lastFailedTurn = null; // the failure belongs to the outgoing session — never retry it into this one
|
|
2018
2633
|
_clearContextUsage('session-switched');
|
|
2019
2634
|
_clearMcpStatus('session-switched');
|
|
2020
2635
|
_clearQueued(); // broadcasts the emptied queue-state
|
|
@@ -2042,11 +2657,16 @@ function _createSession(key, cwd) {
|
|
|
2042
2657
|
// legit idle). Skip the broadcast when nothing was ever announced.
|
|
2043
2658
|
if (_lastSessionState && _lastSessionState !== 'idle') _announceSessionState('idle');
|
|
2044
2659
|
_lastSessionState = null;
|
|
2660
|
+
_clearStateHeartbeat();
|
|
2661
|
+
_clearActivity();
|
|
2662
|
+
_lastTurnStartedAt = 0;
|
|
2045
2663
|
_clearContextUsage('session-switched');
|
|
2046
2664
|
_clearMcpStatus('session-switched');
|
|
2047
2665
|
_pendingTurns = [];
|
|
2048
|
-
|
|
2666
|
+
_lateResultEpoch = 0; // session boundary: a stale debt must not follow into the new session
|
|
2049
2667
|
_consecutiveDeaths = 0;
|
|
2668
|
+
_suspectGeneration = false;
|
|
2669
|
+
_lastFailedTurn = null;
|
|
2050
2670
|
_clearGraceTimer();
|
|
2051
2671
|
_clearTurnWatchdog();
|
|
2052
2672
|
const hadQueued = _messageQueue.length > 0 || !!_dispatching;
|
|
@@ -2099,7 +2719,8 @@ function _createSession(key, cwd) {
|
|
|
2099
2719
|
getSdkInitSnapshot: () => _lastInitSnapshot,
|
|
2100
2720
|
getSessionStateSnapshot: () => {
|
|
2101
2721
|
if (!_lastSessionState) return null;
|
|
2102
|
-
|
|
2722
|
+
const head = _pendingTurns[0] || null;
|
|
2723
|
+
return { type: 'sdk-session-state', state: _lastSessionState, sessionId: _sessionId || null, cwd: _cwd, instanceKey, turnId: _currentTurnId(), turnStartedAt: head ? head.startedAt : _lastTurnStartedAt, pendingApprovals: _pendingApprovals.size };
|
|
2103
2724
|
},
|
|
2104
2725
|
getContextUsageSnapshot: () => _lastContextUsage,
|
|
2105
2726
|
getMcpStatusSnapshot: () => _lastMcpStatus,
|
|
@@ -2127,6 +2748,7 @@ function _createSession(key, cwd) {
|
|
|
2127
2748
|
},
|
|
2128
2749
|
switchToSession: _switchToSession,
|
|
2129
2750
|
getSessionId: () => _sessionId,
|
|
2751
|
+
retryInterruptedTurn: _retryInterruptedTurn,
|
|
2130
2752
|
runEphemeralTurn: _runEphemeralTurn,
|
|
2131
2753
|
close: () => {
|
|
2132
2754
|
for (const { id, kind } of _cancelAllApprovals()) {
|
|
@@ -2234,6 +2856,8 @@ export function __isEphemeralHostForTests(cwd) {
|
|
|
2234
2856
|
* @param {function} deps.broadcastWs — (msg) => void, terminal-WS broadcast for approvals
|
|
2235
2857
|
* @param {string} [deps.permissionMode]
|
|
2236
2858
|
* @param {function} [deps.runWaterfallHook] — plugin waterfall (onPlanRequest/onAskRequest/onPermRequest)
|
|
2859
|
+
* @param {function} [deps.onAgentLoopStart] — ({sessionId, ts, cwd, instanceKey, turnId, turnStartedAt}) => void, fired once per accepted turn
|
|
2860
|
+
* @param {function} [deps.onAgentLoopEnd] — (same + reason) => void, fired once at the turn's real settlement
|
|
2237
2861
|
* @param {object} [deps.env] — child env (must already contain ANTHROPIC_BASE_URL → ccv proxy)
|
|
2238
2862
|
* @param {object} [deps.settings] — merged Settings object (env.ANTHROPIC_BASE_URL double-injection)
|
|
2239
2863
|
* @param {string} [deps.model] — --model lifted from user args (options.model)
|
|
@@ -2243,7 +2867,7 @@ export function __isEphemeralHostForTests(cwd) {
|
|
|
2243
2867
|
* must come from the same selection as PTY mode, else the SDK spawns a PATH claude that
|
|
2244
2868
|
* host security tooling (e.g. agent-security headless guards) may SIGKILL on spawn
|
|
2245
2869
|
*/
|
|
2246
|
-
export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broadcastWs, permissionMode, runWaterfallHook, env, settings, model, launchResume, userArgs, claudeExecutable } = {}) {
|
|
2870
|
+
export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broadcastWs, permissionMode, runWaterfallHook, onAgentLoopStart, onAgentLoopEnd, env, settings, model, launchResume, userArgs, claudeExecutable } = {}) {
|
|
2247
2871
|
void projectName; // reserved (display name comes from the wire path)
|
|
2248
2872
|
// Process-global callbacks (cli.js registers one wiring; every session reuses it). Only
|
|
2249
2873
|
// OVERWRITE when a callback is actually passed — a later per-workspace init (the launch
|
|
@@ -2253,6 +2877,12 @@ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broa
|
|
|
2253
2877
|
if (onQueryError !== undefined) _onQueryError = onQueryError || null;
|
|
2254
2878
|
if (broadcastWs !== undefined) _broadcastWs = broadcastWs;
|
|
2255
2879
|
if (runWaterfallHook !== undefined) _runWaterfallHook = runWaterfallHook || null;
|
|
2880
|
+
if (onAgentLoopStart !== undefined) _onAgentLoopStart = onAgentLoopStart || null;
|
|
2881
|
+
if (onAgentLoopEnd !== undefined) _onAgentLoopEnd = onAgentLoopEnd || null;
|
|
2882
|
+
// Restore any callback stopSession() cleared from the durable process wiring (cli.js's
|
|
2883
|
+
// registration). A workspace stop→relaunch omits these callbacks, so without this every
|
|
2884
|
+
// sdk-* frame would silently stop broadcasting after returning to the workspace list.
|
|
2885
|
+
_adoptProcessWiring();
|
|
2256
2886
|
// Register this workspace's session (per-cwd, re-using the record on re-init) and make
|
|
2257
2887
|
// it active. init() adopts the launch config and tears down ONLY this session's prior
|
|
2258
2888
|
// generation (tests / hot reload) — other workspaces' sessions are untouched.
|
|
@@ -2430,6 +3060,8 @@ export function stopSession() {
|
|
|
2430
3060
|
_onQueryError = null;
|
|
2431
3061
|
_broadcastWs = null;
|
|
2432
3062
|
_runWaterfallHook = null;
|
|
3063
|
+
_onAgentLoopStart = null;
|
|
3064
|
+
_onAgentLoopEnd = null;
|
|
2433
3065
|
}
|
|
2434
3066
|
|
|
2435
3067
|
/**
|
|
@@ -2546,6 +3178,22 @@ export function getSessionId(anchor) {
|
|
|
2546
3178
|
return sess ? sess.getSessionId() : null;
|
|
2547
3179
|
}
|
|
2548
3180
|
|
|
3181
|
+
/**
|
|
3182
|
+
* Manual [retry] of the last failed turn on the active (or anchored) session. Pushes a
|
|
3183
|
+
* synthetic continue instruction instead of resending the user's original message.
|
|
3184
|
+
* Returns the session's { ok, reason? , turnId? } verdict.
|
|
3185
|
+
*/
|
|
3186
|
+
export async function retryInterruptedTurn(turnId, anchor) {
|
|
3187
|
+
if (!_query) return { ok: false, reason: 'unavailable' };
|
|
3188
|
+
const sess = anchor ? _sessionForAnchor(anchor) : _active();
|
|
3189
|
+
if (!sess) return { ok: false, reason: 'not-found' };
|
|
3190
|
+
const r = await sess.retryInterruptedTurn(turnId);
|
|
3191
|
+
// Only a SUCCESSFUL retry re-routes the process-wide active session — a rejected retry
|
|
3192
|
+
// (busy/stale/no-session) must not steal the active anchor from the foreground view.
|
|
3193
|
+
if (r && r.ok && anchor && sess.key !== _activeKey) _activeKey = sess.key;
|
|
3194
|
+
return r;
|
|
3195
|
+
}
|
|
3196
|
+
|
|
2549
3197
|
/**
|
|
2550
3198
|
* The last sdk-init snapshot for WS reconnect replay. No anchor → active session;
|
|
2551
3199
|
* with an anchor → that session. Returns null if no init has been announced yet
|