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.
Files changed (44) hide show
  1. package/package.json +2 -1
  2. package/scripts/atomic-lock-tryonce-test.mjs +66 -0
  3. package/scripts/bench/cache-probe-tasks.json +1 -1
  4. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  5. package/scripts/bench/r4-mixed-tasks.json +1 -1
  6. package/scripts/bench/review-tasks.json +1 -1
  7. package/scripts/build-runtime-windows.ps1 +242 -242
  8. package/scripts/provider-toolcall-test.mjs +79 -2
  9. package/scripts/recall-usecase-cases.json +1 -1
  10. package/scripts/smoke-runtime-negative.ps1 +106 -106
  11. package/scripts/tool-efficiency-diag.mjs +1 -1
  12. package/src/mixdog-session-runtime.mjs +12 -0
  13. package/src/rules/lead/02-channels.md +3 -3
  14. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
  15. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
  16. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
  18. package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
  20. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
  21. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
  22. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
  23. package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
  24. package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
  25. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
  27. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
  28. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
  29. package/src/runtime/channels/lib/runtime-paths.mjs +8 -19
  30. package/src/runtime/memory/index.mjs +37 -0
  31. package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
  32. package/src/runtime/memory/lib/ko-morph.mjs +1 -0
  33. package/src/runtime/shared/atomic-file.mjs +110 -0
  34. package/src/runtime/shared/transcript-writer.mjs +46 -4
  35. package/src/session-runtime/provider-models.mjs +47 -8
  36. package/src/tui/app/transcript-window.mjs +115 -6
  37. package/src/tui/app/use-transcript-window.mjs +58 -7
  38. package/src/tui/components/StatusLine.jsx +1 -1
  39. package/src/tui/dist/index.mjs +373 -81
  40. package/src/tui/engine/tui-steering-persist.mjs +66 -35
  41. package/src/tui/engine.mjs +6 -4
  42. package/src/tui/index.jsx +97 -6
  43. package/src/ui/statusline-segments.mjs +54 -36
  44. 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 { updateJsonAtomicSync } from '../../runtime/shared/atomic-file.mjs';
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
- try {
104
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
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
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
114
- } catch (err) {
115
- try { process.stderr.write(`[tui] steering-queue append failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
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
- try {
125
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
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
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
141
- } catch (err) {
142
- try { process.stderr.write(`[tui] steering-queue drop failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
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
- let drained = [];
160
- try {
161
- updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
162
- const next = normalizePendingStore(raw);
163
- const q = Array.isArray(next.sessions[key]) ? next.sessions[key] : [];
164
- drained = q.map(drainedRowToRestore).filter(Boolean);
165
- if (drained.length === 0) return undefined;
166
- delete next.sessions[key];
167
- if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
168
- next.updatedAt = Date.now();
169
- return next;
170
- }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
171
- } catch (err) {
172
- try { process.stderr.write(`[tui] steering-queue drain failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
173
- }
174
- return drained;
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
+ }
@@ -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
- try {
62
- process.stderr.write(
63
- `[mixdog-perf] frames=${count} avg=${avg.toFixed(2)}ms max=${max.toFixed(2)}ms slow16+=${slow}\n`,
64
- );
65
- } catch { /* ignore */ }
66
- count = 0; sum = 0; max = 0; slow = 0;
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
- * Extracted verbatim from statusline.mjs: the shell-jobs segment (owner-scoped
5
- * job scan + liveness) and the memory-cycle segment (daemon state file). Both
6
- * keep their own 1s process-local caches. No behavior change.
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 { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
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
- return _shellJobsStatusImpl({ clientHostPid });
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 < MEMORY_CYCLE_SEGMENT_CACHE_MS) {
38
- return _memoryCycleSegmentCache.value;
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
- if (existsSync(p)) {
44
- const state = JSON.parse(readFileSync(p, 'utf-8'));
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 _shellJobsStatusImpl({ clientHostPid } = {}) {
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
- if (!existsSync(dir)) {
74
- _shellJobsSegmentCache = { ownerPid, at: now, value };
75
- return value;
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(readFileSync(p, 'utf-8')); } catch { continue; }
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 = statSync(p);
122
+ const st = await stat(p);
103
123
  if (st.mtimeMs < oldestMs) oldestMs = st.mtimeMs;
104
124
  } catch {}
105
125
  }
106
- if (!count) {
107
- _shellJobsSegmentCache = { ownerPid, at: now, value };
108
- return value;
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 = statSync(detailPath);
140
+ const st = await stat(detailPath);
124
141
  const timeoutMs = Number(detail?.timeoutMs);
125
- const enforced = detail?.timeoutEnforced === true || existsSync(join(dir, `${id}.enforced`));
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
  }