mixdog 0.9.151 → 0.9.152
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/README.md +110 -68
- package/package.json +3 -2
- package/scripts/tool-stress.mjs +0 -2
- package/src/defaults/skills/setup/SKILL.md +18 -27
- package/src/headless-exec.mjs +3 -10
- package/src/headless-exec.test.mjs +2 -7
- package/src/runtime/agent/orchestrator/config.mjs +6 -66
- package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
- package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
- package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
- package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
- package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
- package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/browser-bridge/client.mjs +103 -0
- package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
- package/src/runtime/computer-bridge/client.mjs +101 -0
- package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
- package/src/runtime/media/store.mjs +18 -8
- package/src/runtime/media/store.test.mjs +14 -0
- package/src/runtime/memory/index.mjs +1 -1
- package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
- package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
- package/src/runtime/shared/child-spawn-gate.mjs +17 -0
- package/src/runtime/shared/child-spawn-remote.mjs +69 -7
- package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
- package/src/runtime/shared/pristine-execution-contract.json +2 -1
- package/src/runtime/shared/provider-api-key.mjs +1 -0
- package/src/runtime/shared/session-runtime-health.mjs +91 -0
- package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
- package/src/runtime/shared/skill-document.mjs +86 -0
- package/src/runtime/shared/skill-document.test.mjs +81 -0
- package/src/runtime/shared/tool-surface.mjs +18 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
- package/src/runtime/shared/turn-snapshot.mjs +32 -9
- package/src/runtime/shared/user-cwd.mjs +33 -14
- package/src/runtime/shared/user-cwd.test.mjs +43 -0
- package/src/session-runtime/cwd-plugins.mjs +26 -26
- package/src/session-runtime/cwd-plugins.test.mjs +75 -0
- package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
- package/src/session-runtime/global-extensions.mjs +26 -0
- package/src/session-runtime/global-extensions.test.mjs +28 -0
- package/src/session-runtime/goal-runtime.mjs +721 -0
- package/src/session-runtime/goal-runtime.test.mjs +108 -0
- package/src/session-runtime/lifecycle-api.mjs +18 -4
- package/src/session-runtime/lifecycle-api.test.mjs +21 -0
- package/src/session-runtime/mcp-glue.mjs +49 -47
- package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
- package/src/session-runtime/plugin-mcp.mjs +96 -22
- package/src/session-runtime/prewarm.mjs +1 -10
- package/src/session-runtime/resource-api-global.test.mjs +69 -0
- package/src/session-runtime/resource-api.mjs +173 -58
- package/src/session-runtime/runtime-core.mjs +148 -20
- package/src/session-runtime/runtime-tunables.mjs +6 -8
- package/src/session-runtime/session-turn-api.mjs +22 -11
- package/src/session-runtime/skills-api.mjs +79 -25
- package/src/session-runtime/skills-api.test.mjs +99 -0
- package/src/session-runtime/tool-catalog-schema.mjs +0 -5
- package/src/session-runtime/tool-catalog.mjs +4 -13
- package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
- package/src/session-runtime/tool-surface.mjs +12 -0
- package/src/standalone/agent-dispatch-broker.mjs +5 -9
- package/src/standalone/agent-tool/notify.mjs +6 -8
- package/src/standalone/agent-tool/notify.test.mjs +37 -0
- package/src/standalone/daemon.mjs +31 -15
- package/src/standalone/hook-bus/config.mjs +1 -0
- package/src/standalone/plugin-admin.mjs +17 -0
- package/src/standalone/plugin-admin.test.mjs +7 -0
- package/src/standalone/provider-admin.mjs +1 -0
- package/src/standalone/session-protocol.mjs +5 -0
- package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
- package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
- package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
- package/src/standalone/session-runtime-host-factory.mjs +17 -0
- package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
- package/src/standalone/session-runtime-host-health.test.mjs +5 -0
- package/src/standalone/session-runtime-host.mjs +696 -58
- package/src/standalone/session-runtime-inline-host.mjs +351 -0
- package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
- package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
- package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
- package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
- package/src/standalone/session-runtime-shard-router.mjs +149 -0
- package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
- package/src/standalone/session-runtime-worker.mjs +476 -18
- package/src/standalone/session-service.mjs +81 -6
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/core-memory-picker.mjs +2 -0
- package/src/tui/app/extension-pickers.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +3 -3
- package/src/tui/app/model-options.mjs +2 -0
- package/src/tui/app/model-picker.mjs +14 -4
- package/src/tui/app/panel-handoff.test.mjs +311 -0
- package/src/tui/app/project-picker.mjs +1 -0
- package/src/tui/app/route-pickers.mjs +21 -6
- package/src/tui/app/settings-picker.mjs +26 -3
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +73 -22
- package/src/tui/app/theme-effort-pickers.mjs +7 -2
- package/src/tui/components/Picker.jsx +10 -1
- package/src/tui/dist/index.mjs +163 -44
- package/src/tui/session/context-state.mjs +1 -0
- package/src/tui/session/goal-continuation.mjs +110 -0
- package/src/tui/session/goal-continuation.test.mjs +84 -0
- package/src/tui/session/queue-helpers.mjs +5 -2
- package/src/tui/session/session-action-surface.test.mjs +108 -0
- package/src/tui/session/session-api-ext.mjs +39 -8
- package/src/tui/session/session-api.mjs +58 -0
- package/src/tui/session/session-flow.mjs +6 -0
- package/src/tui/session/turn.mjs +1 -0
- package/src/tui/session-local.mjs +15 -1
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// Focused coverage for the append/coalesced turn-checkpoint journal that
|
|
2
|
+
// replaced the 150ms full growing-turn snapshot:
|
|
3
|
+
// * prompt durability still lands synchronously on the first flush,
|
|
4
|
+
// * replay reproduces the exact full-snapshot object (messages + partial
|
|
5
|
+
// text/reasoning + tool pairing) at bounded write cost,
|
|
6
|
+
// * crash-torn tails, stale journals, wholesale transcript rewrites,
|
|
7
|
+
// cancellation and clear all keep latest-wins recovery semantics.
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import {
|
|
11
|
+
appendFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
statSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import test from 'node:test';
|
|
22
|
+
|
|
23
|
+
// MIXDOG_DATA_DIR must be set before the modules resolve the checkpoint dir.
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-turn-journal-'));
|
|
25
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
26
|
+
process.on('exit', () => { try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ } });
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
clearTurnCheckpoint,
|
|
30
|
+
createTurnCheckpointRecorder,
|
|
31
|
+
readTurnCheckpoint,
|
|
32
|
+
recoverTurnCheckpoint,
|
|
33
|
+
settleTurnCheckpointWrites,
|
|
34
|
+
turnMessagesForCheckpoint,
|
|
35
|
+
} = await import('./turn-checkpoint.mjs');
|
|
36
|
+
const {
|
|
37
|
+
_setJournalOpGateForTest,
|
|
38
|
+
turnCheckpointPath,
|
|
39
|
+
turnJournalPath,
|
|
40
|
+
} = await import('./turn-checkpoint-journal.mjs');
|
|
41
|
+
const {
|
|
42
|
+
createTurnInterruptionTracker,
|
|
43
|
+
finalizeTurnInterruptionSnapshot,
|
|
44
|
+
} = await import('./turn-interruption.mjs');
|
|
45
|
+
|
|
46
|
+
function startTurn(sessionId, prompt = 'do the thing') {
|
|
47
|
+
const tracker = createTurnInterruptionTracker();
|
|
48
|
+
const outgoing = [
|
|
49
|
+
{ role: 'system', content: 'sys' },
|
|
50
|
+
{ role: 'user', content: prompt },
|
|
51
|
+
];
|
|
52
|
+
const recorder = createTurnCheckpointRecorder({
|
|
53
|
+
sessionId,
|
|
54
|
+
generation: 3,
|
|
55
|
+
turnToken: `tok-${sessionId}`,
|
|
56
|
+
startedAt: 1000,
|
|
57
|
+
});
|
|
58
|
+
const flush = () => recorder.record({
|
|
59
|
+
currentUserContent: prompt,
|
|
60
|
+
turnOutgoing: outgoing,
|
|
61
|
+
interruption: tracker,
|
|
62
|
+
});
|
|
63
|
+
return { tracker, outgoing, recorder, flush, prompt };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Hold every async journal operation in the write lane so reset/clear/stop/
|
|
68
|
+
* overflow can be driven while a write is GENUINELY in flight (entry.writing
|
|
69
|
+
* is true and its bytes are not on disk) instead of after settlement.
|
|
70
|
+
*/
|
|
71
|
+
function holdJournalWrites() {
|
|
72
|
+
let open = () => {};
|
|
73
|
+
const gate = new Promise((resolve) => { open = resolve; });
|
|
74
|
+
let released = false;
|
|
75
|
+
_setJournalOpGateForTest(() => gate);
|
|
76
|
+
return {
|
|
77
|
+
release() {
|
|
78
|
+
if (released) return;
|
|
79
|
+
released = true;
|
|
80
|
+
_setJournalOpGateForTest(null);
|
|
81
|
+
open();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test('first flush anchors the prompt synchronously', () => {
|
|
87
|
+
const sessionId = 'sess-anchor';
|
|
88
|
+
const turn = startTurn(sessionId);
|
|
89
|
+
assert.equal(turn.flush(), true);
|
|
90
|
+
// No await: the header is the crash-durability anchor and must be on disk
|
|
91
|
+
// before the provider call returns control.
|
|
92
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
93
|
+
assert.equal(checkpoint.turnToken, `tok-${sessionId}`);
|
|
94
|
+
assert.equal(checkpoint.generation, 3);
|
|
95
|
+
assert.deepEqual(checkpoint.turnMessages, [{ role: 'user', content: 'do the thing' }]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('journal replay reproduces the full snapshot at bounded cost', async () => {
|
|
99
|
+
const sessionId = 'sess-replay';
|
|
100
|
+
const turn = startTurn(sessionId);
|
|
101
|
+
turn.flush();
|
|
102
|
+
const headerBytes = statSync(turnCheckpointPath(sessionId)).size;
|
|
103
|
+
const body = 'x'.repeat(2000);
|
|
104
|
+
for (let i = 0; i < 40; i += 1) {
|
|
105
|
+
turn.tracker.recordTextDelta(`chunk-${i} `);
|
|
106
|
+
const call = { id: `call-${i}`, name: 'shell', arguments: '{}' };
|
|
107
|
+
turn.outgoing.push({ role: 'assistant', content: `${body}${i}`, toolCalls: [call] });
|
|
108
|
+
turn.tracker.recordToolCalls([call]);
|
|
109
|
+
turn.outgoing.push({ role: 'tool', content: body, toolCallId: call.id });
|
|
110
|
+
turn.flush();
|
|
111
|
+
}
|
|
112
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
113
|
+
|
|
114
|
+
// The header is written exactly once per turn — it still holds only the
|
|
115
|
+
// opening prompt, so the growing turn never rewrites it.
|
|
116
|
+
assert.equal(statSync(turnCheckpointPath(sessionId)).size, headerBytes);
|
|
117
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
checkpoint.turnMessages,
|
|
120
|
+
turnMessagesForCheckpoint(turn.outgoing, turn.prompt),
|
|
121
|
+
);
|
|
122
|
+
assert.deepEqual(checkpoint.interruption, turn.tracker.snapshot());
|
|
123
|
+
|
|
124
|
+
// Bounded cost: total bytes written stay proportional to the turn payload
|
|
125
|
+
// instead of flushes x turn size (the old writer re-serialized everything
|
|
126
|
+
// on every flush, ~40x this volume for the same turn).
|
|
127
|
+
const payloadBytes = Buffer.byteLength(JSON.stringify(checkpoint.turnMessages));
|
|
128
|
+
const journalBytes = statSync(turnJournalPath(sessionId)).size;
|
|
129
|
+
assert.ok(
|
|
130
|
+
journalBytes < payloadBytes * 2,
|
|
131
|
+
`journal ${journalBytes} bytes should stay near payload ${payloadBytes} bytes`,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('crash recovery matches the live finalize path (text reset, tool pairing)', async () => {
|
|
136
|
+
const sessionId = 'sess-recover';
|
|
137
|
+
const prompt = 'run the tool';
|
|
138
|
+
const turn = startTurn(sessionId, prompt);
|
|
139
|
+
turn.flush();
|
|
140
|
+
turn.tracker.markProviderSendStarted();
|
|
141
|
+
turn.tracker.recordTextDelta('partial answer');
|
|
142
|
+
turn.tracker.recordReasoningDelta('thinking');
|
|
143
|
+
turn.flush();
|
|
144
|
+
// A UI-acknowledged reset tombstones the tail; a crash cannot complete the
|
|
145
|
+
// replacement, so the same visible bytes must survive.
|
|
146
|
+
turn.tracker.tombstoneText(7);
|
|
147
|
+
turn.flush();
|
|
148
|
+
const call = { id: 'call-a', name: 'read', arguments: '{}' };
|
|
149
|
+
turn.tracker.recordToolCalls([call]);
|
|
150
|
+
turn.outgoing.push({ role: 'assistant', content: '', toolCalls: [call] });
|
|
151
|
+
turn.tracker.markToolPhaseStarted();
|
|
152
|
+
turn.tracker.recordToolResult({ toolCallId: 'call-a', content: 'early', __earlyNotify: true });
|
|
153
|
+
turn.flush();
|
|
154
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
155
|
+
|
|
156
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
157
|
+
assert.deepEqual(checkpoint.interruption, turn.tracker.snapshot());
|
|
158
|
+
assert.equal(checkpoint.interruption.partialAssistantContent, 'partial answer');
|
|
159
|
+
|
|
160
|
+
const session = {
|
|
161
|
+
id: sessionId,
|
|
162
|
+
generation: 3,
|
|
163
|
+
updatedAt: 0,
|
|
164
|
+
messages: [{ role: 'system', content: 'sys' }, { role: 'user', content: prompt }],
|
|
165
|
+
activeTurnCheckpoint: { turnToken: `tok-${sessionId}` },
|
|
166
|
+
_providerPrefixGuardState: { messageHashes: ['stale'], requestPrefixHash: 'stale' },
|
|
167
|
+
};
|
|
168
|
+
const recovery = recoverTurnCheckpoint(session);
|
|
169
|
+
assert.equal(recovery.recovered, true);
|
|
170
|
+
// A recovered (rewritten) transcript must never keep the mid-turn provider
|
|
171
|
+
// prefix snapshot: a stale one flags every following send as
|
|
172
|
+
// history_shrink ("Session state changed unexpectedly.").
|
|
173
|
+
assert.equal(Object.prototype.hasOwnProperty.call(session, '_providerPrefixGuardState'), false);
|
|
174
|
+
const expected = finalizeTurnInterruptionSnapshot({
|
|
175
|
+
turnOutgoing: checkpoint.turnMessages,
|
|
176
|
+
currentUserContent: checkpoint.currentUserContent,
|
|
177
|
+
snapshot: turn.tracker.snapshot(),
|
|
178
|
+
abortReason: 'process-crash',
|
|
179
|
+
});
|
|
180
|
+
assert.deepEqual(session.messages, [{ role: 'system', content: 'sys' }, ...expected.messages]);
|
|
181
|
+
assert.equal(session.messages.at(-1).content, '[Request interrupted by process restart]');
|
|
182
|
+
// Every observed call is paired with a result in the recovered transcript.
|
|
183
|
+
assert.ok(session.messages.some((m) => m.role === 'tool' && m.toolCallId === 'call-a'));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('a crash-torn trailing record is discarded and the durable prefix survives', async () => {
|
|
187
|
+
const sessionId = 'sess-torn';
|
|
188
|
+
const turn = startTurn(sessionId);
|
|
189
|
+
turn.flush();
|
|
190
|
+
turn.tracker.recordTextDelta('durable text');
|
|
191
|
+
turn.outgoing.push({ role: 'assistant', content: 'committed' });
|
|
192
|
+
turn.flush();
|
|
193
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
194
|
+
appendFileSync(turnJournalPath(sessionId), '{"t":"m","i":2,"m":{"role":"tool","con');
|
|
195
|
+
|
|
196
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
197
|
+
assert.equal(checkpoint.turnMessages.length, 2);
|
|
198
|
+
assert.equal(checkpoint.turnMessages[1].content, 'committed');
|
|
199
|
+
assert.equal(checkpoint.interruption.partialAssistantContent, 'durable text');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a journal from another turn never replays onto a new header', async () => {
|
|
203
|
+
const sessionId = 'sess-stale';
|
|
204
|
+
const turn = startTurn(sessionId);
|
|
205
|
+
turn.flush();
|
|
206
|
+
turn.tracker.recordTextDelta('old turn text');
|
|
207
|
+
turn.flush();
|
|
208
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
209
|
+
const header = JSON.parse(readFileSync(turnCheckpointPath(sessionId), 'utf8'));
|
|
210
|
+
writeFileSync(
|
|
211
|
+
turnCheckpointPath(sessionId),
|
|
212
|
+
JSON.stringify({ ...header, turnToken: 'other-token' }),
|
|
213
|
+
'utf8',
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
217
|
+
assert.equal(checkpoint.turnToken, 'other-token');
|
|
218
|
+
assert.equal(checkpoint.interruption.partialAssistantContent, '');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('rewritten and truncated transcripts replay latest-wins', async () => {
|
|
222
|
+
const sessionId = 'sess-rewrite';
|
|
223
|
+
const turn = startTurn(sessionId);
|
|
224
|
+
turn.flush();
|
|
225
|
+
turn.outgoing.push({ role: 'assistant', content: 'first' }, { role: 'assistant', content: 'second' });
|
|
226
|
+
turn.flush();
|
|
227
|
+
// Wholesale replacement (image-strip splice / pre-send compaction).
|
|
228
|
+
turn.outgoing.splice(
|
|
229
|
+
0,
|
|
230
|
+
turn.outgoing.length,
|
|
231
|
+
{ role: 'system', content: 'sys' },
|
|
232
|
+
{ role: 'user', content: turn.prompt },
|
|
233
|
+
{ role: 'assistant', content: 'rewritten' },
|
|
234
|
+
);
|
|
235
|
+
turn.flush();
|
|
236
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
237
|
+
|
|
238
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
239
|
+
assert.deepEqual(checkpoint.turnMessages, [
|
|
240
|
+
{ role: 'user', content: turn.prompt },
|
|
241
|
+
{ role: 'assistant', content: 'rewritten' },
|
|
242
|
+
]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('clear is token-guarded and removes header + journal', async () => {
|
|
246
|
+
const sessionId = 'sess-clear';
|
|
247
|
+
const turn = startTurn(sessionId);
|
|
248
|
+
turn.flush();
|
|
249
|
+
turn.tracker.recordTextDelta('x');
|
|
250
|
+
turn.flush();
|
|
251
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
252
|
+
|
|
253
|
+
assert.equal(clearTurnCheckpoint(sessionId, 'someone-elses-token'), false);
|
|
254
|
+
assert.ok(existsSync(turnCheckpointPath(sessionId)));
|
|
255
|
+
assert.equal(clearTurnCheckpoint(sessionId, `tok-${sessionId}`), true);
|
|
256
|
+
assert.equal(existsSync(turnCheckpointPath(sessionId)), false);
|
|
257
|
+
assert.equal(existsSync(turnJournalPath(sessionId)), false);
|
|
258
|
+
assert.equal(readTurnCheckpoint(sessionId), null);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── In-flight write races (regressions) ─────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
test('a new turn fences an older IN-FLIGHT append (no stale turn under a new token)', async () => {
|
|
264
|
+
const sessionId = 'sess-fence';
|
|
265
|
+
const first = startTurn(sessionId, 'first prompt');
|
|
266
|
+
first.flush();
|
|
267
|
+
const hold = holdJournalWrites();
|
|
268
|
+
let during;
|
|
269
|
+
let second;
|
|
270
|
+
try {
|
|
271
|
+
first.outgoing.push({ role: 'assistant', content: 'STALE_OLD_TURN' });
|
|
272
|
+
first.tracker.recordTextDelta('STALE_OLD_TURN partial');
|
|
273
|
+
first.flush(); // genuinely in flight: accepted, not on disk
|
|
274
|
+
first.recorder.stop();
|
|
275
|
+
|
|
276
|
+
second = createTurnCheckpointRecorder({
|
|
277
|
+
sessionId,
|
|
278
|
+
generation: 3,
|
|
279
|
+
turnToken: 'tok-second',
|
|
280
|
+
startedAt: 2000,
|
|
281
|
+
});
|
|
282
|
+
const secondOutgoing = [
|
|
283
|
+
{ role: 'system', content: 'sys' },
|
|
284
|
+
{ role: 'user', content: 'second prompt' },
|
|
285
|
+
];
|
|
286
|
+
assert.equal(second.record({
|
|
287
|
+
currentUserContent: 'second prompt',
|
|
288
|
+
turnOutgoing: secondOutgoing,
|
|
289
|
+
interruption: createTurnInterruptionTracker(),
|
|
290
|
+
}), true);
|
|
291
|
+
// Interim window: header is the new turn, the journal is still the old
|
|
292
|
+
// one. Replay must reject it rather than merge an old turn.
|
|
293
|
+
during = readTurnCheckpoint(sessionId);
|
|
294
|
+
} finally {
|
|
295
|
+
hold.release();
|
|
296
|
+
}
|
|
297
|
+
assert.equal(during.turnToken, 'tok-second');
|
|
298
|
+
assert.ok(!JSON.stringify(during).includes('STALE_OLD_TURN'));
|
|
299
|
+
|
|
300
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
301
|
+
const after = readTurnCheckpoint(sessionId);
|
|
302
|
+
assert.equal(after.turnToken, 'tok-second');
|
|
303
|
+
assert.ok(!JSON.stringify(after).includes('STALE_OLD_TURN'), 'old turn replayed under the new token');
|
|
304
|
+
assert.deepEqual(after.turnMessages, [{ role: 'user', content: 'second prompt' }]);
|
|
305
|
+
const journal = readFileSync(turnJournalPath(sessionId), 'utf8').trim().split('\n');
|
|
306
|
+
assert.equal(JSON.parse(journal[0]).turnToken, 'tok-second');
|
|
307
|
+
assert.ok(!journal.some((line) => line.includes('STALE_OLD_TURN')));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('overflow never writes ahead of an older IN-FLIGHT append (latest-wins holds)', async () => {
|
|
311
|
+
const sessionId = 'sess-overflow';
|
|
312
|
+
const turn = startTurn(sessionId);
|
|
313
|
+
turn.flush();
|
|
314
|
+
const headBytes = statSync(turnJournalPath(sessionId)).size;
|
|
315
|
+
const hold = holdJournalWrites();
|
|
316
|
+
try {
|
|
317
|
+
turn.outgoing.push({ role: 'assistant', content: 'OLD_IN_FLIGHT' });
|
|
318
|
+
turn.flush(); // in flight
|
|
319
|
+
// >4MB of NEWER records queue behind it and cross the overflow ceiling.
|
|
320
|
+
const filler = 'y'.repeat(512 * 1024);
|
|
321
|
+
for (let i = 0; i < 9; i += 1) {
|
|
322
|
+
turn.outgoing[2] = { role: 'assistant', content: `${filler}-${i}` };
|
|
323
|
+
turn.flush();
|
|
324
|
+
}
|
|
325
|
+
turn.outgoing[2] = { role: 'assistant', content: 'NEWEST' };
|
|
326
|
+
turn.flush();
|
|
327
|
+
// Nothing may reach disk while an older write is outstanding.
|
|
328
|
+
assert.equal(
|
|
329
|
+
statSync(turnJournalPath(sessionId)).size,
|
|
330
|
+
headBytes,
|
|
331
|
+
'overflow drain overtook an in-flight append',
|
|
332
|
+
);
|
|
333
|
+
} finally {
|
|
334
|
+
hold.release();
|
|
335
|
+
}
|
|
336
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
337
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
338
|
+
assert.deepEqual(checkpoint.turnMessages[1], { role: 'assistant', content: 'NEWEST' });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('explicit process exit flushes in-flight AND queued deltas in order', () => {
|
|
342
|
+
const sessionId = 'sess-exit';
|
|
343
|
+
const source = `
|
|
344
|
+
const checkpoint = await import(${JSON.stringify(new URL('./turn-checkpoint.mjs', import.meta.url).href)});
|
|
345
|
+
const journal = await import(${JSON.stringify(new URL('./turn-checkpoint-journal.mjs', import.meta.url).href)});
|
|
346
|
+
const interruption = await import(${JSON.stringify(new URL('./turn-interruption.mjs', import.meta.url).href)});
|
|
347
|
+
const sessionId = ${JSON.stringify(sessionId)};
|
|
348
|
+
const prompt = 'exit prompt';
|
|
349
|
+
const tracker = interruption.createTurnInterruptionTracker();
|
|
350
|
+
const outgoing = [{ role: 'system', content: 'sys' }, { role: 'user', content: prompt }];
|
|
351
|
+
const recorder = checkpoint.createTurnCheckpointRecorder({ sessionId, generation: 3, turnToken: 'tok-exit', startedAt: 1000 });
|
|
352
|
+
const flush = () => recorder.record({ currentUserContent: prompt, turnOutgoing: outgoing, interruption: tracker });
|
|
353
|
+
flush();
|
|
354
|
+
// Never resolves: the next append stays in flight, the one after it queues.
|
|
355
|
+
journal._setJournalOpGateForTest(() => new Promise(() => {}));
|
|
356
|
+
tracker.recordTextDelta('IN_FLIGHT ');
|
|
357
|
+
outgoing.push({ role: 'assistant', content: 'IN_FLIGHT_MESSAGE' });
|
|
358
|
+
flush();
|
|
359
|
+
tracker.recordTextDelta('QUEUED');
|
|
360
|
+
outgoing.push({ role: 'tool', content: 'QUEUED_MESSAGE', toolCallId: 'call-q' });
|
|
361
|
+
flush();
|
|
362
|
+
process.exit(0);
|
|
363
|
+
`;
|
|
364
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '-e', source], {
|
|
365
|
+
encoding: 'utf8',
|
|
366
|
+
env: { ...process.env, MIXDOG_DATA_DIR: root },
|
|
367
|
+
});
|
|
368
|
+
assert.equal(result.status, 0, `child failed: ${result.stderr}`);
|
|
369
|
+
|
|
370
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
371
|
+
assert.equal(checkpoint.turnMessages.length, 3, 'exit persisted only the journal head');
|
|
372
|
+
assert.equal(checkpoint.turnMessages[1].content, 'IN_FLIGHT_MESSAGE');
|
|
373
|
+
assert.equal(checkpoint.turnMessages[2].content, 'QUEUED_MESSAGE');
|
|
374
|
+
assert.equal(checkpoint.interruption.partialAssistantContent, 'IN_FLIGHT QUEUED');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('clear while a write is IN FLIGHT drops the header now and the journal after', async () => {
|
|
378
|
+
const sessionId = 'sess-clear-race';
|
|
379
|
+
const turn = startTurn(sessionId);
|
|
380
|
+
turn.flush();
|
|
381
|
+
const hold = holdJournalWrites();
|
|
382
|
+
try {
|
|
383
|
+
turn.outgoing.push({ role: 'assistant', content: 'inflight' });
|
|
384
|
+
turn.flush();
|
|
385
|
+
assert.equal(clearTurnCheckpoint(sessionId, `tok-${sessionId}`), true);
|
|
386
|
+
// The header is the recovery gate; it must be gone immediately.
|
|
387
|
+
assert.equal(existsSync(turnCheckpointPath(sessionId)), false);
|
|
388
|
+
assert.equal(readTurnCheckpoint(sessionId), null);
|
|
389
|
+
} finally {
|
|
390
|
+
hold.release();
|
|
391
|
+
}
|
|
392
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
393
|
+
assert.equal(existsSync(turnJournalPath(sessionId)), false, 'in-flight append resurrected the journal');
|
|
394
|
+
assert.equal(readTurnCheckpoint(sessionId), null);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('stop while a write is IN FLIGHT keeps that write and drops only queued deltas', async () => {
|
|
398
|
+
const sessionId = 'sess-stop-race';
|
|
399
|
+
const turn = startTurn(sessionId);
|
|
400
|
+
turn.flush();
|
|
401
|
+
const hold = holdJournalWrites();
|
|
402
|
+
try {
|
|
403
|
+
turn.outgoing.push({ role: 'assistant', content: 'inflight-kept' });
|
|
404
|
+
turn.flush(); // in flight
|
|
405
|
+
turn.outgoing.push({ role: 'assistant', content: 'queued-dropped' });
|
|
406
|
+
turn.flush(); // queued
|
|
407
|
+
turn.recorder.stop();
|
|
408
|
+
assert.equal(turn.flush(), false);
|
|
409
|
+
} finally {
|
|
410
|
+
hold.release();
|
|
411
|
+
}
|
|
412
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
413
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
414
|
+
assert.equal(checkpoint.turnMessages.length, 2);
|
|
415
|
+
assert.equal(checkpoint.turnMessages[1].content, 'inflight-kept');
|
|
416
|
+
assert.ok(!JSON.stringify(checkpoint).includes('queued-dropped'));
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('replay skips a duplicated re-flushed record and stops at a real gap', async () => {
|
|
420
|
+
const sessionId = 'sess-seq';
|
|
421
|
+
const turn = startTurn(sessionId);
|
|
422
|
+
turn.flush();
|
|
423
|
+
turn.outgoing.push({ role: 'assistant', content: 'first' });
|
|
424
|
+
turn.flush();
|
|
425
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
426
|
+
const lines = readFileSync(turnJournalPath(sessionId), 'utf8').trim().split('\n');
|
|
427
|
+
// Exit-handler style duplicate of the last accepted record, then a record
|
|
428
|
+
// from beyond the durable prefix.
|
|
429
|
+
appendFileSync(turnJournalPath(sessionId), `${lines[lines.length - 1]}\n`);
|
|
430
|
+
appendFileSync(
|
|
431
|
+
turnJournalPath(sessionId),
|
|
432
|
+
`${JSON.stringify({ t: 'm', i: 2, m: { role: 'assistant', content: 'after-gap' }, s: 99 })}\n`,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
436
|
+
assert.equal(checkpoint.turnMessages.length, 2);
|
|
437
|
+
assert.equal(checkpoint.turnMessages[1].content, 'first');
|
|
438
|
+
assert.ok(!JSON.stringify(checkpoint).includes('after-gap'));
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('stop retires further flushes without dropping the durable prefix', async () => {
|
|
442
|
+
const sessionId = 'sess-stop';
|
|
443
|
+
const turn = startTurn(sessionId);
|
|
444
|
+
turn.flush();
|
|
445
|
+
turn.tracker.recordTextDelta('durable');
|
|
446
|
+
turn.flush();
|
|
447
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
448
|
+
|
|
449
|
+
turn.tracker.recordTextDelta('-late');
|
|
450
|
+
turn.recorder.stop();
|
|
451
|
+
assert.equal(turn.flush(), false);
|
|
452
|
+
await settleTurnCheckpointWrites(sessionId);
|
|
453
|
+
|
|
454
|
+
const checkpoint = readTurnCheckpoint(sessionId);
|
|
455
|
+
assert.equal(checkpoint.interruption.partialAssistantContent, 'durable');
|
|
456
|
+
assert.deepEqual(checkpoint.turnMessages, [{ role: 'user', content: 'do the thing' }]);
|
|
457
|
+
});
|