mixdog 0.9.61 → 0.9.62

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.61",
3
+ "version": "0.9.62",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -72,10 +72,10 @@
72
72
  "test:tui-streaming-window": "node --test scripts/streaming-tail-window-test.mjs",
73
73
  "test:tui-ambiguous-width": "node --test scripts/tui-ambiguous-width-test.mjs",
74
74
  "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/verify-release-assets-test.mjs && node --test scripts/verify-release-assets-test.mjs",
75
- "test:release-focused": "npm run test:release-assets && npm run test:patch-binary-cache && npm run test:providers && npm run test:deferred-tools && npm run smoke:compact && node --test scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs && npm run test:shellhardening && node --test scripts/windows-hide-spawn-options-test.mjs && node --test scripts/tui-transcript-perf-test.mjs",
75
+ "test:release-focused": "npm run test:release-assets && npm run test:patch-binary-cache && npm run test:providers && npm run test:deferred-tools && npm run smoke:compact && node --test scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs && npm run test:shellhardening && node --test scripts/windows-hide-spawn-options-test.mjs && npm run test:session && node --test scripts/tui-transcript-perf-test.mjs",
76
76
  "test:native-edit-wire": "node --test scripts/native-edit-wire-test.mjs",
77
77
  "test:patch-binary-cache": "node --test scripts/patch-binary-cache-test.mjs",
78
- "test:session": "node --test scripts/session-orphan-sweep-test.mjs scripts/interrupted-turn-history-test.mjs",
78
+ "test:session": "node --test scripts/session-orphan-sweep-test.mjs scripts/interrupted-turn-history-test.mjs scripts/session-heartbeat-lifecycle-test.mjs",
79
79
  "test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs",
80
80
  "failures": "node scripts/tool-failures.mjs",
81
81
  "trace:llm": "node scripts/llm-trace-summary.mjs",
