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.
- package/package.json +1 -1
- package/src/defaults/cycle3-review-prompt.md +14 -0
- package/src/defaults/memory-promote-prompt.md +9 -9
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +132 -11
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -1
- package/src/runtime/memory/index.mjs +7 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +108 -4
- package/src/runtime/memory/lib/cycle-scheduler.mjs +46 -14
- package/src/runtime/memory/lib/memory-cycle1.mjs +166 -23
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +15 -8
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +37 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +17 -7
- package/src/runtime/memory/lib/memory-cycle3.mjs +106 -6
- package/src/runtime/memory/lib/memory.mjs +7 -1
- package/src/runtime/memory/lib/query-handlers.mjs +1 -0
- package/src/tui/dist/index.mjs +283 -28
- package/src/tui/engine/tui-steering-persist.mjs +175 -0
- package/src/tui/engine.mjs +137 -4
- package/src/ui/statusline.mjs +2 -4
package/src/tui/engine.mjs
CHANGED
|
@@ -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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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;
|
package/src/ui/statusline.mjs
CHANGED
|
@@ -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"
|
|
348
|
-
// (
|
|
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);
|