mixdog 0.9.14 → 0.9.15
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/package.json +1 -1
- package/src/tui/dist/index.mjs +80 -18
- package/src/tui/engine.mjs +91 -2
package/package.json
CHANGED
package/src/tui/dist/index.mjs
CHANGED
|
@@ -22052,6 +22052,19 @@ var TOOL_APPROVAL_TIMEOUT_MS = (() => {
|
|
|
22052
22052
|
const value = Number(process.env.MIXDOG_TOOL_APPROVAL_TIMEOUT_MS);
|
|
22053
22053
|
return Number.isFinite(value) && value > 0 ? Math.max(1e3, Math.round(value)) : 12e4;
|
|
22054
22054
|
})();
|
|
22055
|
+
var LEAD_TURN_TIMEOUT_MS = (() => {
|
|
22056
|
+
const value = Number(process.env.MIXDOG_LEAD_TURN_TIMEOUT_MS);
|
|
22057
|
+
return Number.isFinite(value) && value > 0 ? Math.max(1e4, Math.round(value)) : 30 * 6e4;
|
|
22058
|
+
})();
|
|
22059
|
+
var TUI_DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_TUI_DEBUG || ""));
|
|
22060
|
+
var tuiDebug = (msg) => {
|
|
22061
|
+
if (!TUI_DEBUG) return;
|
|
22062
|
+
try {
|
|
22063
|
+
process.stderr.write(`[tui] ${msg}
|
|
22064
|
+
`);
|
|
22065
|
+
} catch {
|
|
22066
|
+
}
|
|
22067
|
+
};
|
|
22055
22068
|
var _idSeq = 0;
|
|
22056
22069
|
var nextId = () => `it_${++_idSeq}`;
|
|
22057
22070
|
async function createEngineSession({
|
|
@@ -22487,6 +22500,7 @@ async function createEngineSession({
|
|
|
22487
22500
|
async function runTurn(userText, options = {}) {
|
|
22488
22501
|
const turnIndex = state.stats.turns || 0;
|
|
22489
22502
|
const startedAt2 = Date.now();
|
|
22503
|
+
const turnEpoch = ++leadTurnEpoch;
|
|
22490
22504
|
const inputBaseline = state.stats.inputTokens;
|
|
22491
22505
|
const outputBaseline = state.stats.outputTokens;
|
|
22492
22506
|
const submittedIds = Array.isArray(options.submittedIds) ? options.submittedIds : [];
|
|
@@ -22506,6 +22520,44 @@ async function createEngineSession({
|
|
|
22506
22520
|
set({ busy: true, lastTurn: null, spinner: { active: true, verb: pickVerb(turnIndex), startedAt: startedAt2, responseLength: 0, inputTokens: 0, outputTokens: 0, mode: "requesting" } });
|
|
22507
22521
|
let assistantText = "";
|
|
22508
22522
|
let currentAssistantId = null;
|
|
22523
|
+
tuiDebug(`runTurn start turn=${turnIndex} pending=${pending.length} timeoutMs=${LEAD_TURN_TIMEOUT_MS}`);
|
|
22524
|
+
let watchdogTripped = false;
|
|
22525
|
+
let watchdogTimer = null;
|
|
22526
|
+
let watchdogGraceTimer = null;
|
|
22527
|
+
const clearWatchdog = () => {
|
|
22528
|
+
if (watchdogTimer) {
|
|
22529
|
+
clearTimeout(watchdogTimer);
|
|
22530
|
+
watchdogTimer = null;
|
|
22531
|
+
}
|
|
22532
|
+
if (watchdogGraceTimer) {
|
|
22533
|
+
clearTimeout(watchdogGraceTimer);
|
|
22534
|
+
watchdogGraceTimer = null;
|
|
22535
|
+
}
|
|
22536
|
+
};
|
|
22537
|
+
watchdogTimer = setTimeout(() => {
|
|
22538
|
+
if (disposed) return;
|
|
22539
|
+
watchdogTripped = true;
|
|
22540
|
+
const elapsed = Date.now() - startedAt2;
|
|
22541
|
+
tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} \u2014 aborting stuck turn`);
|
|
22542
|
+
pushNotice(`Turn timed out after ${Math.round(elapsed / 1e3)}s \u2014 aborting stuck request. Input is available again.`, "warn", { transcript: true });
|
|
22543
|
+
try {
|
|
22544
|
+
runtime.abort("cli-react-abort-watchdog");
|
|
22545
|
+
} catch {
|
|
22546
|
+
}
|
|
22547
|
+
watchdogGraceTimer = setTimeout(() => {
|
|
22548
|
+
if (disposed) return;
|
|
22549
|
+
if (!state.busy) return;
|
|
22550
|
+
if (leadTurnEpoch !== turnEpoch) return;
|
|
22551
|
+
tuiDebug(`runTurn WATCHDOG FORCE-RELEASE turn=${turnIndex} \u2014 abort unwind starved`);
|
|
22552
|
+
leadTurnEpoch++;
|
|
22553
|
+
set({ busy: false, spinner: null, thinking: null, lastTurn: null });
|
|
22554
|
+
activePromptRestore = null;
|
|
22555
|
+
if (draining) draining = false;
|
|
22556
|
+
if (pending.length > 0) void drain();
|
|
22557
|
+
}, 5e3);
|
|
22558
|
+
watchdogGraceTimer.unref?.();
|
|
22559
|
+
}, LEAD_TURN_TIMEOUT_MS);
|
|
22560
|
+
watchdogTimer.unref?.();
|
|
22509
22561
|
let currentAssistantText = "";
|
|
22510
22562
|
let thinkingText = "";
|
|
22511
22563
|
let thinkingStartedAt = 0;
|
|
@@ -23108,6 +23160,8 @@ async function createEngineSession({
|
|
|
23108
23160
|
}
|
|
23109
23161
|
} finally {
|
|
23110
23162
|
denyAllToolApprovals(cancelled ? "turn cancelled" : "turn finished");
|
|
23163
|
+
clearWatchdog();
|
|
23164
|
+
const isStaleUnwind = leadTurnEpoch !== turnEpoch;
|
|
23111
23165
|
if (deferredEntries.length) {
|
|
23112
23166
|
const last = deferredEntries[deferredEntries.length - 1];
|
|
23113
23167
|
if (last) flushDeferredUpTo(last);
|
|
@@ -23116,7 +23170,7 @@ async function createEngineSession({
|
|
|
23116
23170
|
flushDeferredBeforeImmediatePush = null;
|
|
23117
23171
|
const producedTranscriptItem = state.items.length > itemsAtTurnStart;
|
|
23118
23172
|
const reclaimed = cancelled && activePromptRestore?.reclaimed === true;
|
|
23119
|
-
activePromptRestore = null;
|
|
23173
|
+
if (!isStaleUnwind) activePromptRestore = null;
|
|
23120
23174
|
closeThinkingSegment();
|
|
23121
23175
|
const elapsedMs = Date.now() - startedAt2;
|
|
23122
23176
|
const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
|
|
@@ -23127,31 +23181,37 @@ async function createEngineSession({
|
|
|
23127
23181
|
const resultContent = askResult?.content != null ? String(askResult.content).trim() : "";
|
|
23128
23182
|
const assistantOutput = (currentAssistantText || assistantText || "").trim();
|
|
23129
23183
|
const isNoOpTurn = turnFinishedNormally && !cancelled && toolCards.length === 0 && !resultContent && !assistantOutput && !producedTranscriptItem;
|
|
23130
|
-
if (
|
|
23131
|
-
|
|
23132
|
-
}
|
|
23133
|
-
|
|
23134
|
-
|
|
23184
|
+
if (isStaleUnwind) {
|
|
23185
|
+
tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} \u2014 force-released; skipping shared-state writes`);
|
|
23186
|
+
} else {
|
|
23187
|
+
if (!isNoOpTurn) {
|
|
23188
|
+
state.stats.turns = (state.stats.turns || 0) + 1;
|
|
23189
|
+
}
|
|
23190
|
+
if (!reclaimed && !isNoOpTurn) {
|
|
23191
|
+
pushItem({ kind: "turndone", id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
|
|
23192
|
+
}
|
|
23193
|
+
set({
|
|
23194
|
+
busy: false,
|
|
23195
|
+
spinner: null,
|
|
23196
|
+
thinking: null,
|
|
23197
|
+
lastTurn: null,
|
|
23198
|
+
stats: { ...state.stats },
|
|
23199
|
+
...routeState(),
|
|
23200
|
+
toolMode: runtime.toolMode,
|
|
23201
|
+
...agentStatusState({ force: true })
|
|
23202
|
+
});
|
|
23203
|
+
flushDeferredExecutionPendingResumeKick();
|
|
23135
23204
|
}
|
|
23136
|
-
set({
|
|
23137
|
-
busy: false,
|
|
23138
|
-
spinner: null,
|
|
23139
|
-
thinking: null,
|
|
23140
|
-
lastTurn: null,
|
|
23141
|
-
stats: { ...state.stats },
|
|
23142
|
-
...routeState(),
|
|
23143
|
-
toolMode: runtime.toolMode,
|
|
23144
|
-
...agentStatusState({ force: true })
|
|
23145
|
-
});
|
|
23146
|
-
flushDeferredExecutionPendingResumeKick();
|
|
23147
23205
|
}
|
|
23148
|
-
clearActiveToolSummary();
|
|
23206
|
+
if (leadTurnEpoch === turnEpoch) clearActiveToolSummary();
|
|
23149
23207
|
_publishedThinkingActive = false;
|
|
23208
|
+
tuiDebug(`runTurn end turn=${turnIndex} status=${cancelled ? "cancelled" : "done"} elapsedMs=${Date.now() - startedAt2}${watchdogTripped ? " watchdogTripped=1" : ""} pending=${pending.length}`);
|
|
23150
23209
|
return cancelled ? "cancelled" : "done";
|
|
23151
23210
|
}
|
|
23152
23211
|
const pending = [];
|
|
23153
23212
|
let draining = false;
|
|
23154
23213
|
let activePromptRestore = null;
|
|
23214
|
+
let leadTurnEpoch = 0;
|
|
23155
23215
|
const leadSessionId = () => runtime.id;
|
|
23156
23216
|
function shouldMirrorSteeringEntry(entry) {
|
|
23157
23217
|
return isQueuedEntryEditable(entry) && !isSlashQueuedEntry(entry);
|
|
@@ -23252,6 +23312,7 @@ async function createEngineSession({
|
|
|
23252
23312
|
const batch = dequeueQueueBatch("later", { limit: firstBatch ? 1 : Infinity });
|
|
23253
23313
|
firstBatch = false;
|
|
23254
23314
|
if (batch.length === 0) break;
|
|
23315
|
+
tuiDebug(`busy-queue drain batch=${batch.length} remaining=${pending.length}`);
|
|
23255
23316
|
const ids = new Set(batch.map((e) => e.id));
|
|
23256
23317
|
const merged = mergePromptContents(batch);
|
|
23257
23318
|
for (const entry of batch) {
|
|
@@ -23299,6 +23360,7 @@ async function createEngineSession({
|
|
|
23299
23360
|
appendTuiSteeringPersist(leadSessionId(), entry);
|
|
23300
23361
|
}
|
|
23301
23362
|
if (isQueuedEntryVisible(entry)) set({ queued: [...state.queued, entry] });
|
|
23363
|
+
if (state.busy) tuiDebug(`busy-queue enqueue mode=${entry.mode} pending=${pending.length}`);
|
|
23302
23364
|
void drain();
|
|
23303
23365
|
return true;
|
|
23304
23366
|
}
|
package/src/tui/engine.mjs
CHANGED
|
@@ -96,6 +96,26 @@ const TOOL_APPROVAL_TIMEOUT_MS = (() => {
|
|
|
96
96
|
return Number.isFinite(value) && value > 0 ? Math.max(1000, Math.round(value)) : 120_000;
|
|
97
97
|
})();
|
|
98
98
|
|
|
99
|
+
// Wall-clock cap for a single lead TUI turn. A provider call that never
|
|
100
|
+
// resolves (e.g. a rate-limit retry loop that spins forever) otherwise leaves
|
|
101
|
+
// runTurn holding busy=true with no unwind, so submit() only ever queues and
|
|
102
|
+
// the UI is permanently input-dead. On trip we abort the in-flight run through
|
|
103
|
+
// the SAME interrupt path Esc uses, force busy=false, and drain the queue.
|
|
104
|
+
// Generous default (30min); env-overridable for tuning/tests.
|
|
105
|
+
const LEAD_TURN_TIMEOUT_MS = (() => {
|
|
106
|
+
const value = Number(process.env.MIXDOG_LEAD_TURN_TIMEOUT_MS);
|
|
107
|
+
return Number.isFinite(value) && value > 0 ? Math.max(10_000, Math.round(value)) : 30 * 60_000;
|
|
108
|
+
})();
|
|
109
|
+
|
|
110
|
+
// Opt-in diagnostic trace for the hang chain (runTurn start/end, busy-queue
|
|
111
|
+
// enqueue/drain, watchdog trip). Quiet by default so it can never tear through
|
|
112
|
+
// the alternate-screen render; enable with MIXDOG_TUI_DEBUG=1.
|
|
113
|
+
const TUI_DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_TUI_DEBUG || ''));
|
|
114
|
+
const tuiDebug = (msg) => {
|
|
115
|
+
if (!TUI_DEBUG) return;
|
|
116
|
+
try { process.stderr.write(`[tui] ${msg}\n`); } catch {}
|
|
117
|
+
};
|
|
118
|
+
|
|
99
119
|
let _idSeq = 0;
|
|
100
120
|
const nextId = () => `it_${++_idSeq}`;
|
|
101
121
|
|
|
@@ -610,6 +630,12 @@ export async function createEngineSession({
|
|
|
610
630
|
async function runTurn(userText, options = {}) {
|
|
611
631
|
const turnIndex = state.stats.turns || 0;
|
|
612
632
|
const startedAt = Date.now();
|
|
633
|
+
// Per-turn epoch. Force-release (watchdog grace) bumps the shared counter so
|
|
634
|
+
// this turn's own eventual `finally` — which may run LONG after force-release
|
|
635
|
+
// already started a new turn that reuses the per-session mutex — can detect
|
|
636
|
+
// it is stale and skip all shared-state writes (busy, activePromptRestore,
|
|
637
|
+
// turndone, drain kick). Neutralizes the stale unwind without touching the mutex.
|
|
638
|
+
const turnEpoch = ++leadTurnEpoch;
|
|
613
639
|
const inputBaseline = state.stats.inputTokens;
|
|
614
640
|
const outputBaseline = state.stats.outputTokens;
|
|
615
641
|
const submittedIds = Array.isArray(options.submittedIds) ? options.submittedIds : [];
|
|
@@ -630,6 +656,48 @@ export async function createEngineSession({
|
|
|
630
656
|
|
|
631
657
|
let assistantText = '';
|
|
632
658
|
let currentAssistantId = null;
|
|
659
|
+
|
|
660
|
+
tuiDebug(`runTurn start turn=${turnIndex} pending=${pending.length} timeoutMs=${LEAD_TURN_TIMEOUT_MS}`);
|
|
661
|
+
// ── Wall-clock watchdog ───────────────────────────────────────────────
|
|
662
|
+
// If this turn never unwinds (provider call stuck), trip after the cap:
|
|
663
|
+
// abort the in-flight run via the existing interrupt path (runtime.abort,
|
|
664
|
+
// the same one Esc uses), which rejects the pending runtime.ask() with a
|
|
665
|
+
// SessionClosedError so the normal cancelled-turn teardown runs. If even
|
|
666
|
+
// that unwind is starved, forceReleaseTurn() in the timer hard-clears busy
|
|
667
|
+
// and kicks the drain so input is never permanently dead.
|
|
668
|
+
let watchdogTripped = false;
|
|
669
|
+
let watchdogTimer = null;
|
|
670
|
+
let watchdogGraceTimer = null;
|
|
671
|
+
const clearWatchdog = () => {
|
|
672
|
+
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
|
|
673
|
+
if (watchdogGraceTimer) { clearTimeout(watchdogGraceTimer); watchdogGraceTimer = null; }
|
|
674
|
+
};
|
|
675
|
+
watchdogTimer = setTimeout(() => {
|
|
676
|
+
if (disposed) return;
|
|
677
|
+
watchdogTripped = true;
|
|
678
|
+
const elapsed = Date.now() - startedAt;
|
|
679
|
+
tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} — aborting stuck turn`);
|
|
680
|
+
pushNotice(`Turn timed out after ${Math.round(elapsed / 1000)}s — aborting stuck request. Input is available again.`, 'warn', { transcript: true });
|
|
681
|
+
try { runtime.abort('cli-react-abort-watchdog'); } catch {}
|
|
682
|
+
// Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
|
|
683
|
+
// (unwind starved), hard-release the turn after a short grace so the
|
|
684
|
+
// React store is never left with busy=true and no drain in flight.
|
|
685
|
+
watchdogGraceTimer = setTimeout(() => {
|
|
686
|
+
if (disposed) return;
|
|
687
|
+
if (!state.busy) return;
|
|
688
|
+
if (leadTurnEpoch !== turnEpoch) return; // a newer turn already owns the store
|
|
689
|
+
tuiDebug(`runTurn WATCHDOG FORCE-RELEASE turn=${turnIndex} — abort unwind starved`);
|
|
690
|
+
// Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
|
|
691
|
+
// a no-op for shared state and cannot corrupt the turn we hand off to.
|
|
692
|
+
leadTurnEpoch++;
|
|
693
|
+
set({ busy: false, spinner: null, thinking: null, lastTurn: null });
|
|
694
|
+
activePromptRestore = null;
|
|
695
|
+
if (draining) draining = false;
|
|
696
|
+
if (pending.length > 0) void drain();
|
|
697
|
+
}, 5_000);
|
|
698
|
+
watchdogGraceTimer.unref?.();
|
|
699
|
+
}, LEAD_TURN_TIMEOUT_MS);
|
|
700
|
+
watchdogTimer.unref?.();
|
|
633
701
|
let currentAssistantText = '';
|
|
634
702
|
let thinkingText = '';
|
|
635
703
|
let thinkingStartedAt = 0;
|
|
@@ -1451,6 +1519,14 @@ export async function createEngineSession({
|
|
|
1451
1519
|
}
|
|
1452
1520
|
} finally {
|
|
1453
1521
|
denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
|
|
1522
|
+
// Turn is unwinding normally (or via abort) — cancel the wall-clock
|
|
1523
|
+
// watchdog + its force-release grace so they never fire on a live turn.
|
|
1524
|
+
clearWatchdog();
|
|
1525
|
+
// If the watchdog force-release already fired, a NEWER turn now owns the
|
|
1526
|
+
// shared store (busy, activePromptRestore, turndone, drain). This stale
|
|
1527
|
+
// unwind must NOT write shared state or it corrupts that turn. It still
|
|
1528
|
+
// runs its own turn-local teardown (approvals, deferred cards) below.
|
|
1529
|
+
const isStaleUnwind = leadTurnEpoch !== turnEpoch;
|
|
1454
1530
|
// Flush any still-deferred tool cards into the transcript and cancel their
|
|
1455
1531
|
// pending push timers so nothing fires (or leaks) after the turn ends. The
|
|
1456
1532
|
// finalize path above already patches results onto visible cards; this just
|
|
@@ -1463,7 +1539,7 @@ export async function createEngineSession({
|
|
|
1463
1539
|
flushDeferredBeforeImmediatePush = null;
|
|
1464
1540
|
const producedTranscriptItem = state.items.length > itemsAtTurnStart;
|
|
1465
1541
|
const reclaimed = cancelled && activePromptRestore?.reclaimed === true;
|
|
1466
|
-
activePromptRestore = null;
|
|
1542
|
+
if (!isStaleUnwind) activePromptRestore = null;
|
|
1467
1543
|
closeThinkingSegment();
|
|
1468
1544
|
const elapsedMs = Date.now() - startedAt;
|
|
1469
1545
|
const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
|
|
@@ -1486,6 +1562,9 @@ export async function createEngineSession({
|
|
|
1486
1562
|
&& !resultContent
|
|
1487
1563
|
&& !assistantOutput
|
|
1488
1564
|
&& !producedTranscriptItem;
|
|
1565
|
+
if (isStaleUnwind) {
|
|
1566
|
+
tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-state writes`);
|
|
1567
|
+
} else {
|
|
1489
1568
|
if (!isNoOpTurn) {
|
|
1490
1569
|
state.stats.turns = (state.stats.turns || 0) + 1;
|
|
1491
1570
|
}
|
|
@@ -1507,15 +1586,23 @@ export async function createEngineSession({
|
|
|
1507
1586
|
...agentStatusState({ force: true }),
|
|
1508
1587
|
});
|
|
1509
1588
|
flushDeferredExecutionPendingResumeKick();
|
|
1589
|
+
}
|
|
1510
1590
|
}
|
|
1511
|
-
|
|
1591
|
+
// Shared UI state: a stale unwind must not wipe a newer turn's live
|
|
1592
|
+
// tool-summary line (same epoch rule as the shared-state block above).
|
|
1593
|
+
if (leadTurnEpoch === turnEpoch) clearActiveToolSummary();
|
|
1512
1594
|
_publishedThinkingActive = false; // turn teardown cleared state.thinking
|
|
1595
|
+
tuiDebug(`runTurn end turn=${turnIndex} status=${cancelled ? 'cancelled' : 'done'} elapsedMs=${Date.now() - startedAt}${watchdogTripped ? ' watchdogTripped=1' : ''} pending=${pending.length}`);
|
|
1513
1596
|
return cancelled ? 'cancelled' : 'done';
|
|
1514
1597
|
}
|
|
1515
1598
|
|
|
1516
1599
|
const pending = [];
|
|
1517
1600
|
let draining = false;
|
|
1518
1601
|
let activePromptRestore = null;
|
|
1602
|
+
// Monotonic per-lead-turn epoch (see runTurn). Bumped on watchdog
|
|
1603
|
+
// force-release so a stale turn's late `finally` can detect it no longer owns
|
|
1604
|
+
// the shared store and skip all shared-state writes.
|
|
1605
|
+
let leadTurnEpoch = 0;
|
|
1519
1606
|
|
|
1520
1607
|
const leadSessionId = () => runtime.id;
|
|
1521
1608
|
|
|
@@ -1627,6 +1714,7 @@ export async function createEngineSession({
|
|
|
1627
1714
|
const batch = dequeueQueueBatch('later', { limit: firstBatch ? 1 : Infinity });
|
|
1628
1715
|
firstBatch = false;
|
|
1629
1716
|
if (batch.length === 0) break;
|
|
1717
|
+
tuiDebug(`busy-queue drain batch=${batch.length} remaining=${pending.length}`);
|
|
1630
1718
|
const ids = new Set(batch.map((e) => e.id));
|
|
1631
1719
|
const merged = mergePromptContents(batch);
|
|
1632
1720
|
for (const entry of batch) {
|
|
@@ -1686,6 +1774,7 @@ export async function createEngineSession({
|
|
|
1686
1774
|
appendTuiSteeringPersist(leadSessionId(), entry);
|
|
1687
1775
|
}
|
|
1688
1776
|
if (isQueuedEntryVisible(entry)) set({ queued: [...state.queued, entry] });
|
|
1777
|
+
if (state.busy) tuiDebug(`busy-queue enqueue mode=${entry.mode} pending=${pending.length}`);
|
|
1689
1778
|
void drain();
|
|
1690
1779
|
return true;
|
|
1691
1780
|
}
|