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,556 @@
|
|
|
1
|
+
// manager/turn-checkpoint-journal.mjs
|
|
2
|
+
// Bounded-cost, crash-safe turn checkpointing.
|
|
3
|
+
//
|
|
4
|
+
// PROBLEM. The turn checkpoint used to be a single full-snapshot file that was
|
|
5
|
+
// rewritten on every flush (up to ~6/s per live session): the WHOLE growing
|
|
6
|
+
// turn — every committed message, every buffered partial character, every
|
|
7
|
+
// observed tool call — was re-sanitized and re-serialized on the engine thread.
|
|
8
|
+
// Cost per flush grew with the turn (O(turn^2) over a long tool-heavy turn) and
|
|
9
|
+
// at maximum agent fan-out every live session paid it on the SAME event loop.
|
|
10
|
+
//
|
|
11
|
+
// DESIGN. Split the checkpoint into a write-once header plus an append-only
|
|
12
|
+
// delta journal:
|
|
13
|
+
// <session>.json header — written ONCE per turn, synchronously, atomically.
|
|
14
|
+
// It carries the prompt, so prompt durability is anchored
|
|
15
|
+
// before the provider ever runs, and store-summary-reader
|
|
16
|
+
// keeps probing this exact path to detect a live turn.
|
|
17
|
+
// <session>.jsonl append-only JSONL deltas — NEW/CHANGED turn messages (by
|
|
18
|
+
// turn-relative index), APPENDED partial text/reasoning and
|
|
19
|
+
// small interruption-state transitions. A flush costs
|
|
20
|
+
// O(new bytes), never O(turn).
|
|
21
|
+
// Appends ride a bounded coalescing lane (one in-flight write, one pending
|
|
22
|
+
// buffer, synchronous drain above the byte ceiling) so the engine thread never
|
|
23
|
+
// blocks on disk and the queue can never grow without limit.
|
|
24
|
+
//
|
|
25
|
+
// Change detection is reference identity on message objects — the same contract
|
|
26
|
+
// the session store's delta saves already rely on ("Replace, never mutate":
|
|
27
|
+
// manager/prompt-utils.mjs, manager/session-lifecycle.mjs). A wholesale
|
|
28
|
+
// transcript rewrite (image-strip splice, pre-send compaction) replaces the
|
|
29
|
+
// refs, so it journals as changed indices plus a truncation record.
|
|
30
|
+
//
|
|
31
|
+
// Reads replay header+journal into the EXACT object shape the full-snapshot
|
|
32
|
+
// writer produced, so recovery/projection semantics are unchanged. A crash-torn
|
|
33
|
+
// trailing line is discarded: replay stops at the first unparsable record and
|
|
34
|
+
// keeps the durable prefix.
|
|
35
|
+
import {
|
|
36
|
+
appendFileSync,
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
unlinkSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from 'fs';
|
|
43
|
+
import { appendFile, unlink, writeFile } from 'fs/promises';
|
|
44
|
+
import { randomBytes } from 'crypto';
|
|
45
|
+
import { join } from 'path';
|
|
46
|
+
import { getPluginData } from '../../config.mjs';
|
|
47
|
+
import { renameWithRetrySync } from '../../../../shared/atomic-file.mjs';
|
|
48
|
+
import { sanitizeContentForStoredHistory } from '../../providers/media-normalization.mjs';
|
|
49
|
+
import { promptContentText } from './prompt-utils.mjs';
|
|
50
|
+
|
|
51
|
+
export const TURN_CHECKPOINT_VERSION = 1;
|
|
52
|
+
const SESSION_ID = /^[A-Za-z0-9_-]+$/;
|
|
53
|
+
// Ceiling for records queued but not yet handed to the OS. Reaching it means
|
|
54
|
+
// the append lane cannot keep up; the writer then drains synchronously rather
|
|
55
|
+
// than dropping records — recovery correctness outranks a rare, bounded stall.
|
|
56
|
+
const MAX_PENDING_JOURNAL_BYTES = 4 * 1024 * 1024;
|
|
57
|
+
|
|
58
|
+
export function checkpointDir() {
|
|
59
|
+
const dir = join(getPluginData(), 'turn-checkpoints');
|
|
60
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
61
|
+
return dir;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertSessionId(sessionId) {
|
|
65
|
+
if (!SESSION_ID.test(String(sessionId || ''))) {
|
|
66
|
+
throw new Error(`[turn-checkpoint] invalid session id: ${JSON.stringify(sessionId)}`);
|
|
67
|
+
}
|
|
68
|
+
return sessionId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function turnCheckpointPath(sessionId) {
|
|
72
|
+
assertSessionId(sessionId);
|
|
73
|
+
return join(checkpointDir(), `${sessionId}.json`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function turnJournalPath(sessionId) {
|
|
77
|
+
assertSessionId(sessionId);
|
|
78
|
+
return join(checkpointDir(), `${sessionId}.jsonl`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function checkpointMessage(message) {
|
|
82
|
+
if (!message || typeof message !== 'object') return message;
|
|
83
|
+
const content = sanitizeContentForStoredHistory(message.content);
|
|
84
|
+
return content === message.content ? message : { ...message, content };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function matchingUserMessage(message, currentUserContent) {
|
|
88
|
+
return message?.role === 'user'
|
|
89
|
+
&& (message.content === currentUserContent
|
|
90
|
+
|| promptContentText(message.content) === promptContentText(currentUserContent));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Index of the user message that opened this turn, or -1. */
|
|
94
|
+
export function findTurnStart(messages, currentUserContent) {
|
|
95
|
+
const source = Array.isArray(messages) ? messages : [];
|
|
96
|
+
for (let index = source.length - 1; index >= 0; index -= 1) {
|
|
97
|
+
if (matchingUserMessage(source[index], currentUserContent)) return index;
|
|
98
|
+
}
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function turnMessagesForCheckpoint(messages, currentUserContent) {
|
|
103
|
+
const source = Array.isArray(messages) ? messages : [];
|
|
104
|
+
const start = findTurnStart(source, currentUserContent);
|
|
105
|
+
const turn = start >= 0 ? source.slice(start) : [];
|
|
106
|
+
return turn.map(checkpointMessage);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function emptyInterruptionSnapshot() {
|
|
110
|
+
return {
|
|
111
|
+
responseStarted: false,
|
|
112
|
+
partialAssistantContent: '',
|
|
113
|
+
partialReasoningContent: '',
|
|
114
|
+
phase: 'streaming',
|
|
115
|
+
observedToolCalls: [],
|
|
116
|
+
observedToolResults: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Header (write-once, atomic) ─────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export function writeCheckpointHeader(checkpoint) {
|
|
123
|
+
const target = turnCheckpointPath(checkpoint.sessionId);
|
|
124
|
+
const payload = {
|
|
125
|
+
...checkpoint,
|
|
126
|
+
version: TURN_CHECKPOINT_VERSION,
|
|
127
|
+
currentUserContent: sanitizeContentForStoredHistory(checkpoint.currentUserContent),
|
|
128
|
+
turnMessages: (Array.isArray(checkpoint.turnMessages) ? checkpoint.turnMessages : [])
|
|
129
|
+
.map(checkpointMessage),
|
|
130
|
+
interruption: checkpoint.interruption ?? emptyInterruptionSnapshot(),
|
|
131
|
+
updatedAt: Date.now(),
|
|
132
|
+
};
|
|
133
|
+
const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
|
|
134
|
+
try {
|
|
135
|
+
writeFileSync(temp, JSON.stringify(payload), 'utf8');
|
|
136
|
+
renameWithRetrySync(temp, target);
|
|
137
|
+
return true;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
try { unlinkSync(temp); } catch { /* best-effort */ }
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function readTurnCheckpointHeader(sessionId) {
|
|
145
|
+
try {
|
|
146
|
+
const value = JSON.parse(readFileSync(turnCheckpointPath(sessionId), 'utf8'));
|
|
147
|
+
if (value?.version !== TURN_CHECKPOINT_VERSION
|
|
148
|
+
|| value?.sessionId !== sessionId
|
|
149
|
+
|| !value?.turnToken
|
|
150
|
+
|| !Array.isArray(value?.turnMessages)) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return value;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Write lane: ONE total per-session order ─────────────────────────────────
|
|
160
|
+
// Every journal mutation — delta append, turn open (truncate+head), and
|
|
161
|
+
// removal — goes through a single ordered per-session queue with at most one
|
|
162
|
+
// operation in flight. Nothing may ever jump ahead of an accepted older write:
|
|
163
|
+
// * consecutive appends coalesce into one chunk (the bounded-cost lane),
|
|
164
|
+
// * a new turn's open FENCES older in-flight appends by queueing behind them,
|
|
165
|
+
// so an old turn's records can never land beneath a new turn token,
|
|
166
|
+
// * the overflow drain runs synchronously ONLY when nothing is in flight,
|
|
167
|
+
// * process exit re-writes the in-flight operation and then the queue, in
|
|
168
|
+
// order — record sequence numbers make that re-write idempotent.
|
|
169
|
+
// sessionId → { path, ops: [], bytes, writing, inFlight }
|
|
170
|
+
const _journalWrites = new Map();
|
|
171
|
+
const _journalWarned = new Set();
|
|
172
|
+
|
|
173
|
+
// Test seam: awaited before every async operation so a test can hold a write
|
|
174
|
+
// genuinely in flight and drive the reset/clear/stop/exit races deterministically.
|
|
175
|
+
let _journalOpGate = null;
|
|
176
|
+
export function _setJournalOpGateForTest(gate) {
|
|
177
|
+
_journalOpGate = typeof gate === 'function' ? gate : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _warnJournalOnce(sessionId, error) {
|
|
181
|
+
if (_journalWarned.has(sessionId)) return;
|
|
182
|
+
_journalWarned.add(sessionId);
|
|
183
|
+
try {
|
|
184
|
+
process.stderr.write(`[turn-checkpoint] journal write failed session=${sessionId}: ${error?.message || error}\n`);
|
|
185
|
+
} catch { /* stderr best-effort */ }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function _runJournalOpSync(op) {
|
|
189
|
+
if (op.kind === 'append') {
|
|
190
|
+
appendFileSync(op.path, op.data, 'utf8');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (op.kind === 'reset') {
|
|
194
|
+
writeFileSync(op.path, op.data, 'utf8');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
unlinkSync(op.path);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function _runJournalOpAsync(op) {
|
|
205
|
+
if (_journalOpGate) await _journalOpGate(op);
|
|
206
|
+
if (op.kind === 'append') return appendFile(op.path, op.data, 'utf8');
|
|
207
|
+
if (op.kind === 'reset') return writeFile(op.path, op.data, 'utf8');
|
|
208
|
+
try {
|
|
209
|
+
await unlink(op.path);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _releaseIdleEntry(sessionId, entry) {
|
|
217
|
+
if (!entry.writing && entry.ops.length === 0) _journalWrites.delete(sessionId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Drain the queue in order on the calling thread. Only ever called when no
|
|
221
|
+
* operation is in flight, so a synchronous write cannot overtake an older one. */
|
|
222
|
+
function _drainJournalOpsSync(sessionId, entry) {
|
|
223
|
+
const ops = entry.ops;
|
|
224
|
+
entry.ops = [];
|
|
225
|
+
entry.bytes = 0;
|
|
226
|
+
for (const op of ops) {
|
|
227
|
+
try {
|
|
228
|
+
_runJournalOpSync(op);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
_warnJournalOnce(sessionId, error);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
_releaseIdleEntry(sessionId, entry);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function _pumpJournal(sessionId) {
|
|
238
|
+
const entry = _journalWrites.get(sessionId);
|
|
239
|
+
if (!entry || entry.writing) return;
|
|
240
|
+
if (entry.ops.length === 0) {
|
|
241
|
+
_journalWrites.delete(sessionId);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (entry.bytes >= MAX_PENDING_JOURNAL_BYTES) {
|
|
245
|
+
// Back-pressure: the producer outran the lane. Draining synchronously is
|
|
246
|
+
// safe here and ONLY here — the queue is the oldest accepted work, so
|
|
247
|
+
// nothing older can land after it.
|
|
248
|
+
_drainJournalOpsSync(sessionId, entry);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const op = entry.ops.shift();
|
|
252
|
+
if (op.kind === 'append') entry.bytes = Math.max(0, entry.bytes - op.data.length);
|
|
253
|
+
entry.writing = true;
|
|
254
|
+
entry.inFlight = op;
|
|
255
|
+
_runJournalOpAsync(op)
|
|
256
|
+
.catch((error) => { _warnJournalOnce(sessionId, error); })
|
|
257
|
+
.finally(() => {
|
|
258
|
+
entry.writing = false;
|
|
259
|
+
entry.inFlight = null;
|
|
260
|
+
_pumpJournal(sessionId);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function _enqueueJournalOp(sessionId, op) {
|
|
265
|
+
const entry = _journalWrites.get(sessionId)
|
|
266
|
+
?? { path: op.path, ops: [], bytes: 0, writing: false, inFlight: null };
|
|
267
|
+
_journalWrites.set(sessionId, entry);
|
|
268
|
+
const last = entry.ops[entry.ops.length - 1];
|
|
269
|
+
if (op.kind === 'append' && last?.kind === 'append' && last.path === op.path) {
|
|
270
|
+
last.data += op.data;
|
|
271
|
+
} else {
|
|
272
|
+
entry.ops.push(op);
|
|
273
|
+
}
|
|
274
|
+
if (op.kind === 'append') entry.bytes += op.data.length;
|
|
275
|
+
_pumpJournal(sessionId);
|
|
276
|
+
return entry;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Retire queued (not yet written) delta appends. The durable prefix on disk
|
|
281
|
+
* stays: between turn commit and clearTurnCheckpoint a crash must still recover
|
|
282
|
+
* the turn's work, not just its opening header. Structural operations already
|
|
283
|
+
* queued (open/remove) keep their slot in the total order.
|
|
284
|
+
*/
|
|
285
|
+
export function cancelJournalWrites(sessionId) {
|
|
286
|
+
const entry = _journalWrites.get(sessionId);
|
|
287
|
+
if (!entry) return;
|
|
288
|
+
entry.ops = entry.ops.filter((op) => op.kind !== 'append');
|
|
289
|
+
entry.bytes = 0;
|
|
290
|
+
_releaseIdleEntry(sessionId, entry);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function appendJournalLines(sessionId, lines) {
|
|
294
|
+
if (!Array.isArray(lines) || lines.length === 0) return false;
|
|
295
|
+
_enqueueJournalOp(sessionId, {
|
|
296
|
+
kind: 'append',
|
|
297
|
+
path: turnJournalPath(sessionId),
|
|
298
|
+
data: lines.join(''),
|
|
299
|
+
});
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Truncate + head-write the journal for a NEW turn. Returns true when it landed
|
|
305
|
+
* synchronously (the common case: the previous turn's lane is idle). When an
|
|
306
|
+
* older write is still in flight the open is QUEUED behind it instead: the
|
|
307
|
+
* durability anchor is the atomically written `.json` header, and until the
|
|
308
|
+
* queued open runs the on-disk journal still carries the OLD head token, which
|
|
309
|
+
* replay rejects. Truncating under an in-flight append would instead let that
|
|
310
|
+
* append land beneath the new turn's head and resurrect an old turn.
|
|
311
|
+
*/
|
|
312
|
+
export function openJournalForTurn(sessionId, openLines) {
|
|
313
|
+
const path = turnJournalPath(sessionId);
|
|
314
|
+
const data = (Array.isArray(openLines) ? openLines : []).join('');
|
|
315
|
+
cancelJournalWrites(sessionId);
|
|
316
|
+
if (!_journalWrites.has(sessionId)) {
|
|
317
|
+
writeFileSync(path, data, 'utf8');
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
_enqueueJournalOp(sessionId, { kind: 'reset', path, data });
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Opening lines for a journal with no encoder behind it (full-snapshot
|
|
325
|
+
* writes): just the sequence-0 head record that binds the file to its turn. */
|
|
326
|
+
export function journalHeadLines(turnToken) {
|
|
327
|
+
return [`${JSON.stringify({ t: 'h', turnToken, at: Date.now(), s: 0 })}\n`];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function removeTurnJournal(sessionId) {
|
|
331
|
+
const path = turnJournalPath(sessionId);
|
|
332
|
+
cancelJournalWrites(sessionId);
|
|
333
|
+
if (!_journalWrites.has(sessionId)) {
|
|
334
|
+
try { unlinkSync(path); } catch { /* best-effort */ }
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
// An older write is in flight; unlinking now would let it recreate the file
|
|
338
|
+
// behind us. Queue the removal so it runs after that write.
|
|
339
|
+
_enqueueJournalOp(sessionId, { kind: 'remove', path });
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Test/shutdown helper: resolve once the write lane for a session is idle. */
|
|
344
|
+
export async function settleTurnJournalWrites(sessionId, { timeoutMs = 5_000 } = {}) {
|
|
345
|
+
const deadline = Date.now() + timeoutMs;
|
|
346
|
+
for (;;) {
|
|
347
|
+
const entry = _journalWrites.get(sessionId);
|
|
348
|
+
if (!entry || (!entry.writing && entry.ops.length === 0)) return true;
|
|
349
|
+
if (Date.now() > deadline) return false;
|
|
350
|
+
await new Promise((resolve) => { setTimeout(resolve, 1); });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Explicit process exit: after this handler returns the process dies, so an
|
|
355
|
+
// operation already handed to the threadpool may have landed fully, partially,
|
|
356
|
+
// or not at all, and the queued operations behind it would be lost outright.
|
|
357
|
+
// Re-write the in-flight operation FIRST and then the queue, in order. Record
|
|
358
|
+
// sequence numbers make a duplicated re-write a no-op at replay and a torn
|
|
359
|
+
// fragment is skipped, so this can only ever add durability.
|
|
360
|
+
process.on('exit', () => {
|
|
361
|
+
for (const [sessionId, entry] of _journalWrites) {
|
|
362
|
+
const ops = entry.inFlight ? [entry.inFlight, ...entry.ops] : entry.ops.slice();
|
|
363
|
+
if (ops.length === 0) continue;
|
|
364
|
+
entry.ops = [];
|
|
365
|
+
entry.bytes = 0;
|
|
366
|
+
entry.inFlight = null;
|
|
367
|
+
for (const op of ops) {
|
|
368
|
+
try {
|
|
369
|
+
_runJournalOpSync(op);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
_warnJournalOnce(sessionId, error);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ── Delta encoding / replay ─────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
function interruptionStateFromSnapshot(snapshot) {
|
|
381
|
+
const source = snapshot && typeof snapshot === 'object' ? snapshot : {};
|
|
382
|
+
return {
|
|
383
|
+
responseStarted: source.responseStarted === true,
|
|
384
|
+
text: String(source.partialAssistantContent || ''),
|
|
385
|
+
tomb: '',
|
|
386
|
+
reasoning: String(source.partialReasoningContent || ''),
|
|
387
|
+
phase: source.phase === 'tools' ? 'tools' : 'streaming',
|
|
388
|
+
calls: new Map(Array.isArray(source.observedToolCalls) ? source.observedToolCalls : []),
|
|
389
|
+
results: new Map(Array.isArray(source.observedToolResults) ? source.observedToolResults : []),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function interruptionSnapshotFromState(state) {
|
|
394
|
+
return {
|
|
395
|
+
responseStarted: state.responseStarted,
|
|
396
|
+
// Same combination rule the live tracker's snapshot() uses: text the UI
|
|
397
|
+
// was told to retract still counts as bytes a crash cannot replace.
|
|
398
|
+
partialAssistantContent: state.text + state.tomb,
|
|
399
|
+
partialReasoningContent: state.reasoning,
|
|
400
|
+
phase: state.phase,
|
|
401
|
+
observedToolCalls: [...state.calls.entries()],
|
|
402
|
+
observedToolResults: [...state.results.entries()],
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function applyInterruptionDelta(state, delta) {
|
|
407
|
+
if (!delta || typeof delta !== 'object') return state;
|
|
408
|
+
if (typeof delta.rs === 'boolean') state.responseStarted = delta.rs;
|
|
409
|
+
if (delta.ph === 'tools' || delta.ph === 'streaming') state.phase = delta.ph;
|
|
410
|
+
if (typeof delta.ts === 'string') state.text = delta.ts;
|
|
411
|
+
if (typeof delta.ta === 'string') state.text += delta.ta;
|
|
412
|
+
if (typeof delta.qs === 'string') state.reasoning = delta.qs;
|
|
413
|
+
if (typeof delta.qa === 'string') state.reasoning += delta.qa;
|
|
414
|
+
if (typeof delta.tb === 'string') state.tomb = delta.tb;
|
|
415
|
+
if (delta.cc === true) state.calls.clear();
|
|
416
|
+
for (const pair of Array.isArray(delta.cs) ? delta.cs : []) {
|
|
417
|
+
if (Array.isArray(pair) && pair.length === 2) state.calls.set(pair[0], pair[1]);
|
|
418
|
+
}
|
|
419
|
+
for (const pair of Array.isArray(delta.os) ? delta.os : []) {
|
|
420
|
+
if (Array.isArray(pair) && pair.length === 2) state.results.set(pair[0], pair[1]);
|
|
421
|
+
}
|
|
422
|
+
for (const id of Array.isArray(delta.od) ? delta.od : []) state.results.delete(id);
|
|
423
|
+
return state;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Merge the append-only journal onto its header. Returns the header unchanged
|
|
427
|
+
* when no journal exists, when the journal belongs to another turn, or when it
|
|
428
|
+
* has no usable head record. */
|
|
429
|
+
export function replayTurnJournal(header) {
|
|
430
|
+
if (!header) return null;
|
|
431
|
+
let raw;
|
|
432
|
+
try {
|
|
433
|
+
raw = readFileSync(turnJournalPath(header.sessionId), 'utf8');
|
|
434
|
+
} catch {
|
|
435
|
+
return header;
|
|
436
|
+
}
|
|
437
|
+
const messages = header.turnMessages.slice();
|
|
438
|
+
const state = interruptionStateFromSnapshot(header.interruption);
|
|
439
|
+
let updatedAt = Number(header.updatedAt) || 0;
|
|
440
|
+
let head = false;
|
|
441
|
+
// Records are sequence-numbered per turn, so replay is exact under the two
|
|
442
|
+
// things a crash-safe append lane can produce: a DUPLICATE (the exit
|
|
443
|
+
// handler re-writes an operation that may already have landed) and a TORN
|
|
444
|
+
// fragment. Duplicates are skipped, unparsable lines are skipped, and a
|
|
445
|
+
// real gap ends the durable prefix — a missing delta must never let later
|
|
446
|
+
// (relative) deltas apply out of order.
|
|
447
|
+
let expected = 0;
|
|
448
|
+
for (const line of raw.split('\n')) {
|
|
449
|
+
if (!line) continue;
|
|
450
|
+
let record;
|
|
451
|
+
try {
|
|
452
|
+
record = JSON.parse(line);
|
|
453
|
+
} catch {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (!record || typeof record !== 'object') continue;
|
|
457
|
+
const seq = Number(record.s);
|
|
458
|
+
if (!Number.isInteger(seq) || seq < 0) continue;
|
|
459
|
+
if (seq < expected) continue;
|
|
460
|
+
if (seq > expected) break;
|
|
461
|
+
expected = seq + 1;
|
|
462
|
+
if (!head) {
|
|
463
|
+
if (record.t !== 'h' || record.turnToken !== header.turnToken) return header;
|
|
464
|
+
head = true;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (record.t === 'm') {
|
|
468
|
+
const index = Number(record.i);
|
|
469
|
+
if (Number.isInteger(index) && index >= 0 && index <= messages.length) {
|
|
470
|
+
messages[index] = record.m;
|
|
471
|
+
}
|
|
472
|
+
} else if (record.t === 'len') {
|
|
473
|
+
const next = Number(record.n);
|
|
474
|
+
if (Number.isInteger(next) && next >= 0 && next <= messages.length) {
|
|
475
|
+
messages.length = next;
|
|
476
|
+
}
|
|
477
|
+
} else if (record.t === 'x') {
|
|
478
|
+
applyInterruptionDelta(state, record.d);
|
|
479
|
+
}
|
|
480
|
+
const at = Number(record.at);
|
|
481
|
+
if (Number.isFinite(at) && at > updatedAt) updatedAt = at;
|
|
482
|
+
}
|
|
483
|
+
if (!head) return header;
|
|
484
|
+
return {
|
|
485
|
+
...header,
|
|
486
|
+
turnMessages: messages,
|
|
487
|
+
interruption: interruptionSnapshotFromState(state),
|
|
488
|
+
updatedAt,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Per-turn delta encoder. Holds the message refs already journaled (by
|
|
494
|
+
* turn-relative index) plus an opaque interruption cursor, so each encode()
|
|
495
|
+
* touches only what changed since the previous flush.
|
|
496
|
+
*/
|
|
497
|
+
export function createTurnJournalEncoder({ turnToken } = {}) {
|
|
498
|
+
let journaled = [];
|
|
499
|
+
let cursor = null;
|
|
500
|
+
// Per-turn record sequence. It is assigned where records are produced, so
|
|
501
|
+
// the number line is exactly the enqueue order the write lane preserves.
|
|
502
|
+
let seq = 0;
|
|
503
|
+
const line = (record) => {
|
|
504
|
+
const stamped = { ...record, s: seq };
|
|
505
|
+
seq += 1;
|
|
506
|
+
return `${JSON.stringify(stamped)}\n`;
|
|
507
|
+
};
|
|
508
|
+
return {
|
|
509
|
+
/** Seed from the turn's opening state; returns the header's messages
|
|
510
|
+
* and the journal's opening lines (head record, plus — defensively —
|
|
511
|
+
* the delta that makes the journal authoritative if the tracker already
|
|
512
|
+
* carried state at open time). */
|
|
513
|
+
seed(turnOutgoing, currentUserContent, interruption) {
|
|
514
|
+
const source = Array.isArray(turnOutgoing) ? turnOutgoing : [];
|
|
515
|
+
const start = findTurnStart(source, currentUserContent);
|
|
516
|
+
journaled = start >= 0 ? source.slice(start) : [];
|
|
517
|
+
const seeded = typeof interruption?.journalDelta === 'function'
|
|
518
|
+
? interruption.journalDelta(null)
|
|
519
|
+
: null;
|
|
520
|
+
cursor = seeded ? seeded.cursor : null;
|
|
521
|
+
seq = 0;
|
|
522
|
+
const openLines = [line({ t: 'h', turnToken, at: Date.now() })];
|
|
523
|
+
if (seeded?.changed) openLines.push(line({ t: 'x', d: seeded.delta, at: Date.now() }));
|
|
524
|
+
return {
|
|
525
|
+
turnMessages: journaled.map(checkpointMessage),
|
|
526
|
+
openLines,
|
|
527
|
+
};
|
|
528
|
+
},
|
|
529
|
+
encode({ turnOutgoing, currentUserContent, interruption }) {
|
|
530
|
+
const source = Array.isArray(turnOutgoing) ? turnOutgoing : [];
|
|
531
|
+
const start = findTurnStart(source, currentUserContent);
|
|
532
|
+
const length = start >= 0 ? source.length - start : 0;
|
|
533
|
+
const records = [];
|
|
534
|
+
for (let index = 0; index < length; index += 1) {
|
|
535
|
+
const raw = source[start + index];
|
|
536
|
+
if (journaled[index] === raw) continue;
|
|
537
|
+
journaled[index] = raw;
|
|
538
|
+
records.push({ t: 'm', i: index, m: checkpointMessage(raw) });
|
|
539
|
+
}
|
|
540
|
+
if (journaled.length > length) {
|
|
541
|
+
journaled.length = length;
|
|
542
|
+
records.push({ t: 'len', n: length });
|
|
543
|
+
}
|
|
544
|
+
if (typeof interruption?.journalDelta === 'function') {
|
|
545
|
+
const next = interruption.journalDelta(cursor);
|
|
546
|
+
cursor = next.cursor;
|
|
547
|
+
if (next.changed) records.push({ t: 'x', d: next.delta });
|
|
548
|
+
}
|
|
549
|
+
if (records.length === 0) return [];
|
|
550
|
+
// One timestamp per batch keeps `updatedAt` fresh for the
|
|
551
|
+
// newer-than-session recovery guard without a per-record cost.
|
|
552
|
+
records[records.length - 1].at = Date.now();
|
|
553
|
+
return records.map(line);
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|