mixdog 0.9.87 → 0.9.89
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 +13 -7
- package/scripts/tool-failures.mjs +60 -24
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +1 -2
- package/src/cli.mjs +8 -0
- package/src/defaults/cycle3-review-prompt.md +4 -4
- package/src/rules/agent/00-core.md +3 -0
- package/src/rules/agent/42-cycle3-agent.md +5 -6
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/01-tool.md +29 -35
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -11
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
- package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
- package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
- package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
- package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
- package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
- package/src/runtime/media/lanes.mjs +2 -2
- package/src/runtime/media/renditions.mjs +101 -17
- package/src/runtime/media/renditions.test.mjs +54 -0
- package/src/runtime/media/store.mjs +111 -43
- package/src/runtime/media/store.test.mjs +53 -11
- package/src/runtime/memory/index.mjs +26 -50
- package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
- package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
- package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
- package/src/runtime/memory/lib/http-router.mjs +4 -0
- package/src/runtime/memory/lib/ko-morph.mjs +49 -6
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
- package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
- package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
- package/src/runtime/memory/lib/query-handlers.mjs +26 -28
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/atomic-file.mjs +5 -1
- package/src/runtime/shared/child-guardian.mjs +73 -2
- package/src/runtime/shared/provider-api-key.mjs +22 -5
- package/src/runtime/shared/tool-execution-contract.mjs +53 -0
- package/src/session-runtime/config-helpers.mjs +6 -5
- package/src/session-runtime/lifecycle-api.mjs +16 -4
- package/src/session-runtime/media-api.mjs +31 -16
- package/src/session-runtime/prewarm.mjs +2 -24
- package/src/session-runtime/runtime-core.mjs +33 -15
- package/src/session-runtime/runtime-tunables.mjs +0 -3
- package/src/session-runtime/session-lifecycle.mjs +0 -6
- package/src/session-runtime/session-title.mjs +228 -0
- package/src/session-runtime/session-turn-api.mjs +11 -7
- package/src/session-runtime/workflow-agents-api.mjs +16 -0
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/job-views.mjs +90 -36
- package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
- package/src/standalone/agent-tool/worker-index.mjs +8 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +17 -17
- package/src/standalone/agent-watchdog-registry.mjs +19 -2
- package/src/standalone/channel-daemon.mjs +8 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/standalone/memory-runtime-proxy.mjs +4 -7
- package/src/standalone/projects.mjs +4 -1
- package/src/standalone/usage-dashboard.mjs +25 -3
- package/src/tui/App.jsx +5 -3
- package/src/tui/app/resume-picker.mjs +7 -2
- package/src/tui/app/route-pickers.mjs +11 -117
- package/src/tui/app/slash-dispatch.mjs +13 -3
- package/src/tui/app/transcript-row-estimate.mjs +1 -1
- package/src/tui/app/transcript-window.mjs +17 -93
- package/src/tui/app/use-transcript-scroll.mjs +32 -2
- package/src/tui/app/use-transcript-window.mjs +64 -114
- package/src/tui/components/Markdown.jsx +19 -2
- package/src/tui/components/Message.jsx +8 -3
- package/src/tui/components/ToolExecution.jsx +7 -3
- package/src/tui/dist/index.mjs +243 -223
- package/src/tui/engine/live-share.mjs +15 -5
- package/src/tui/engine/render-timing.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +74 -4
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine.mjs +2 -2
- package/src/tui/hooks/useSharedTick.mjs +0 -2
- package/src/tui/index.jsx +5 -5
- package/src/ui/statusline-agents.mjs +44 -11
- package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
- package/src/workflows/default/WORKFLOW.md +3 -3
- package/src/workflows/solo/WORKFLOW.md +5 -3
- package/src/runtime/media/index.mjs +0 -18
- package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
- package/src/standalone/agent-shard/shard-child.mjs +0 -300
- package/src/standalone/agent-shard/shard-pool.mjs +0 -443
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// in-flight controller, tears down runtime/bash/dedup/offload/pending state,
|
|
5
5
|
// and defers the runtime-map clear. abortSessionTurn cancels the current turn
|
|
6
6
|
// without tombstoning.
|
|
7
|
-
import { loadSession, markSessionClosed, bumpSessionGeneration } from '../store.mjs';
|
|
7
|
+
import { loadSession, markSessionClosed, bumpSessionGeneration, getSessionLifecycleCommitError } from '../store.mjs';
|
|
8
8
|
import { clearReadDedupSession } from '../read-dedup.mjs';
|
|
9
9
|
import { clearOffloadSession } from '../tool-result-offload.mjs';
|
|
10
10
|
import { SessionClosedError } from './session-errors.mjs';
|
|
@@ -70,9 +70,43 @@ export function closeSession(id, reason = 'manual', opts = {}) {
|
|
|
70
70
|
// write could silently overwrite the session after the user resumes
|
|
71
71
|
// it back (BL: burned-session late-save clobber).
|
|
72
72
|
const newGen = tombstone ? markSessionClosed(id, reason) : bumpSessionGeneration(id, reason);
|
|
73
|
+
// Durable lifecycle barrier check. `null` is ambiguous — it covers both a
|
|
74
|
+
// legitimate veto (session gone, live owner, contended commit) and a
|
|
75
|
+
// FAILED durable write. Only the latter publishes a lifecycle commit
|
|
76
|
+
// error. When the barrier never landed there is no tombstone and no
|
|
77
|
+
// generation bump on disk, so nothing fences a late save: tearing down
|
|
78
|
+
// here would abort the provider and clear the crash checkpoint while the
|
|
79
|
+
// session remains open and resumable on disk — the exact way a failed
|
|
80
|
+
// close silently destroys a turn. Leave every runtime structure intact,
|
|
81
|
+
// surface the cause, and report failure to the caller.
|
|
82
|
+
// ANY nonnumeric result means the barrier is not on disk — a write
|
|
83
|
+
// failure, a liveness/ownership veto, a contended commit lock, or a
|
|
84
|
+
// missing record. The detail map only explains WHY when the cause was a
|
|
85
|
+
// failed write; its absence is not evidence of success. Treating a
|
|
86
|
+
// detail-less null as a close is what let a vetoed/failed close abort the
|
|
87
|
+
// provider and drop the crash checkpoint while the session stayed open and
|
|
88
|
+
// resumable on disk. Leave every runtime structure intact and report
|
|
89
|
+
// failure in all of them.
|
|
90
|
+
if (typeof newGen !== 'number') {
|
|
91
|
+
const commitError = getSessionLifecycleCommitError(id);
|
|
92
|
+
const detail = commitError
|
|
93
|
+
? `${commitError.message}${commitError.code ? ` (${commitError.code})` : ''}`
|
|
94
|
+
: 'no durable barrier written (veto, contended commit, or missing record)';
|
|
95
|
+
if (entry) {
|
|
96
|
+
entry.lastError = `session close failed: ${detail}`;
|
|
97
|
+
entry.closeBarrierError = commitError || { message: detail, code: null, reason, at: Date.now() };
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
process.stderr.write(
|
|
101
|
+
`[agent-close] session=${id} reason=${reason} tombstone=${tombstone} `
|
|
102
|
+
+ `FAILED durable lifecycle barrier: ${detail}\n`,
|
|
103
|
+
);
|
|
104
|
+
} catch { /* best-effort */ }
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
73
107
|
// prepareCloseSnapshot was folded into the synchronous generation write
|
|
74
108
|
// above. Only now is it safe to remove the crash fallback.
|
|
75
|
-
|
|
109
|
+
clearTurnCheckpoint(id);
|
|
76
110
|
// 2. Mark runtime as closed so post-await validation in askSession fires.
|
|
77
111
|
if (entry) {
|
|
78
112
|
entry.closed = true;
|
|
@@ -134,3 +168,67 @@ export function abortSessionTurn(id, reason = 'turn-abort') {
|
|
|
134
168
|
try { globalThis.__mixdogCloseProviderConnectionsForSession?.(id, `turn-abort:${reason}`); } catch { /* ignore */ }
|
|
135
169
|
return true;
|
|
136
170
|
}
|
|
171
|
+
|
|
172
|
+
// Stages that still own live provider/tool work. An entry in one of them is
|
|
173
|
+
// never unloaded — the caller's "this agent finished" belief lost a race.
|
|
174
|
+
const _UNLOAD_BLOCKED_STAGES = new Set([
|
|
175
|
+
'connecting', 'requesting', 'streaming', 'tool_running', 'cancelling',
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Runtime-only unload for a session whose work is DONE.
|
|
180
|
+
*
|
|
181
|
+
* Releases the heavy process-local runtime a finished agent still pins —
|
|
182
|
+
* persistent bash shells (real child processes), pooled provider connections,
|
|
183
|
+
* the read-dedup cache, and the liveness/metric runtime entry — WITHOUT
|
|
184
|
+
* touching lifecycle state. Deliberately NOT a close:
|
|
185
|
+
* - no markSessionClosed() tombstone and no bumpSessionGeneration(): the
|
|
186
|
+
* session file stays open and resumable, so a same-tag follow-up
|
|
187
|
+
* (`agent type=send`) resumes the SAME session with its full transcript
|
|
188
|
+
* instead of respawning a fresh one through tombstone absorption;
|
|
189
|
+
* - no controller abort: an unload never cancels work, it only reclaims
|
|
190
|
+
* what a settled turn left behind;
|
|
191
|
+
* - offload sidecars, the turn checkpoint and persisted pending messages
|
|
192
|
+
* are left intact — they are the follow-up turn's context, not runtime.
|
|
193
|
+
* Terminal ownership stays with the caller's own reap path (agent-tool
|
|
194
|
+
* scheduleReap → closeSession('terminal-reap')).
|
|
195
|
+
*
|
|
196
|
+
* Returns false (changing nothing) when the session is still in flight.
|
|
197
|
+
*/
|
|
198
|
+
export function unloadSessionRuntime(id, reason = 'runtime-unload') {
|
|
199
|
+
if (!id) return false;
|
|
200
|
+
const entry = _getRuntimeEntry(id);
|
|
201
|
+
// Liveness veto: an unaborted controller or a non-settled stage means the
|
|
202
|
+
// session still owns provider/tool work. Unloading under it would kill the
|
|
203
|
+
// shells and connections of a running turn.
|
|
204
|
+
//
|
|
205
|
+
// `closed === true` is NOT an exemption. closeSession() flips closed and
|
|
206
|
+
// sets stage='cancelling' while the provider call is still unwinding (it
|
|
207
|
+
// defers _clearSessionRuntime to the next tick precisely because the turn
|
|
208
|
+
// has not settled yet), and a close whose durable barrier failed leaves an
|
|
209
|
+
// unaborted controller behind. Both are live work: veto and touch nothing.
|
|
210
|
+
if (entry) {
|
|
211
|
+
if (entry.controller && !entry.controller.signal?.aborted) return false;
|
|
212
|
+
if (_UNLOAD_BLOCKED_STAGES.has(entry.stage)) return false;
|
|
213
|
+
}
|
|
214
|
+
_stopToolActivityHeartbeat(id);
|
|
215
|
+
// Prefer the in-memory runtime session: shells opened in the last turn may
|
|
216
|
+
// not be on disk yet (same BL-bash-disk-sync reason as closeSession).
|
|
217
|
+
const persisted = entry?.session || loadSession(id);
|
|
218
|
+
const bashSessionId = persisted?.implicitBashSessionId || null;
|
|
219
|
+
const allBashIds = Array.isArray(persisted?.allBashSessionIds)
|
|
220
|
+
? persisted.allBashSessionIds.filter(Boolean)
|
|
221
|
+
: (bashSessionId ? [bashSessionId] : []);
|
|
222
|
+
if (bashSessionId && !allBashIds.includes(bashSessionId)) allBashIds.push(bashSessionId);
|
|
223
|
+
for (const bsid of allBashIds) {
|
|
224
|
+
try { _closeBashSessionLazy(bsid, `runtime-unload:${id}`); } catch { /* ignore */ }
|
|
225
|
+
}
|
|
226
|
+
try { globalThis.__mixdogCloseProviderConnectionsForSession?.(id, `runtime-unload:${reason}`); } catch { /* ignore */ }
|
|
227
|
+
// Pure caches — a resumed turn rebuilds them from the transcript/disk.
|
|
228
|
+
try { clearReadDedupSession(id); } catch { /* ignore */ }
|
|
229
|
+
_clearSessionRuntime(id);
|
|
230
|
+
if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
|
|
231
|
+
try { process.stderr.write(`[agent-unload] session=${id} reason=${reason} shells=${allBashIds.length}\n`); } catch { /* best-effort */ }
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
@@ -47,6 +47,34 @@ export function listSessions(opts = {}) {
|
|
|
47
47
|
// (e.g. agent list includeClosed:true).
|
|
48
48
|
return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
|
|
49
49
|
}
|
|
50
|
+
export async function updateSessionGeneratedTitle(id, title, stage) {
|
|
51
|
+
const session = loadSession(id);
|
|
52
|
+
if (!session || session.closed === true) return false;
|
|
53
|
+
const normalized = String(title || '').replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
54
|
+
const normalizedStage = stage === 'third' ? 'third' : stage === 'first' ? 'first' : '';
|
|
55
|
+
if (!normalized || !normalizedStage) return false;
|
|
56
|
+
if (session.titleLocked === true) return false;
|
|
57
|
+
if (session.generatedTitleStage === 'third') return false;
|
|
58
|
+
if (normalizedStage === 'first' && session.generatedTitleStage === 'first') return false;
|
|
59
|
+
if (session.title === normalized && session.generatedTitleStage === normalizedStage) return false;
|
|
60
|
+
session.title = normalized;
|
|
61
|
+
session.generatedTitleStage = normalizedStage;
|
|
62
|
+
session.titleUpdatedAt = Date.now();
|
|
63
|
+
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
export async function updateSessionManualTitle(id, title) {
|
|
67
|
+
const session = loadSession(id);
|
|
68
|
+
if (!session || session.closed === true) return false;
|
|
69
|
+
const normalized = String(title || '').replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
70
|
+
if (!normalized) return false;
|
|
71
|
+
if (session.title === normalized && session.titleLocked === true) return false;
|
|
72
|
+
session.title = normalized;
|
|
73
|
+
session.titleLocked = true;
|
|
74
|
+
session.titleUpdatedAt = Date.now();
|
|
75
|
+
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
50
78
|
// --- Clear messages (keep system prompt + provider/model/cwd) ---
|
|
51
79
|
export async function clearSessionMessages(sessionId, options = {}) {
|
|
52
80
|
const session = loadSession(sessionId);
|
|
@@ -538,16 +538,62 @@ function _rememberPreparedResume(sessionId, prepared) {
|
|
|
538
538
|
}
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
+
function _preparedResumeForSession(session, preset) {
|
|
542
|
+
const cached = _preparedResumes.get(session.id);
|
|
543
|
+
if (cached?.session === session && cached?.preset === preset) return cached;
|
|
544
|
+
const prepared = _prepareResumeTools(session, preset);
|
|
545
|
+
_rememberPreparedResume(session.id, prepared);
|
|
546
|
+
return prepared;
|
|
547
|
+
}
|
|
548
|
+
|
|
541
549
|
// Desktop session-row hover/idle prefetch. loadSession validates the atomic
|
|
542
550
|
// file signature before reusing its parsed object, so a later external write
|
|
543
551
|
// naturally misses this preparation and rebuilds from the new session object.
|
|
544
552
|
export function prefetchSession(sessionId, preset = 'full') {
|
|
545
553
|
const session = loadSession(sessionId);
|
|
546
554
|
if (!session || session.closed === true) return false;
|
|
547
|
-
|
|
555
|
+
_preparedResumeForSession(session, preset);
|
|
548
556
|
return true;
|
|
549
557
|
}
|
|
550
558
|
|
|
559
|
+
// Read-only desktop-pane projection. It shares the exact prepared tool surface
|
|
560
|
+
// that resumeSession will consume, but clones the session instead of claiming
|
|
561
|
+
// ownership or persisting refreshed metadata. This lets every visible pane run
|
|
562
|
+
// the live context-pressure calculation before it is ever focused.
|
|
563
|
+
export function prepareSessionProjection(session, preset = 'full') {
|
|
564
|
+
if (!session?.id) return null;
|
|
565
|
+
const prepared = _preparedResumeForSession(session, preset);
|
|
566
|
+
let contextMeta = null;
|
|
567
|
+
try {
|
|
568
|
+
const provider = session.provider ? getProvider(session.provider) : null;
|
|
569
|
+
if (provider && session.model) {
|
|
570
|
+
contextMeta = resolveSessionContextMeta(provider, session.model, session);
|
|
571
|
+
}
|
|
572
|
+
} catch {
|
|
573
|
+
// A cold metadata/provider failure must not make the transcript vanish.
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
...session,
|
|
577
|
+
toolSpec: prepared.toolSpec,
|
|
578
|
+
tools: prepared.tools,
|
|
579
|
+
...(contextMeta ? {
|
|
580
|
+
contextWindow: contextMeta.contextWindow,
|
|
581
|
+
rawContextWindow: contextMeta.rawContextWindow,
|
|
582
|
+
effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
|
|
583
|
+
autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
|
|
584
|
+
compactBoundaryTokens: contextMeta.compactBoundaryTokens,
|
|
585
|
+
compaction: {
|
|
586
|
+
...(session.compaction || {}),
|
|
587
|
+
boundaryTokens: contextMeta.compactBoundaryTokens,
|
|
588
|
+
contextWindow: contextMeta.contextWindow,
|
|
589
|
+
rawContextWindow: contextMeta.rawContextWindow,
|
|
590
|
+
effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
|
|
591
|
+
autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
|
|
592
|
+
},
|
|
593
|
+
} : {}),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
551
597
|
// Owner-pid hint for liveness signals that carry NO pid of their own:
|
|
552
598
|
// `session.lastHeartbeatAt` is persisted in the session file and therefore
|
|
553
599
|
// survives its writer forever, and pre-pid `.hb` sidecars only hold a
|
|
@@ -620,6 +666,38 @@ export function isSessionOwnerGone(sessionId) {
|
|
|
620
666
|
return !_isActivelyOwnedElsewhere(session, sessionId);
|
|
621
667
|
}
|
|
622
668
|
|
|
669
|
+
function _recoverTurnCheckpointDurably(session, sessionId) {
|
|
670
|
+
const recovery = recoverTurnCheckpoint(session);
|
|
671
|
+
if (!recovery.changed) return recovery;
|
|
672
|
+
// Recovery is a durable reconnect boundary, not a renderer-only view.
|
|
673
|
+
// Persist before removing the checkpoint so a second crash leaves at
|
|
674
|
+
// least one complete copy of the interrupted turn.
|
|
675
|
+
saveSession(session, {
|
|
676
|
+
sync: true,
|
|
677
|
+
expectedGeneration: session.generation,
|
|
678
|
+
});
|
|
679
|
+
if (recovery.turnToken) {
|
|
680
|
+
clearTurnCheckpoint(sessionId, recovery.turnToken);
|
|
681
|
+
}
|
|
682
|
+
// A force-killed owner cannot clear its activity/presence sidecars. Once
|
|
683
|
+
// its checkpoint is committed as interrupted, neither sidecar may keep a
|
|
684
|
+
// restored pane busy or make a new prompt queue behind a dead process.
|
|
685
|
+
deleteSessionPresence(sessionId);
|
|
686
|
+
void deleteHeartbeat(sessionId);
|
|
687
|
+
return recovery;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Reconnect-safe historical read used by desktop pane peeks. Unlike a normal
|
|
691
|
+
// read-only load, it resolves a dead owner's durable turn checkpoint before
|
|
692
|
+
// returning the transcript. A genuinely live foreign owner remains untouched.
|
|
693
|
+
export function recoverSessionAfterProcessRestart(sessionId) {
|
|
694
|
+
const session = loadSession(sessionId);
|
|
695
|
+
if (!session || session.closed === true) return session || null;
|
|
696
|
+
if (_isActivelyOwnedElsewhere(session, sessionId)) return session;
|
|
697
|
+
_recoverTurnCheckpointDurably(session, sessionId);
|
|
698
|
+
return session;
|
|
699
|
+
}
|
|
700
|
+
|
|
623
701
|
export async function resumeSession(sessionId, preset, options = {}) {
|
|
624
702
|
let session = loadSession(sessionId);
|
|
625
703
|
if (!session)
|
|
@@ -658,19 +736,7 @@ export async function resumeSession(sessionId, preset, options = {}) {
|
|
|
658
736
|
}
|
|
659
737
|
return attached;
|
|
660
738
|
}
|
|
661
|
-
|
|
662
|
-
if (crashRecovery.changed) {
|
|
663
|
-
// Resume must not expose a recovered frame that exists only in memory.
|
|
664
|
-
// Persist synchronously, then remove the sidecar; a second crash at
|
|
665
|
-
// either boundary therefore leaves at least one complete copy.
|
|
666
|
-
saveSession(session, {
|
|
667
|
-
sync: true,
|
|
668
|
-
expectedGeneration: session.generation,
|
|
669
|
-
});
|
|
670
|
-
if (crashRecovery.turnToken) {
|
|
671
|
-
clearTurnCheckpoint(sessionId, crashRecovery.turnToken);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
739
|
+
_recoverTurnCheckpointDurably(session, sessionId);
|
|
674
740
|
const oldTools = session.tools || [];
|
|
675
741
|
const cached = _preparedResumes.get(sessionId);
|
|
676
742
|
_preparedResumes.delete(sessionId);
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
unlinkSync,
|
|
6
6
|
writeFileSync,
|
|
7
7
|
} from 'fs';
|
|
8
|
+
import { writeFile } from 'fs/promises';
|
|
8
9
|
import { randomBytes } from 'crypto';
|
|
9
10
|
import { join } from 'path';
|
|
10
11
|
import { getPluginData } from '../../config.mjs';
|
|
@@ -54,10 +55,134 @@ export function turnMessagesForCheckpoint(messages, currentUserContent) {
|
|
|
54
55
|
return turn.map(checkpointMessage);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
/** Read-only projection for a turn still owned by another process. Unlike
|
|
59
|
+
* recoverTurnCheckpoint this never mutates the session, clears the checkpoint,
|
|
60
|
+
* or marks a live turn as interrupted. */
|
|
61
|
+
export function projectTurnCheckpointMessages(session, checkpoint) {
|
|
62
|
+
const source = Array.isArray(session?.messages) ? session.messages : [];
|
|
63
|
+
if (!session?.id || checkpoint?.sessionId !== session.id
|
|
64
|
+
|| !Array.isArray(checkpoint?.turnMessages)) return source;
|
|
65
|
+
const sessionGeneration = Number(session.generation) || 0;
|
|
66
|
+
const checkpointGeneration = Number(checkpoint.generation) || 0;
|
|
67
|
+
const owner = String(session.owner || '').trim().toLowerCase();
|
|
68
|
+
const agent = String(session.agent || '').trim().toLowerCase();
|
|
69
|
+
const detachedLiveAgent = session.closed === true
|
|
70
|
+
&& (owner === 'agent' || (agent && agent !== 'lead'))
|
|
71
|
+
&& Boolean(String(session.ownerSessionId || session.parentSessionId || '').trim());
|
|
72
|
+
const markerToken = typeof session.activeTurnCheckpoint?.turnToken === 'string'
|
|
73
|
+
? session.activeTurnCheckpoint.turnToken
|
|
74
|
+
: null;
|
|
75
|
+
const markerMatches = markerToken === checkpoint.turnToken;
|
|
76
|
+
const checkpointIsNewer = Number(checkpoint.updatedAt) > Number(session.updatedAt || 0);
|
|
77
|
+
// External workers deliberately detach the durable session before their
|
|
78
|
+
// shard-owned turn finishes. closeSession writes the lifecycle fence by
|
|
79
|
+
// advancing the session generation once, while that already-running turn
|
|
80
|
+
// keeps publishing under its original generation. Accept exactly that
|
|
81
|
+
// one-generation handoff only when the durable marker token still proves
|
|
82
|
+
// identity and the checkpoint was written after the detach.
|
|
83
|
+
const detachedGenerationHandoff = detachedLiveAgent
|
|
84
|
+
&& sessionGeneration === checkpointGeneration + 1
|
|
85
|
+
&& markerMatches
|
|
86
|
+
&& checkpointIsNewer;
|
|
87
|
+
if ((checkpointGeneration !== sessionGeneration && !detachedGenerationHandoff)
|
|
88
|
+
|| (markerToken && !markerMatches)
|
|
89
|
+
|| (!markerToken && !checkpointIsNewer)) return source;
|
|
90
|
+
let start = -1;
|
|
91
|
+
for (let index = source.length - 1; index >= 0; index -= 1) {
|
|
92
|
+
if (matchingUserMessage(source[index], checkpoint.currentUserContent)) {
|
|
93
|
+
start = index;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [
|
|
98
|
+
...(start >= 0 ? source.slice(0, start) : source),
|
|
99
|
+
...checkpoint.turnMessages,
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Async checkpoint writes ──────────────────────────────────────────────────
|
|
104
|
+
// The throttled streaming-delta flushes used to serialize+write synchronously
|
|
105
|
+
// on the engine thread (up to ~6 stalls/second during a tool-heavy turn). The
|
|
106
|
+
// async lane keeps per-session latest-wins coalescing (one in-flight write,
|
|
107
|
+
// one queued payload) with an epoch so clear/stop can retire stale writes —
|
|
108
|
+
// no locks, sessions stay fully parallel. The FIRST write of a turn stays
|
|
109
|
+
// sync (opts.sync) because it is the crash-durability anchor for the prompt.
|
|
110
|
+
const _pendingCheckpointWrites = new Map(); // sessionId → { queued, writing, epoch }
|
|
111
|
+
const _asyncWriteWarned = new Set(); // sessionId — warn once per session
|
|
112
|
+
|
|
113
|
+
function _warnAsyncWriteOnce(sessionId, error) {
|
|
114
|
+
if (_asyncWriteWarned.has(sessionId)) return;
|
|
115
|
+
_asyncWriteWarned.add(sessionId);
|
|
116
|
+
try {
|
|
117
|
+
process.stderr.write(`[turn-checkpoint] async write failed session=${sessionId}: ${error?.message || error}\n`);
|
|
118
|
+
} catch { /* stderr best-effort */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Retire queued + in-flight async writes for a session (epoch bump). */
|
|
122
|
+
export function cancelPendingTurnCheckpoint(sessionId) {
|
|
123
|
+
const entry = _pendingCheckpointWrites.get(sessionId);
|
|
124
|
+
if (!entry) return;
|
|
125
|
+
entry.queued = null;
|
|
126
|
+
entry.epoch += 1;
|
|
127
|
+
if (!entry.writing) _pendingCheckpointWrites.delete(sessionId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _pumpCheckpointWrites(sessionId) {
|
|
131
|
+
const entry = _pendingCheckpointWrites.get(sessionId);
|
|
132
|
+
if (!entry || entry.writing) return;
|
|
133
|
+
if (!entry.queued) {
|
|
134
|
+
_pendingCheckpointWrites.delete(sessionId);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const { target, json } = entry.queued;
|
|
138
|
+
entry.queued = null;
|
|
139
|
+
entry.writing = true;
|
|
140
|
+
const epoch = entry.epoch;
|
|
141
|
+
const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
|
|
142
|
+
writeFile(temp, json, 'utf8')
|
|
143
|
+
.then(() => {
|
|
144
|
+
// A clear/stop after this write was dequeued must win: renaming a
|
|
145
|
+
// retired payload would resurrect a checkpoint the turn already
|
|
146
|
+
// finalized. The epoch check and rename run in one synchronous
|
|
147
|
+
// block, so cancel (also synchronous) can never interleave.
|
|
148
|
+
if (entry.epoch !== epoch) {
|
|
149
|
+
try { unlinkSync(temp); } catch { /* best-effort */ }
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
renameWithRetrySync(temp, target);
|
|
153
|
+
})
|
|
154
|
+
.catch((error) => {
|
|
155
|
+
try { unlinkSync(temp); } catch { /* best-effort */ }
|
|
156
|
+
_warnAsyncWriteOnce(sessionId, error);
|
|
157
|
+
})
|
|
158
|
+
.finally(() => {
|
|
159
|
+
entry.writing = false;
|
|
160
|
+
_pumpCheckpointWrites(sessionId);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Hard-exit parity with the old sync behavior: a queued-but-unwritten payload
|
|
165
|
+
// is flushed synchronously so a clean process exit never loses the newest
|
|
166
|
+
// checkpoint state (crash loss remains bounded by the caller's throttle).
|
|
167
|
+
process.on('exit', () => {
|
|
168
|
+
for (const [sessionId, entry] of _pendingCheckpointWrites) {
|
|
169
|
+
if (!entry.queued) continue;
|
|
170
|
+
const { target, json } = entry.queued;
|
|
171
|
+
entry.queued = null;
|
|
172
|
+
const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
|
|
173
|
+
try {
|
|
174
|
+
writeFileSync(temp, json, 'utf8');
|
|
175
|
+
renameWithRetrySync(temp, target);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
try { unlinkSync(temp); } catch { /* best-effort */ }
|
|
178
|
+
_warnAsyncWriteOnce(sessionId, error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export function writeTurnCheckpoint(checkpoint, { sync = true } = {}) {
|
|
58
184
|
if (!checkpoint?.sessionId || !checkpoint?.turnToken) return false;
|
|
59
185
|
const target = turnCheckpointPath(checkpoint.sessionId);
|
|
60
|
-
const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
|
|
61
186
|
const payload = {
|
|
62
187
|
...checkpoint,
|
|
63
188
|
version: TURN_CHECKPOINT_VERSION,
|
|
@@ -66,6 +191,16 @@ export function writeTurnCheckpoint(checkpoint) {
|
|
|
66
191
|
.map(checkpointMessage),
|
|
67
192
|
updatedAt: Date.now(),
|
|
68
193
|
};
|
|
194
|
+
if (!sync) {
|
|
195
|
+
const json = JSON.stringify(payload);
|
|
196
|
+
const entry = _pendingCheckpointWrites.get(checkpoint.sessionId)
|
|
197
|
+
?? { queued: null, writing: false, epoch: 0 };
|
|
198
|
+
_pendingCheckpointWrites.set(checkpoint.sessionId, entry);
|
|
199
|
+
entry.queued = { target, json };
|
|
200
|
+
_pumpCheckpointWrites(checkpoint.sessionId);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
|
|
69
204
|
try {
|
|
70
205
|
writeFileSync(temp, JSON.stringify(payload), 'utf8');
|
|
71
206
|
renameWithRetrySync(temp, target);
|
|
@@ -94,12 +229,17 @@ export function readTurnCheckpoint(sessionId) {
|
|
|
94
229
|
|
|
95
230
|
export function clearTurnCheckpoint(sessionId, turnToken = null) {
|
|
96
231
|
const target = turnCheckpointPath(sessionId);
|
|
232
|
+
// Queued async writes are stale the moment the caller decides to clear
|
|
233
|
+
// this turn's checkpoint; retire them BEFORE the token guard reads disk
|
|
234
|
+
// so a lagging write can never resurrect the file after the unlink.
|
|
235
|
+
if (!turnToken) cancelPendingTurnCheckpoint(sessionId);
|
|
97
236
|
if (!existsSync(target)) return true;
|
|
98
237
|
if (turnToken) {
|
|
99
238
|
const current = readTurnCheckpoint(sessionId);
|
|
100
239
|
// A token guard prevents an older turn's late terminal save from
|
|
101
240
|
// deleting the checkpoint already created by its queued follow-up.
|
|
102
241
|
if (current?.turnToken && current.turnToken !== turnToken) return false;
|
|
242
|
+
cancelPendingTurnCheckpoint(sessionId);
|
|
103
243
|
}
|
|
104
244
|
try {
|
|
105
245
|
unlinkSync(target);
|