mixdog 0.9.60 → 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 +3 -3
- package/scripts/session-heartbeat-lifecycle-test.mjs +68 -0
- package/scripts/tui-transcript-perf-test.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +14 -8
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +24 -4
- package/src/tui/dist/index.mjs +3 -3
- package/src/tui/engine/turn.mjs +7 -3
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
|
+
});
|
|
@@ -409,6 +409,32 @@ test('abort before a newline settles the accumulated partial response', async ()
|
|
|
409
409
|
assert.equal(assistants[0].text, 'partial without newline');
|
|
410
410
|
});
|
|
411
411
|
|
|
412
|
+
test('abort after tool boundaries does not replay committed progress as one giant assistant row', async () => {
|
|
413
|
+
const harness = makeTurnHarness(async (_text, options) => {
|
|
414
|
+
options.onAssistantText('first progress');
|
|
415
|
+
await options.onToolCall(1, [{
|
|
416
|
+
id: 'call_1',
|
|
417
|
+
name: 'shell',
|
|
418
|
+
input: { command: 'first' },
|
|
419
|
+
}]);
|
|
420
|
+
options.onToolResult({ tool_call_id: 'call_1', content: 'ok' });
|
|
421
|
+
options.onAssistantText('second progress');
|
|
422
|
+
await options.onToolCall(2, [{
|
|
423
|
+
id: 'call_2',
|
|
424
|
+
name: 'shell',
|
|
425
|
+
input: { command: 'second' },
|
|
426
|
+
}]);
|
|
427
|
+
const error = new Error('interrupted');
|
|
428
|
+
error.name = 'SessionClosedError';
|
|
429
|
+
throw error;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
assert.equal(await harness.runTurn('go'), 'cancelled');
|
|
433
|
+
assert.equal(harness.getState().streamingTail, null);
|
|
434
|
+
const assistants = harness.getState().items.filter((item) => item.kind === 'assistant');
|
|
435
|
+
assert.deepEqual(assistants.map((item) => item.text), ['first progress', 'second progress']);
|
|
436
|
+
});
|
|
437
|
+
|
|
412
438
|
// Codifies pre-existing parity: the old in-items completed-line snapshot survived starved recovery.
|
|
413
439
|
test('starved Esc recovery settles a non-empty tail exactly once', async () => {
|
|
414
440
|
let state = {
|
|
@@ -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`
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -27537,9 +27537,9 @@ function createRunTurn(bag) {
|
|
|
27537
27537
|
flushStreamBatch();
|
|
27538
27538
|
if (error?.name === "SessionClosedError") {
|
|
27539
27539
|
cancelled = true;
|
|
27540
|
-
if (
|
|
27541
|
-
const id = currentAssistantId || ensureAssistant(
|
|
27542
|
-
settleStreamingTail(id, { text: currentAssistantText
|
|
27540
|
+
if (currentAssistantText.trim()) {
|
|
27541
|
+
const id = currentAssistantId || ensureAssistant(currentAssistantText);
|
|
27542
|
+
settleStreamingTail(id, { text: currentAssistantText });
|
|
27543
27543
|
}
|
|
27544
27544
|
flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
|
|
27545
27545
|
finalizeToolHeaders();
|
package/src/tui/engine/turn.mjs
CHANGED
|
@@ -1278,9 +1278,13 @@ export function createRunTurn(bag) {
|
|
|
1278
1278
|
flushStreamBatch(); // ensure any batched text lands before the error notice
|
|
1279
1279
|
if (error?.name === 'SessionClosedError') {
|
|
1280
1280
|
cancelled = true;
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1281
|
+
// Tool boundaries already sealed prior progress segments into their
|
|
1282
|
+
// own rows. On abort, preserve only the still-open segment; replaying
|
|
1283
|
+
// turn-global assistantText creates one giant duplicate after the
|
|
1284
|
+
// cancelled tool card.
|
|
1285
|
+
if (currentAssistantText.trim()) {
|
|
1286
|
+
const id = currentAssistantId || ensureAssistant(currentAssistantText);
|
|
1287
|
+
settleStreamingTail(id, { text: currentAssistantText });
|
|
1284
1288
|
}
|
|
1285
1289
|
// Finalize pending tool cards so they don't stay "Running..." forever
|
|
1286
1290
|
// after cancellation. Without this, the spinner vanishes and TurnDone
|