mixdog 0.9.13 → 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.
@@ -79,6 +79,11 @@ import {
79
79
  import { createToolApproval } from './engine/tool-approval.mjs';
80
80
  import { createToolCardResults } from './engine/tool-card-results.mjs';
81
81
  import { createAgentJobFeed } from './engine/agent-job-feed.mjs';
82
+ import {
83
+ appendTuiSteeringPersist,
84
+ dropTuiSteeringPersist,
85
+ drainTuiSteeringPersist,
86
+ } from './engine/tui-steering-persist.mjs';
82
87
 
83
88
  // Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
84
89
  // src/tui/dist/index.mjs.
@@ -91,6 +96,26 @@ const TOOL_APPROVAL_TIMEOUT_MS = (() => {
91
96
  return Number.isFinite(value) && value > 0 ? Math.max(1000, Math.round(value)) : 120_000;
92
97
  })();
93
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
+
94
119
  let _idSeq = 0;
95
120
  const nextId = () => `it_${++_idSeq}`;
96
121
 
@@ -605,6 +630,12 @@ export async function createEngineSession({
605
630
  async function runTurn(userText, options = {}) {
606
631
  const turnIndex = state.stats.turns || 0;
607
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;
608
639
  const inputBaseline = state.stats.inputTokens;
609
640
  const outputBaseline = state.stats.outputTokens;
610
641
  const submittedIds = Array.isArray(options.submittedIds) ? options.submittedIds : [];
@@ -625,6 +656,48 @@ export async function createEngineSession({
625
656
 
626
657
  let assistantText = '';
627
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?.();
628
701
  let currentAssistantText = '';
629
702
  let thinkingText = '';
630
703
  let thinkingStartedAt = 0;
@@ -1446,6 +1519,14 @@ export async function createEngineSession({
1446
1519
  }
1447
1520
  } finally {
1448
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;
1449
1530
  // Flush any still-deferred tool cards into the transcript and cancel their
1450
1531
  // pending push timers so nothing fires (or leaks) after the turn ends. The
1451
1532
  // finalize path above already patches results onto visible cards; this just
@@ -1458,7 +1539,7 @@ export async function createEngineSession({
1458
1539
  flushDeferredBeforeImmediatePush = null;
1459
1540
  const producedTranscriptItem = state.items.length > itemsAtTurnStart;
1460
1541
  const reclaimed = cancelled && activePromptRestore?.reclaimed === true;
1461
- activePromptRestore = null;
1542
+ if (!isStaleUnwind) activePromptRestore = null;
1462
1543
  closeThinkingSegment();
1463
1544
  const elapsedMs = Date.now() - startedAt;
1464
1545
  const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
@@ -1481,6 +1562,9 @@ export async function createEngineSession({
1481
1562
  && !resultContent
1482
1563
  && !assistantOutput
1483
1564
  && !producedTranscriptItem;
1565
+ if (isStaleUnwind) {
1566
+ tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-state writes`);
1567
+ } else {
1484
1568
  if (!isNoOpTurn) {
1485
1569
  state.stats.turns = (state.stats.turns || 0) + 1;
1486
1570
  }
@@ -1502,15 +1586,37 @@ export async function createEngineSession({
1502
1586
  ...agentStatusState({ force: true }),
1503
1587
  });
1504
1588
  flushDeferredExecutionPendingResumeKick();
1589
+ }
1505
1590
  }
1506
- 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();
1507
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}`);
1508
1596
  return cancelled ? 'cancelled' : 'done';
1509
1597
  }
1510
1598
 
1511
1599
  const pending = [];
1512
1600
  let draining = false;
1513
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;
1606
+
1607
+ const leadSessionId = () => runtime.id;
1608
+
1609
+ function shouldMirrorSteeringEntry(entry) {
1610
+ return isQueuedEntryEditable(entry) && !isSlashQueuedEntry(entry);
1611
+ }
1612
+
1613
+ function commitSteeringQueueEntries(entries) {
1614
+ callCommitCallbacks(entries);
1615
+ const mirrored = (Array.isArray(entries) ? entries : []).filter(
1616
+ (entry) => shouldMirrorSteeringEntry(entry) && !entry.steeringPersistRestored,
1617
+ );
1618
+ if (mirrored.length > 0) dropTuiSteeringPersist(leadSessionId(), mirrored);
1619
+ }
1514
1620
 
1515
1621
  function makeQueueEntry(text, options = {}) {
1516
1622
  const mode = options.mode || 'prompt';
@@ -1528,6 +1634,8 @@ export async function createEngineSession({
1528
1634
  key: options.key || null,
1529
1635
  skipSlashCommands: options.skipSlashCommands === true,
1530
1636
  displayText: mode === 'task-notification' ? notificationDisplayText(displayText) : String(displayText || ''),
1637
+ steeringPersistId: options.steeringPersistId || null,
1638
+ steeringPersistRestored: options.steeringPersistRestored === true,
1531
1639
  };
1532
1640
  }
1533
1641
 
@@ -1606,6 +1714,7 @@ export async function createEngineSession({
1606
1714
  const batch = dequeueQueueBatch('later', { limit: firstBatch ? 1 : Infinity });
1607
1715
  firstBatch = false;
1608
1716
  if (batch.length === 0) break;
1717
+ tuiDebug(`busy-queue drain batch=${batch.length} remaining=${pending.length}`);
1609
1718
  const ids = new Set(batch.map((e) => e.id));
1610
1719
  const merged = mergePromptContents(batch);
1611
1720
  for (const entry of batch) {
@@ -1619,7 +1728,7 @@ export async function createEngineSession({
1619
1728
  displayText: batch.map((entry) => entry.text).filter((text) => String(text || '').trim()).join('\n'),
1620
1729
  pastedImages: batchPastedImages,
1621
1730
  pastedTexts: batchPastedTexts,
1622
- onCommitted: () => callCommitCallbacks(batch),
1731
+ onCommitted: () => commitSteeringQueueEntries(batch),
1623
1732
  submittedIds: [...ids],
1624
1733
  restorable: nonEditable.length === 0,
1625
1734
  requeueOnAbort: nonEditable,
@@ -1661,7 +1770,11 @@ export async function createEngineSession({
1661
1770
  pendingNotificationKeys.add(entry.key);
1662
1771
  }
1663
1772
  pending.push(entry);
1773
+ if (state.busy && shouldMirrorSteeringEntry(entry)) {
1774
+ appendTuiSteeringPersist(leadSessionId(), entry);
1775
+ }
1664
1776
  if (isQueuedEntryVisible(entry)) set({ queued: [...state.queued, entry] });
1777
+ if (state.busy) tuiDebug(`busy-queue enqueue mode=${entry.mode} pending=${pending.length}`);
1665
1778
  void drain();
1666
1779
  return true;
1667
1780
  }
@@ -1687,10 +1800,26 @@ export async function createEngineSession({
1687
1800
  if (Array.isArray(entry?.content)) return entry.content.length > 0;
1688
1801
  return String(entry?.content ?? '').trim().length > 0;
1689
1802
  });
1690
- callCommitCallbacks(batch);
1803
+ commitSteeringQueueEntries(batch);
1691
1804
  return out;
1692
1805
  }
1693
1806
 
1807
+ function restoreLeadSteeringFromDisk() {
1808
+ const rows = drainTuiSteeringPersist(leadSessionId());
1809
+ if (!rows.length) return;
1810
+ const restored = [];
1811
+ for (const row of rows) {
1812
+ const entry = makeQueueEntry(row.text, {
1813
+ steeringPersistRestored: true,
1814
+ steeringPersistId: row.steeringPersistId || undefined,
1815
+ });
1816
+ pending.push(entry);
1817
+ if (isQueuedEntryVisible(entry)) restored.push(entry);
1818
+ }
1819
+ if (restored.length > 0) set({ queued: [...state.queued, ...restored] });
1820
+ void drain();
1821
+ }
1822
+
1694
1823
  async function autoClearBeforeSubmit() {
1695
1824
  const cfg = autoClearState();
1696
1825
  const now = Date.now();
@@ -1858,6 +1987,8 @@ export async function createEngineSession({
1858
1987
  return state.stats;
1859
1988
  };
1860
1989
 
1990
+ restoreLeadSteeringFromDisk();
1991
+
1861
1992
  return {
1862
1993
  getState: () => state,
1863
1994
  patchItem,
@@ -2826,11 +2957,13 @@ export async function createEngineSession({
2826
2957
  ...routeState(),
2827
2958
  stats: { ...state.stats },
2828
2959
  });
2960
+ restoreLeadSteeringFromDisk();
2829
2961
  return true;
2830
2962
  } finally {
2831
2963
  set({ commandBusy: false, commandStatus: null });
2832
2964
  }
2833
2965
  },
2966
+
2834
2967
  dispose: async (reason = 'cli-react-exit', options = {}) => {
2835
2968
  if (disposed) return;
2836
2969
  disposed = true;
@@ -344,14 +344,12 @@ function renderNativeStatusline({
344
344
  addL2(`${spin} ${B}${label}${R}${elapsedSuffix(shellStatus.elapsedLabel)}`);
345
345
  }
346
346
  // Memory cycle segment — single unified "Memory" wording for all states:
347
- // running -> "⠋ Memory · 12s"; backlog warning -> "⠋ Memory · backlog 512"
348
- // (yellow). Nothing when idle (no L2 pollution).
347
+ // running -> "⠋ Memory · 12s". Backlog is intentionally NOT rendered
348
+ // (owner preference: cycle-health WARN logs cover it); nothing when idle.
349
349
  const memStatus = memoryCycleStatus();
350
350
  if (memStatus?.kind === 'running') {
351
351
  const elapsed = formatElapsed(Date.now() - memStatus.startedAt);
352
352
  addL2(`${spin} ${B}Memory${R}${elapsedSuffix(elapsed)}`);
353
- } else if (memStatus?.kind === 'backlog') {
354
- addL2(`${spin} ${B}Memory${R} ${D}·${R} ${YLW}backlog ${memStatus.pending}${R}`);
355
353
  }
356
354
  const l1 = l1Parts.join(sep) || 'mixdog';
357
355
  const l2 = l2Parts.join(sep);