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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.14",
3
+ "version": "0.9.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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 (!isNoOpTurn) {
23131
- state.stats.turns = (state.stats.turns || 0) + 1;
23132
- }
23133
- if (!reclaimed && !isNoOpTurn) {
23134
- pushItem({ kind: "turndone", id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
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
  }
@@ -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
- clearActiveToolSummary();
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
  }