mixdog 0.9.16 → 0.9.17
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/bench/cache-probe-tasks.json +1 -1
- package/scripts/bench/lead-review-tasks-r3.json +1 -1
- package/scripts/bench/r4-mixed-tasks.json +1 -1
- package/scripts/bench/review-tasks.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/provider-toolcall-test.mjs +79 -2
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/src/mixdog-session-runtime.mjs +12 -0
- package/src/rules/lead/02-channels.md +3 -3
- 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/lib/runtime-paths.mjs +8 -19
- 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/tui/app/transcript-window.mjs +115 -6
- package/src/tui/app/use-transcript-window.mjs +58 -7
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/dist/index.mjs +373 -81
- package/src/tui/engine/tui-steering-persist.mjs +66 -35
- package/src/tui/engine.mjs +6 -4
- 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
|
|
@@ -1814,8 +1815,8 @@ export async function createEngineSession({
|
|
|
1814
1815
|
return out;
|
|
1815
1816
|
}
|
|
1816
1817
|
|
|
1817
|
-
function restoreLeadSteeringFromDisk() {
|
|
1818
|
-
const rows = drainTuiSteeringPersist(leadSessionId());
|
|
1818
|
+
async function restoreLeadSteeringFromDisk() {
|
|
1819
|
+
const rows = await drainTuiSteeringPersist(leadSessionId());
|
|
1819
1820
|
if (!rows.length) return;
|
|
1820
1821
|
const restored = [];
|
|
1821
1822
|
for (const row of rows) {
|
|
@@ -2017,7 +2018,7 @@ export async function createEngineSession({
|
|
|
2017
2018
|
return state.stats;
|
|
2018
2019
|
};
|
|
2019
2020
|
|
|
2020
|
-
restoreLeadSteeringFromDisk();
|
|
2021
|
+
void restoreLeadSteeringFromDisk();
|
|
2021
2022
|
|
|
2022
2023
|
return {
|
|
2023
2024
|
getState: () => state,
|
|
@@ -2987,7 +2988,7 @@ export async function createEngineSession({
|
|
|
2987
2988
|
...routeState(),
|
|
2988
2989
|
stats: { ...state.stats },
|
|
2989
2990
|
});
|
|
2990
|
-
restoreLeadSteeringFromDisk();
|
|
2991
|
+
void restoreLeadSteeringFromDisk();
|
|
2991
2992
|
return true;
|
|
2992
2993
|
} finally {
|
|
2993
2994
|
set({ commandBusy: false, commandStatus: null });
|
|
@@ -3004,6 +3005,7 @@ export async function createEngineSession({
|
|
|
3004
3005
|
try { unsubscribeRemoteState?.(); } catch {}
|
|
3005
3006
|
unsubscribeRemoteState = null;
|
|
3006
3007
|
denyAllToolApprovals('runtime closing');
|
|
3008
|
+
await flushTuiSteeringPersist();
|
|
3007
3009
|
await runtime.close(reason, options);
|
|
3008
3010
|
listeners.clear();
|
|
3009
3011
|
},
|
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
|
}
|