@@ -0,0 +1,68 @@
1
+ import test, { after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdtemp, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ const originalDataDir = process.env.MIXDOG_DATA_DIR;
9
+ const dataDir = await mkdtemp(join(tmpdir(), 'mixdog-heartbeat-lifecycle-'));
10
+ process.env.MIXDOG_DATA_DIR = dataDir;
11
+
12
+ const {
13
+ deleteHeartbeat,
14
+ listSessionHeartbeatMtimes,
15
+ publishHeartbeat,
16
+ } = await import('../src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs');
17
+ const {
18
+ _clearSessionRuntime,
19
+ _getRuntimeEntry,
20
+ markSessionAskStart,
21
+ markSessionDone,
22
+ markSessionStreamDelta,
23
+ markSessionToolCall,
24
+ markSessionTransportActivity,
25
+ } = await import('../src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs');
26
+
27
+ const heartbeatPath = (id) => join(dataDir, 'sessions', `${id}.hb`);
28
+
29
+ after(async () => {
30
+ if (originalDataDir === undefined) delete process.env.MIXDOG_DATA_DIR;
31
+ else process.env.MIXDOG_DATA_DIR = originalDataDir;
32
+ await rm(dataDir, { recursive: true, force: true });
33
+ });
34
+
35
+ test('heartbeat deletion wins over an already queued write', async () => {
36
+ const id = `heartbeat_delete_race_${Date.now()}`;
37
+ const write = publishHeartbeat(id, Date.now());
38
+ const deletion = deleteHeartbeat(id);
39
+ await Promise.all([write, deletion]);
40
+
41
+ assert.equal(existsSync(heartbeatPath(id)), false);
42
+ assert.equal(listSessionHeartbeatMtimes().has(id), false);
43
+
44
+ await publishHeartbeat(id, Date.now() + 10_000);
45
+ assert.equal(existsSync(heartbeatPath(id)), true, 'a later real turn can publish again');
46
+ await deleteHeartbeat(id);
47
+ assert.equal(existsSync(heartbeatPath(id)), false);
48
+ });
49
+
50
+ test('terminal sessions ignore late transport, stream, and tool callbacks', async () => {
51
+ const id = `heartbeat_terminal_${Date.now()}`;
52
+ await markSessionAskStart(id);
53
+ assert.equal(existsSync(heartbeatPath(id)), true);
54
+
55
+ await markSessionDone(id);
56
+ assert.equal(_getRuntimeEntry(id)?.stage, 'done');
57
+ assert.equal(existsSync(heartbeatPath(id)), false);
58
+
59
+ markSessionTransportActivity(id);
60
+ await markSessionStreamDelta(id, 'text');
61
+ await markSessionToolCall(id, 'shell');
62
+ await new Promise((resolve) => setImmediate(resolve));
63
+
64
+ assert.equal(existsSync(heartbeatPath(id)), false);
65
+ assert.equal(listSessionHeartbeatMtimes().has(id), false,
66
+ 'desktop session catalog must not receive a working marker after completion');
67
+ _clearSessionRuntime(id);
68
+ });
@@ -156,7 +156,7 @@ export function markSessionAskStart(id) {
156
156
  // statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
157
157
  // still drops a session whose provider truly never returns a chunk;
158
158
  // markSessionStreamDelta keeps refreshing once chunks arrive.
159
- publishHeartbeat(id, now);
159
+ return publishHeartbeat(id, now);
160
160
  }
161
161
  export function enableSessionTransportTracking(id) {
162
162
  if (!id) return;
@@ -173,7 +173,7 @@ export function disableSessionTransportTracking(id) {
173
173
  export function markSessionTransportActivity(id) {
174
174
  if (!id) return;
175
175
  const entry = _runtimeState.get(id);
176
- if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
176
+ if (!entry || entry.closed || entry.controller?.signal?.aborted || TERMINAL_STAGES.has(entry.stage)) return;
177
177
  const now = Date.now();
178
178
  entry.lastTransportAt = now;
179
179
  entry.updatedAt = now;
@@ -199,7 +199,7 @@ export async function markSessionStreamDelta(id, kind = 'semantic') {
199
199
  // heartbeat indefinitely (the disk tombstone blocks ask resumption but not this
200
200
  // path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
201
201
  const entry = _runtimeState.get(id);
202
- if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
202
+ if (!entry || entry.closed || entry.controller?.signal?.aborted || TERMINAL_STAGES.has(entry.stage)) return;
203
203
  const progressKind = _normalizeModelProgressKind(kind);
204
204
  if (progressKind === 'transport') {
205
205
  markSessionTransportActivity(id);
@@ -237,7 +237,10 @@ export async function markSessionStreamDelta(id, kind = 'semantic') {
237
237
  }
238
238
  export function markSessionToolCall(id, toolName, selfDeadlineMs) {
239
239
  if (!id) return;
240
- const entry = _touchRuntime(id);
240
+ // A real tool call always follows markSessionAskStart. Never create or
241
+ // revive a runtime from a late provider callback after the turn settled.
242
+ const entry = _runtimeState.get(id);
243
+ if (!entry || entry.closed || entry.controller?.signal?.aborted || TERMINAL_STAGES.has(entry.stage)) return;
241
244
  entry.stage = 'tool_running';
242
245
  entry.lastToolCall = toolName || null;
243
246
  // A new tool call invalidates the previous call's live-output tail.
@@ -258,8 +261,9 @@ export function markSessionToolCall(id, toolName, selfDeadlineMs) {
258
261
  entry.lastToolProtocolAt = entry.toolStartedAt;
259
262
  entry.lastProgressAt = entry.toolStartedAt;
260
263
  entry.updatedAt = entry.toolStartedAt;
261
- publishHeartbeat(id, entry.toolStartedAt);
264
+ const heartbeat = publishHeartbeat(id, entry.toolStartedAt);
262
265
  _startToolActivityHeartbeat(id);
266
+ return heartbeat;
263
267
  }
264
268
  // Live shell-output tail for the CURRENT running tool. Written by the shell
265
269
  // tool's onOutputTail timer (~1 s cadence), read by engine transcript
@@ -298,7 +302,7 @@ export function markSessionDone(id, { empty = false } = {}) {
298
302
  // Terminal stage — drop the heartbeat so the status badge releases
299
303
  // immediately. A subsequent ask on the same session re-publishes via
300
304
  // markSessionStreamDelta on the first chunk.
301
- deleteHeartbeat(id);
305
+ return deleteHeartbeat(id);
302
306
  }
303
307
  // Tag a session as having completed with empty final synthesis (no
304
308
  // content/reasoning). Distinct from `markSessionDone`: still a success
@@ -327,8 +331,9 @@ export function markSessionError(id, msg) {
327
331
  entry.doneAt = errTs;
328
332
  entry.lastProgressAt = errTs;
329
333
  entry.updatedAt = errTs;
330
- deleteHeartbeat(id);
334
+ const heartbeatDeleted = deleteHeartbeat(id);
331
335
  _unlinkParentAbortListener(entry);
336
+ return heartbeatDeleted;
332
337
  }
333
338
  export function markSessionCancelled(id) {
334
339
  if (!id) return;
@@ -344,8 +349,9 @@ export function markSessionCancelled(id) {
344
349
  entry.doneAt = doneTs;
345
350
  entry.lastProgressAt = doneTs;
346
351
  entry.updatedAt = doneTs;
347
- deleteHeartbeat(id);
352
+ const heartbeatDeleted = deleteHeartbeat(id);
348
353
  _unlinkParentAbortListener(entry);
354
+ return heartbeatDeleted;
349
355
  }
350
356
  export function getSessionRuntime(id) {
351
357
  return id ? (_runtimeState.get(id) || null) : null;
@@ -28,6 +28,17 @@ export function sessionPath(id) {
28
28
  // directory and matches `<id>.hb` to `<id>.json`.
29
29
  const _HEARTBEAT_THROTTLE_MS = 5_000;
30
30
  const _hbLastAt = new Map();
31
+ const _hbOperations = new Map();
32
+
33
+ function _queueHeartbeatOperation(id, operation) {
34
+ const prior = _hbOperations.get(id) || Promise.resolve();
35
+ const next = prior.then(operation, operation).catch(() => {});
36
+ _hbOperations.set(id, next);
37
+ void next.then(() => {
38
+ if (_hbOperations.get(id) === next) _hbOperations.delete(id);
39
+ });
40
+ return next;
41
+ }
31
42
 
32
43
  function _heartbeatPath(id) {
33
44
  if (!id || typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
@@ -37,18 +48,27 @@ function _heartbeatPath(id) {
37
48
  }
38
49
 
39
50
  export function publishHeartbeat(id, ts) {
40
- if (!id) return;
51
+ if (!id) return Promise.resolve();
41
52
  const now = ts || Date.now();
42
53
  const last = _hbLastAt.get(id) || 0;
43
- if (now - last < _HEARTBEAT_THROTTLE_MS) return;
54
+ if (now - last < _HEARTBEAT_THROTTLE_MS) {
55
+ return _hbOperations.get(id) || Promise.resolve();
56
+ }
44
57
  const target = _heartbeatPath(id);
45
58
  _hbLastAt.set(id, now);
46
- void fsp.writeFile(target, `${now}\n`, 'utf8').catch(() => {});
59
+ return _queueHeartbeatOperation(id, () => fsp.writeFile(target, `${now}\n`, 'utf8'));
47
60
  }
48
61
 
49
62
  export function deleteHeartbeat(id) {
50
- try { unlinkSync(_heartbeatPath(id)); } catch { /* ignore */ }
63
+ if (!id) return Promise.resolve();
64
+ const target = _heartbeatPath(id);
65
+ // Remove the visible marker immediately, then queue a second delete behind
66
+ // any pending write. Without the ordered cleanup, a write started just
67
+ // before turn completion can land after this unlink and resurrect the
68
+ // session's working badge for the full freshness window.
69
+ try { unlinkSync(target); } catch { /* ignore */ }
51
70
  _hbLastAt.delete(id);
71
+ return _queueHeartbeatOperation(id, () => fsp.unlink(target).catch(() => {}));
52
72
  }
53
73
 
54
74
  // Batch reader for session catalogs. Scanning only the handful of `.hb`