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.
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
59
|
+
return _queueHeartbeatOperation(id, () => fsp.writeFile(target, `${now}\n`, 'utf8'));
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
export function deleteHeartbeat(id) {
|
|
50
|
-
|
|
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`
|