mixdog 0.9.16 → 0.9.18
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 +2 -1
- package/scripts/atomic-lock-tryonce-test.mjs +66 -0
- package/scripts/provider-toolcall-test.mjs +79 -2
- package/src/mixdog-session-runtime.mjs +12 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
- package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
- package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +12 -2
- package/src/runtime/channels/backends/discord.mjs +97 -7
- package/src/runtime/channels/index.mjs +150 -23
- package/src/runtime/channels/lib/crash-log.mjs +21 -3
- package/src/runtime/channels/lib/output-forwarder.mjs +118 -7
- package/src/runtime/channels/lib/runtime-paths.mjs +21 -19
- package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
- package/src/runtime/memory/index.mjs +37 -0
- package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -0
- package/src/runtime/shared/atomic-file.mjs +110 -0
- package/src/runtime/shared/transcript-writer.mjs +46 -4
- package/src/session-runtime/provider-models.mjs +47 -8
- package/src/standalone/channel-worker.mjs +14 -2
- package/src/tui/app/transcript-window.mjs +137 -6
- package/src/tui/app/use-transcript-window.mjs +67 -10
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/dist/index.mjs +436 -93
- package/src/tui/engine/tui-steering-persist.mjs +66 -35
- package/src/tui/engine.mjs +66 -14
- package/src/tui/index.jsx +97 -6
- package/src/ui/statusline-segments.mjs +54 -36
- package/src/ui/statusline.mjs +141 -95
|
@@ -3,12 +3,25 @@
|
|
|
3
3
|
import { randomBytes } from 'crypto';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { resolvePluginData } from '../../runtime/shared/plugin-paths.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { updateJsonAtomic } from '../../runtime/shared/atomic-file.mjs';
|
|
7
7
|
import { promptContentText } from './queue-helpers.mjs';
|
|
8
8
|
|
|
9
9
|
const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
10
10
|
const PENDING_MESSAGES_MODE = 0o600;
|
|
11
11
|
|
|
12
|
+
// Serialize this UI process's own steering writes so append→drop→drain keep
|
|
13
|
+
// their issue order even though each now waits on the lock asynchronously.
|
|
14
|
+
// Cross-process mutual exclusion still comes from the shared lock protocol;
|
|
15
|
+
// this chain only orders same-process operations that were previously
|
|
16
|
+
// naturally ordered by the synchronous call site.
|
|
17
|
+
let _persistChain = Promise.resolve();
|
|
18
|
+
function _serialize(task) {
|
|
19
|
+
const run = _persistChain.then(task, task);
|
|
20
|
+
// Never let a rejection poison the chain; each op logs its own error.
|
|
21
|
+
_persistChain = run.catch(() => {});
|
|
22
|
+
return run;
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
function pendingMessagesPath() {
|
|
13
26
|
return join(resolvePluginData(), PENDING_MESSAGES_FILE);
|
|
14
27
|
}
|
|
@@ -93,15 +106,20 @@ function removePersistRow(q, entry) {
|
|
|
93
106
|
if (idx >= 0) q.splice(idx, 1);
|
|
94
107
|
}
|
|
95
108
|
|
|
109
|
+
// Consistency-required: a dropped append loses a user's queued steering
|
|
110
|
+
// message, so we async-wait for the lock (never try-once). Returns a promise;
|
|
111
|
+
// fire-and-forget at call sites is fine — _serialize keeps append/drop/drain
|
|
112
|
+
// in issue order and the write never blocks the render loop.
|
|
96
113
|
export function appendTuiSteeringPersist(leadSessionId, entry) {
|
|
97
114
|
const text = entryPersistText(entry);
|
|
98
|
-
if (!text) return;
|
|
115
|
+
if (!text) return Promise.resolve();
|
|
99
116
|
const key = tuiSteeringSessionKey(leadSessionId);
|
|
100
|
-
if (!key) return;
|
|
117
|
+
if (!key) return Promise.resolve();
|
|
101
118
|
if (!entry.steeringPersistId) entry.steeringPersistId = newSteeringPersistId();
|
|
102
119
|
const record = { id: entry.steeringPersistId, text };
|
|
103
|
-
|
|
104
|
-
|
|
120
|
+
return _serialize(async () => {
|
|
121
|
+
try {
|
|
122
|
+
await updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
105
123
|
const next = normalizePendingStore(raw);
|
|
106
124
|
const q = Array.isArray(next.sessions[key]) ? next.sessions[key] : [];
|
|
107
125
|
q.push(record);
|
|
@@ -110,19 +128,21 @@ export function appendTuiSteeringPersist(leadSessionId, entry) {
|
|
|
110
128
|
next.updatedAt = now;
|
|
111
129
|
touchPendingSessionEntry(next, key, now);
|
|
112
130
|
return next;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
try { process.stderr.write(`[tui] steering-queue append failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
117
136
|
}
|
|
118
137
|
|
|
119
138
|
export function dropTuiSteeringPersist(leadSessionId, entries) {
|
|
120
139
|
const key = tuiSteeringSessionKey(leadSessionId);
|
|
121
|
-
if (!key) return;
|
|
140
|
+
if (!key) return Promise.resolve();
|
|
122
141
|
const batch = Array.isArray(entries) ? entries : [];
|
|
123
|
-
if (batch.length === 0) return;
|
|
124
|
-
|
|
125
|
-
|
|
142
|
+
if (batch.length === 0) return Promise.resolve();
|
|
143
|
+
return _serialize(async () => {
|
|
144
|
+
try {
|
|
145
|
+
await updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
126
146
|
const next = normalizePendingStore(raw);
|
|
127
147
|
const q = Array.isArray(next.sessions[key]) ? next.sessions[key].slice() : [];
|
|
128
148
|
if (q.length === 0) return undefined;
|
|
@@ -137,10 +157,15 @@ export function dropTuiSteeringPersist(leadSessionId, entries) {
|
|
|
137
157
|
}
|
|
138
158
|
next.updatedAt = Date.now();
|
|
139
159
|
return next;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
try { process.stderr.write(`[tui] steering-queue drop failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function flushTuiSteeringPersist() {
|
|
168
|
+
return _persistChain.catch(() => {});
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
function drainedRowToRestore(row) {
|
|
@@ -153,23 +178,29 @@ function drainedRowToRestore(row) {
|
|
|
153
178
|
return null;
|
|
154
179
|
}
|
|
155
180
|
|
|
181
|
+
// Consistency-required (restores queued messages after boot/command). Async
|
|
182
|
+
// lock wait, serialized on _persistChain so it never reorders against a
|
|
183
|
+
// pending append/drop and never blocks the render loop. Returns a promise of
|
|
184
|
+
// the drained rows; call sites await it.
|
|
156
185
|
export function drainTuiSteeringPersist(leadSessionId) {
|
|
157
186
|
const key = tuiSteeringSessionKey(leadSessionId);
|
|
158
|
-
if (!key) return [];
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
187
|
+
if (!key) return Promise.resolve([]);
|
|
188
|
+
return _serialize(async () => {
|
|
189
|
+
let drained = [];
|
|
190
|
+
try {
|
|
191
|
+
await updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
192
|
+
const next = normalizePendingStore(raw);
|
|
193
|
+
const q = Array.isArray(next.sessions[key]) ? next.sessions[key] : [];
|
|
194
|
+
drained = q.map(drainedRowToRestore).filter(Boolean);
|
|
195
|
+
if (drained.length === 0) return undefined;
|
|
196
|
+
delete next.sessions[key];
|
|
197
|
+
if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
|
|
198
|
+
next.updatedAt = Date.now();
|
|
199
|
+
return next;
|
|
200
|
+
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
try { process.stderr.write(`[tui] steering-queue drain failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
|
|
203
|
+
}
|
|
204
|
+
return drained;
|
|
205
|
+
});
|
|
206
|
+
}
|
package/src/tui/engine.mjs
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
appendTuiSteeringPersist,
|
|
85
85
|
dropTuiSteeringPersist,
|
|
86
86
|
drainTuiSteeringPersist,
|
|
87
|
+
flushTuiSteeringPersist,
|
|
87
88
|
} from './engine/tui-steering-persist.mjs';
|
|
88
89
|
|
|
89
90
|
// Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
|
|
@@ -774,12 +775,19 @@ export async function createEngineSession({
|
|
|
774
775
|
seq: deferredSeqCounter++,
|
|
775
776
|
pushed: false,
|
|
776
777
|
timer: null,
|
|
777
|
-
|
|
778
|
+
// Mark the card visible and return its spec WITHOUT emitting, so a
|
|
779
|
+
// batched turn-close flush can commit many specs in one set().
|
|
780
|
+
materialize: () => {
|
|
778
781
|
card.pushed = true;
|
|
779
|
-
if (!card.spec) return;
|
|
782
|
+
if (!card.spec) return null;
|
|
780
783
|
card.spec.deferredDisplayReady = true;
|
|
784
|
+
return card.spec;
|
|
785
|
+
},
|
|
786
|
+
push: () => {
|
|
787
|
+
const spec = entry.materialize();
|
|
788
|
+
if (!spec) return;
|
|
781
789
|
pushingFromDeferredEntry = true;
|
|
782
|
-
try { pushItem(
|
|
790
|
+
try { pushItem(spec); } finally { pushingFromDeferredEntry = false; }
|
|
783
791
|
},
|
|
784
792
|
};
|
|
785
793
|
card.deferred = entry;
|
|
@@ -797,12 +805,17 @@ export async function createEngineSession({
|
|
|
797
805
|
seq: deferredSeqCounter++,
|
|
798
806
|
pushed: false,
|
|
799
807
|
timer: null,
|
|
800
|
-
|
|
808
|
+
materialize: () => {
|
|
801
809
|
aggregate.pushed = true;
|
|
802
|
-
if (!aggregate.pendingSpec) return;
|
|
810
|
+
if (!aggregate.pendingSpec) return null;
|
|
803
811
|
aggregate.pendingSpec.deferredDisplayReady = true;
|
|
812
|
+
return aggregate.pendingSpec;
|
|
813
|
+
},
|
|
814
|
+
push: () => {
|
|
815
|
+
const spec = entry.materialize();
|
|
816
|
+
if (!spec) return;
|
|
804
817
|
pushingFromDeferredEntry = true;
|
|
805
|
-
try { pushItem(
|
|
818
|
+
try { pushItem(spec); } finally { pushingFromDeferredEntry = false; }
|
|
806
819
|
},
|
|
807
820
|
};
|
|
808
821
|
aggregate.deferred = entry;
|
|
@@ -820,6 +833,35 @@ export async function createEngineSession({
|
|
|
820
833
|
if (e.timer) { clearTimeout(e.timer); e.timer = null; }
|
|
821
834
|
}
|
|
822
835
|
};
|
|
836
|
+
// Collect (mark pushed + cancel timers) every still-deferred entry up to
|
|
837
|
+
// `entry` in creation order, returning their specs WITHOUT emitting — the
|
|
838
|
+
// caller commits them (optionally alongside a trailing turndone item) in a
|
|
839
|
+
// single set() so turn-close writes land as ONE visible commit.
|
|
840
|
+
const collectDeferredUpTo = (entry) => {
|
|
841
|
+
const specs = [];
|
|
842
|
+
if (!entry) return specs;
|
|
843
|
+
for (const e of deferredEntries) {
|
|
844
|
+
if (e.seq > entry.seq) break;
|
|
845
|
+
if (e.pushed) continue;
|
|
846
|
+
e.pushed = true;
|
|
847
|
+
if (e.timer) { clearTimeout(e.timer); e.timer = null; }
|
|
848
|
+
const spec = e.materialize?.();
|
|
849
|
+
if (spec) specs.push(spec);
|
|
850
|
+
}
|
|
851
|
+
return specs;
|
|
852
|
+
};
|
|
853
|
+
// Append pre-built items (deferred cards + turndone) in ONE set(). None are
|
|
854
|
+
// 'user' kind, so no promptHistory rebuild is needed.
|
|
855
|
+
const appendItemsBatch = (newItems, extra = {}) => {
|
|
856
|
+
if (!newItems || !newItems.length) { set(extra); return; }
|
|
857
|
+
const base = state.items.length;
|
|
858
|
+
const items = [...state.items, ...newItems];
|
|
859
|
+
for (let i = 0; i < newItems.length; i++) {
|
|
860
|
+
const it = newItems[i];
|
|
861
|
+
if (it?.id != null) itemIndexById.set(it.id, base + i);
|
|
862
|
+
}
|
|
863
|
+
set({ items, ...extra });
|
|
864
|
+
};
|
|
823
865
|
|
|
824
866
|
const markPromptCommitted = () => {
|
|
825
867
|
if (activePromptRestore) {
|
|
@@ -1541,13 +1583,18 @@ export async function createEngineSession({
|
|
|
1541
1583
|
// pending push timers so nothing fires (or leaks) after the turn ends. The
|
|
1542
1584
|
// finalize path above already patches results onto visible cards; this just
|
|
1543
1585
|
// guarantees every registered card is materialized before the turn closes.
|
|
1586
|
+
// Collect (don't emit) the still-deferred cards so the turn-close flush and
|
|
1587
|
+
// the turndone item append in ONE set() below instead of one render bounce
|
|
1588
|
+
// per row. Order/ids are preserved (creation order, then turndone last).
|
|
1589
|
+
let closingItems = [];
|
|
1544
1590
|
if (deferredEntries.length) {
|
|
1545
1591
|
const last = deferredEntries[deferredEntries.length - 1];
|
|
1546
|
-
|
|
1592
|
+
closingItems = collectDeferredUpTo(last);
|
|
1547
1593
|
clearDeferredTimers();
|
|
1548
1594
|
}
|
|
1549
1595
|
flushDeferredBeforeImmediatePush = null;
|
|
1550
|
-
const producedTranscriptItem =
|
|
1596
|
+
const producedTranscriptItem =
|
|
1597
|
+
state.items.length + closingItems.length > itemsAtTurnStart;
|
|
1551
1598
|
const reclaimed = cancelled && activePromptRestore?.reclaimed === true;
|
|
1552
1599
|
if (!isStaleUnwind) activePromptRestore = null;
|
|
1553
1600
|
closeThinkingSegment();
|
|
@@ -1573,6 +1620,9 @@ export async function createEngineSession({
|
|
|
1573
1620
|
&& !assistantOutput
|
|
1574
1621
|
&& !producedTranscriptItem;
|
|
1575
1622
|
if (isStaleUnwind) {
|
|
1623
|
+
// Preserve prior behavior: a stale unwind still materializes its own
|
|
1624
|
+
// deferred cards (turn-local teardown) but writes no other shared state.
|
|
1625
|
+
if (closingItems.length) appendItemsBatch(closingItems);
|
|
1576
1626
|
tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-state writes`);
|
|
1577
1627
|
} else {
|
|
1578
1628
|
if (!isNoOpTurn) {
|
|
@@ -1583,9 +1633,10 @@ export async function createEngineSession({
|
|
|
1583
1633
|
// in scrollback. (Previously TurnDone rendered only in the
|
|
1584
1634
|
// bottom-fixed live-status slot and vanished on the next turn.)
|
|
1585
1635
|
if (!reclaimed && !isNoOpTurn) {
|
|
1586
|
-
|
|
1636
|
+
closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
|
|
1587
1637
|
}
|
|
1588
|
-
set(
|
|
1638
|
+
// Deferred cards + turndone + status all land in ONE set() (one commit).
|
|
1639
|
+
appendItemsBatch(closingItems, {
|
|
1589
1640
|
busy: false,
|
|
1590
1641
|
spinner: null,
|
|
1591
1642
|
thinking: null,
|
|
@@ -1814,8 +1865,8 @@ export async function createEngineSession({
|
|
|
1814
1865
|
return out;
|
|
1815
1866
|
}
|
|
1816
1867
|
|
|
1817
|
-
function restoreLeadSteeringFromDisk() {
|
|
1818
|
-
const rows = drainTuiSteeringPersist(leadSessionId());
|
|
1868
|
+
async function restoreLeadSteeringFromDisk() {
|
|
1869
|
+
const rows = await drainTuiSteeringPersist(leadSessionId());
|
|
1819
1870
|
if (!rows.length) return;
|
|
1820
1871
|
const restored = [];
|
|
1821
1872
|
for (const row of rows) {
|
|
@@ -2017,7 +2068,7 @@ export async function createEngineSession({
|
|
|
2017
2068
|
return state.stats;
|
|
2018
2069
|
};
|
|
2019
2070
|
|
|
2020
|
-
restoreLeadSteeringFromDisk();
|
|
2071
|
+
void restoreLeadSteeringFromDisk();
|
|
2021
2072
|
|
|
2022
2073
|
return {
|
|
2023
2074
|
getState: () => state,
|
|
@@ -2987,7 +3038,7 @@ export async function createEngineSession({
|
|
|
2987
3038
|
...routeState(),
|
|
2988
3039
|
stats: { ...state.stats },
|
|
2989
3040
|
});
|
|
2990
|
-
restoreLeadSteeringFromDisk();
|
|
3041
|
+
void restoreLeadSteeringFromDisk();
|
|
2991
3042
|
return true;
|
|
2992
3043
|
} finally {
|
|
2993
3044
|
set({ commandBusy: false, commandStatus: null });
|
|
@@ -3004,6 +3055,7 @@ export async function createEngineSession({
|
|
|
3004
3055
|
try { unsubscribeRemoteState?.(); } catch {}
|
|
3005
3056
|
unsubscribeRemoteState = null;
|
|
3006
3057
|
denyAllToolApprovals('runtime closing');
|
|
3058
|
+
await flushTuiSteeringPersist();
|
|
3007
3059
|
await runtime.close(reason, options);
|
|
3008
3060
|
listeners.clear();
|
|
3009
3061
|
},
|
package/src/tui/index.jsx
CHANGED
|
@@ -32,38 +32,123 @@ const EXIT_HARD_DELAY_MS = positiveIntEnv('MIXDOG_TUI_HARD_EXIT_DELAY_MS', 500);
|
|
|
32
32
|
const EXIT_HARD_ENABLED = !/^(0|false|no|off)$/i.test(String(process.env.MIXDOG_TUI_HARD_EXIT || '1'));
|
|
33
33
|
const EXIT_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_TUI_EXIT_DEBUG || ''));
|
|
34
34
|
const PERF_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_TUI_PERF || ''));
|
|
35
|
+
const LOOP_PROBE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_TUI_LOOP_PROBE || ''));
|
|
36
|
+
const LOOP_PROBE_INTERVAL_MS = 1000;
|
|
37
|
+
const LOOP_PROBE_DRIFT_THRESHOLD_MS = 200;
|
|
35
38
|
|
|
36
39
|
function positiveIntEnv(name, fallback) {
|
|
37
40
|
const value = Number(process.env[name]);
|
|
38
41
|
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
// Opt-in event-loop stall probe (MIXDOG_TUI_LOOP_PROBE=1). A setInterval that
|
|
45
|
+
// measures its own scheduling drift: if the loop is blocked (sync file lock,
|
|
46
|
+
// heavy render, GC), the callback fires late and drift = actual - expected.
|
|
47
|
+
// Logs `[mixdog-loop] stall=NNNms` via the same stderr surface the TUI guard
|
|
48
|
+
// captures. unref'd so it never keeps the process alive.
|
|
49
|
+
function installTuiLoopProbe() {
|
|
50
|
+
if (!LOOP_PROBE_ENABLED) return () => {};
|
|
51
|
+
let last = performance.now();
|
|
52
|
+
const timer = setInterval(() => {
|
|
53
|
+
const now = performance.now();
|
|
54
|
+
const drift = (now - last) - LOOP_PROBE_INTERVAL_MS;
|
|
55
|
+
last = now;
|
|
56
|
+
if (drift > LOOP_PROBE_DRIFT_THRESHOLD_MS) {
|
|
57
|
+
try { process.stderr.write(`[mixdog-loop] stall=${drift.toFixed(0)}ms\n`); } catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
}, LOOP_PROBE_INTERVAL_MS);
|
|
60
|
+
timer.unref?.();
|
|
61
|
+
return () => { try { clearInterval(timer); } catch { /* ignore */ } };
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
// Lightweight render-frame profiler. Forked ink calls options.onRender with the
|
|
42
65
|
// per-frame render() wall time (renderNodeToOutput serialization). We aggregate
|
|
43
66
|
// and emit a rolling summary every PERF_REPORT_EVERY frames so typing latency
|
|
44
67
|
// can be measured without flooding output. Entirely no-op unless
|
|
45
68
|
// MIXDOG_TUI_PERF=1, so it costs nothing in normal runs.
|
|
46
69
|
const PERF_REPORT_EVERY = positiveIntEnv('MIXDOG_TUI_PERF_EVERY', 60);
|
|
70
|
+
const PERF_STALL_INTERVAL_MS = positiveIntEnv('MIXDOG_TUI_PERF_STALL_INTERVAL_MS', 100);
|
|
71
|
+
const PERF_STALL_THRESHOLD_MS = positiveIntEnv('MIXDOG_TUI_PERF_STALL_MS', 80);
|
|
72
|
+
const PERF_RENDER_GAP_MS = positiveIntEnv('MIXDOG_TUI_PERF_RENDER_GAP_MS', 120);
|
|
73
|
+
|
|
74
|
+
function perfLog(event, fields = {}) {
|
|
75
|
+
if (!PERF_ENABLED) return;
|
|
76
|
+
const elapsedMs = performance.now() - BOOT_PROFILE_START;
|
|
77
|
+
const parts = [`[mixdog-perf] +${elapsedMs.toFixed(1)}ms`, event];
|
|
78
|
+
for (const [key, value] of Object.entries(fields || {})) {
|
|
79
|
+
if (value === undefined || value === null || value === '') continue;
|
|
80
|
+
parts.push(`${key}=${String(value).replace(/\s+/g, '_')}`);
|
|
81
|
+
}
|
|
82
|
+
try { process.stderr.write(`${parts.join(' ')}\n`); } catch { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function installTuiPerfProbe() {
|
|
86
|
+
if (!PERF_ENABLED) return () => {};
|
|
87
|
+
let last = performance.now();
|
|
88
|
+
let lastCpu = process.cpuUsage();
|
|
89
|
+
perfLog('probe:start', {
|
|
90
|
+
intervalMs: PERF_STALL_INTERVAL_MS,
|
|
91
|
+
stallMs: PERF_STALL_THRESHOLD_MS,
|
|
92
|
+
renderGapMs: PERF_RENDER_GAP_MS,
|
|
93
|
+
});
|
|
94
|
+
const timer = setInterval(() => {
|
|
95
|
+
const now = performance.now();
|
|
96
|
+
const elapsed = now - last;
|
|
97
|
+
const lagMs = elapsed - PERF_STALL_INTERVAL_MS;
|
|
98
|
+
const cpu = process.cpuUsage(lastCpu);
|
|
99
|
+
last = now;
|
|
100
|
+
lastCpu = process.cpuUsage();
|
|
101
|
+
if (lagMs < PERF_STALL_THRESHOLD_MS) return;
|
|
102
|
+
const mem = process.memoryUsage();
|
|
103
|
+
perfLog('event-loop-stall', {
|
|
104
|
+
lagMs: lagMs.toFixed(1),
|
|
105
|
+
elapsedMs: elapsed.toFixed(1),
|
|
106
|
+
cpuUserMs: (cpu.user / 1000).toFixed(1),
|
|
107
|
+
cpuSystemMs: (cpu.system / 1000).toFixed(1),
|
|
108
|
+
rssMb: (mem.rss / 1048576).toFixed(1),
|
|
109
|
+
heapMb: (mem.heapUsed / 1048576).toFixed(1),
|
|
110
|
+
});
|
|
111
|
+
}, PERF_STALL_INTERVAL_MS);
|
|
112
|
+
timer.unref?.();
|
|
113
|
+
return () => {
|
|
114
|
+
try { clearInterval(timer); } catch { /* ignore */ }
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
47
118
|
function makeRenderProfiler() {
|
|
48
119
|
if (!PERF_ENABLED) return undefined;
|
|
49
120
|
let count = 0;
|
|
50
121
|
let sum = 0;
|
|
51
122
|
let max = 0;
|
|
52
123
|
let slow = 0;
|
|
124
|
+
let maxGap = 0;
|
|
125
|
+
let lastFrameAt = 0;
|
|
53
126
|
return ({ renderTime } = {}) => {
|
|
127
|
+
const now = performance.now();
|
|
54
128
|
const ms = Number(renderTime) || 0;
|
|
129
|
+
const gap = lastFrameAt ? now - lastFrameAt : 0;
|
|
130
|
+
lastFrameAt = now;
|
|
55
131
|
count += 1;
|
|
56
132
|
sum += ms;
|
|
57
133
|
if (ms > max) max = ms;
|
|
134
|
+
if (gap > maxGap) maxGap = gap;
|
|
58
135
|
if (ms >= 16) slow += 1;
|
|
136
|
+
if (gap >= PERF_RENDER_GAP_MS) {
|
|
137
|
+
perfLog('render-gap', {
|
|
138
|
+
gapMs: gap.toFixed(1),
|
|
139
|
+
renderMs: ms.toFixed(2),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
59
142
|
if (count >= PERF_REPORT_EVERY) {
|
|
60
143
|
const avg = sum / count;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
|
|
144
|
+
perfLog('render-summary', {
|
|
145
|
+
frames: count,
|
|
146
|
+
avgMs: avg.toFixed(2),
|
|
147
|
+
maxMs: max.toFixed(2),
|
|
148
|
+
maxGapMs: maxGap.toFixed(1),
|
|
149
|
+
slow16: slow,
|
|
150
|
+
});
|
|
151
|
+
count = 0; sum = 0; max = 0; slow = 0; maxGap = 0;
|
|
67
152
|
}
|
|
68
153
|
};
|
|
69
154
|
}
|
|
@@ -291,6 +376,8 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
|
|
|
291
376
|
}
|
|
292
377
|
|
|
293
378
|
const restoreStderr = installTuiStderrGuard();
|
|
379
|
+
const stopPerfProbe = installTuiPerfProbe();
|
|
380
|
+
const stopLoopProbe = installTuiLoopProbe();
|
|
294
381
|
const restorePrimedInput = () => drainStdin(process.stdin);
|
|
295
382
|
let restored = false;
|
|
296
383
|
const restoreTerminal = () => {
|
|
@@ -343,6 +430,8 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
|
|
|
343
430
|
bootProfile('store:ready', { ms: (performance.now() - startedAt).toFixed(1) });
|
|
344
431
|
} catch (error) {
|
|
345
432
|
splash.stop();
|
|
433
|
+
stopPerfProbe();
|
|
434
|
+
stopLoopProbe();
|
|
346
435
|
restoreTerminal();
|
|
347
436
|
try { process.off('exit', restoreTerminal); } catch { /* ignore */ }
|
|
348
437
|
restoreStderr();
|
|
@@ -456,6 +545,8 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
|
|
|
456
545
|
}
|
|
457
546
|
await waitUntilExit();
|
|
458
547
|
} finally {
|
|
548
|
+
stopPerfProbe();
|
|
549
|
+
stopLoopProbe();
|
|
459
550
|
clearInterval(uiHeartbeatTimer);
|
|
460
551
|
for (const [stream, event, handler] of stdioDeathListeners.splice(0)) {
|
|
461
552
|
try { stream.off(event, handler); } catch { /* ignore */ }
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/ui/statusline-segments.mjs — filesystem-backed L2 segment sources.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The shell-jobs segment (owner-scoped job scan + liveness) and the
|
|
5
|
+
* memory-cycle segment (daemon state file) each keep a 1s process-local
|
|
6
|
+
* cache. Render calls (`shellJobsStatus`/`memoryCycleStatus`) are pure cache
|
|
7
|
+
* reads and NEVER touch the filesystem synchronously: on cache expiry they
|
|
8
|
+
* return the last known value immediately and kick a background
|
|
9
|
+
* fs/promises refresh (guarded against overlap) that updates the cache for
|
|
10
|
+
* the next render tick. This keeps the 500ms render tick off sync fs I/O.
|
|
7
11
|
*/
|
|
8
|
-
import {
|
|
12
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
9
13
|
import { homedir } from 'node:os';
|
|
10
14
|
import { join } from 'node:path';
|
|
11
15
|
import { formatElapsed } from './statusline-format.mjs';
|
|
@@ -15,13 +19,25 @@ const DEFAULT_STANDALONE_DATA_DIR = join(DEFAULT_MIXDOG_HOME, 'data');
|
|
|
15
19
|
const SHELL_JOBS_SEGMENT_CACHE_MS = 1000;
|
|
16
20
|
|
|
17
21
|
let _shellJobsSegmentCache = { ownerPid: 0, at: 0, value: { count: 0, elapsedLabel: '' } };
|
|
22
|
+
let _shellJobsRefreshInFlight = false;
|
|
18
23
|
|
|
19
24
|
function dataDir() {
|
|
20
25
|
return process.env.MIXDOG_DATA_DIR || DEFAULT_STANDALONE_DATA_DIR;
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
// Render-path entry point: synchronous, cache-only. Never blocks on fs.
|
|
23
29
|
export function shellJobsStatus({ clientHostPid } = {}) {
|
|
24
|
-
|
|
30
|
+
const ownerPid = positiveInt(clientHostPid);
|
|
31
|
+
const empty = { count: 0, elapsedLabel: '' };
|
|
32
|
+
if (!ownerPid) return empty;
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const sameOwner = _shellJobsSegmentCache.ownerPid === ownerPid;
|
|
35
|
+
const fresh = sameOwner && now - _shellJobsSegmentCache.at < SHELL_JOBS_SEGMENT_CACHE_MS;
|
|
36
|
+
if (!fresh && !_shellJobsRefreshInFlight) {
|
|
37
|
+
_shellJobsRefreshInFlight = true;
|
|
38
|
+
refreshShellJobsStatus(ownerPid).finally(() => { _shellJobsRefreshInFlight = false; });
|
|
39
|
+
}
|
|
40
|
+
return sameOwner ? (_shellJobsSegmentCache.value || empty) : empty;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
// Memory cycle L2 segment source. The memory daemon writes
|
|
@@ -30,23 +46,32 @@ export function shellJobsStatus({ clientHostPid } = {}) {
|
|
|
30
46
|
// the same 1s cadence as the shell segment. Shows a single unified "Memory"
|
|
31
47
|
// segment: running (spinner + elapsed) or backlog warning (yellow count).
|
|
32
48
|
let _memoryCycleSegmentCache = { at: 0, value: null };
|
|
49
|
+
let _memoryCycleRefreshInFlight = false;
|
|
33
50
|
const MEMORY_CYCLE_SEGMENT_CACHE_MS = 1000;
|
|
34
51
|
const MEMORY_CYCLE_BACKLOG_WARN = 500;
|
|
52
|
+
|
|
53
|
+
// Render-path entry point: synchronous, cache-only. Never blocks on fs.
|
|
35
54
|
export function memoryCycleStatus() {
|
|
36
55
|
const now = Date.now();
|
|
37
|
-
if (now - _memoryCycleSegmentCache.at
|
|
38
|
-
|
|
56
|
+
if (now - _memoryCycleSegmentCache.at >= MEMORY_CYCLE_SEGMENT_CACHE_MS && !_memoryCycleRefreshInFlight) {
|
|
57
|
+
_memoryCycleRefreshInFlight = true;
|
|
58
|
+
refreshMemoryCycleStatus().finally(() => { _memoryCycleRefreshInFlight = false; });
|
|
39
59
|
}
|
|
60
|
+
return _memoryCycleSegmentCache.value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function refreshMemoryCycleStatus() {
|
|
40
64
|
let value = null;
|
|
41
65
|
try {
|
|
42
66
|
const p = join(dataDir(), 'memory-cycle-state.json');
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
const raw = await readFile(p, 'utf-8').catch(() => null);
|
|
68
|
+
if (raw != null) {
|
|
69
|
+
const state = JSON.parse(raw);
|
|
45
70
|
const running = state?.running || null;
|
|
46
71
|
const backlog = state?.backlog || {};
|
|
47
72
|
// Stale-file guard: a daemon that died mid-run leaves running set —
|
|
48
73
|
// ignore anything not refreshed in the last 10 minutes.
|
|
49
|
-
const fresh = Number(state?.updatedAt) > now - 10 * 60_000;
|
|
74
|
+
const fresh = Number(state?.updatedAt) > Date.now() - 10 * 60_000;
|
|
50
75
|
if (fresh && running?.cycle && Number(running.started_at) > 0) {
|
|
51
76
|
value = { kind: 'running', startedAt: Number(running.started_at) };
|
|
52
77
|
} else if (fresh) {
|
|
@@ -55,26 +80,21 @@ export function memoryCycleStatus() {
|
|
|
55
80
|
}
|
|
56
81
|
}
|
|
57
82
|
} catch { value = null; }
|
|
58
|
-
_memoryCycleSegmentCache = { at: now, value };
|
|
59
|
-
return value;
|
|
83
|
+
_memoryCycleSegmentCache = { at: Date.now(), value };
|
|
60
84
|
}
|
|
61
85
|
|
|
62
|
-
function
|
|
63
|
-
const ownerPid = positiveInt(clientHostPid);
|
|
86
|
+
async function refreshShellJobsStatus(ownerPid) {
|
|
64
87
|
const empty = { count: 0, elapsedLabel: '' };
|
|
65
|
-
if (!ownerPid) return empty;
|
|
66
|
-
const now = Date.now();
|
|
67
|
-
if (_shellJobsSegmentCache.ownerPid === ownerPid && now - _shellJobsSegmentCache.at < SHELL_JOBS_SEGMENT_CACHE_MS) {
|
|
68
|
-
return _shellJobsSegmentCache.value || empty;
|
|
69
|
-
}
|
|
70
88
|
let value = empty;
|
|
71
89
|
try {
|
|
72
90
|
const dir = join(dataDir(), 'shell-jobs');
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = await readdir(dir);
|
|
94
|
+
} catch {
|
|
95
|
+
_shellJobsSegmentCache = { ownerPid, at: Date.now(), value };
|
|
96
|
+
return;
|
|
76
97
|
}
|
|
77
|
-
const names = readdirSync(dir);
|
|
78
98
|
const done = new Set(names.filter((n) => n.endsWith('.done')).map((n) => n.slice(0, -5)));
|
|
79
99
|
const ownerByJob = new Map();
|
|
80
100
|
for (const n of names) {
|
|
@@ -95,34 +115,32 @@ function _shellJobsStatusImpl({ clientHostPid } = {}) {
|
|
|
95
115
|
for (const id of ids) {
|
|
96
116
|
const p = join(dir, `${id}.json`);
|
|
97
117
|
let detail;
|
|
98
|
-
try { detail = JSON.parse(
|
|
99
|
-
if (!isShellJobAlive(detail, p, dir, id)) continue;
|
|
118
|
+
try { detail = JSON.parse(await readFile(p, 'utf-8')); } catch { continue; }
|
|
119
|
+
if (!(await isShellJobAlive(detail, p, dir, id))) continue;
|
|
100
120
|
count++;
|
|
101
121
|
try {
|
|
102
|
-
const st =
|
|
122
|
+
const st = await stat(p);
|
|
103
123
|
if (st.mtimeMs < oldestMs) oldestMs = st.mtimeMs;
|
|
104
124
|
} catch {}
|
|
105
125
|
}
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
126
|
+
if (count) {
|
|
127
|
+
const elapsedLabel = Number.isFinite(oldestMs) ? formatElapsed(Date.now() - oldestMs) : '';
|
|
128
|
+
value = { count, elapsedLabel };
|
|
109
129
|
}
|
|
110
|
-
const elapsedLabel = Number.isFinite(oldestMs) ? formatElapsed(Date.now() - oldestMs) : '';
|
|
111
|
-
value = { count, elapsedLabel };
|
|
112
130
|
} catch {
|
|
113
131
|
value = empty;
|
|
114
132
|
}
|
|
115
|
-
_shellJobsSegmentCache = { ownerPid, at: now, value };
|
|
116
|
-
return value;
|
|
133
|
+
_shellJobsSegmentCache = { ownerPid, at: Date.now(), value };
|
|
117
134
|
}
|
|
118
135
|
|
|
119
|
-
function isShellJobAlive(detail, detailPath, dir, id) {
|
|
136
|
+
async function isShellJobAlive(detail, detailPath, dir, id) {
|
|
120
137
|
const pid = positiveInt(detail?.pid);
|
|
121
138
|
if (!pid) return false;
|
|
122
139
|
try {
|
|
123
|
-
const st =
|
|
140
|
+
const st = await stat(detailPath);
|
|
124
141
|
const timeoutMs = Number(detail?.timeoutMs);
|
|
125
|
-
const enforced = detail?.timeoutEnforced === true
|
|
142
|
+
const enforced = detail?.timeoutEnforced === true
|
|
143
|
+
|| await stat(join(dir, `${id}.enforced`)).then(() => true, () => false);
|
|
126
144
|
if (enforced && Number.isFinite(timeoutMs) && timeoutMs > 0 && Date.now() - st.mtimeMs > timeoutMs + 30 * 60_000) {
|
|
127
145
|
return false;
|
|
128
146
|
}
|