mixdog 0.8.0 → 0.8.1
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 +1 -1
- package/scripts/boot-smoke.mjs +4 -4
- package/scripts/session-context-bench.mjs +172 -0
- package/scripts/tool-smoke.mjs +7 -11
- package/src/mixdog-session-runtime.mjs +121 -12
- package/src/repl.mjs +8 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +3 -3
- package/src/runtime/agent/orchestrator/internal-roles.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +11 -62
- package/src/runtime/agent/orchestrator/session/manager.mjs +19 -7
- package/src/runtime/agent/orchestrator/session/store.mjs +230 -23
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
- package/src/runtime/channels/lib/tool-format.mjs +0 -2
- package/src/runtime/memory/index.mjs +116 -10
- package/src/runtime/memory/lib/memory-cycle1.mjs +10 -10
- package/src/runtime/memory/lib/memory-cycle2.mjs +2 -2
- package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
- package/src/runtime/memory/lib/pg/adapter.mjs +14 -0
- package/src/runtime/shared/tool-surface.mjs +1 -4
- package/src/tui/App.jsx +10 -2
- package/src/tui/components/ContextPanel.jsx +76 -28
- package/src/tui/components/ToolExecution.jsx +26 -7
- package/src/tui/dist/index.mjs +106 -45
- package/src/ui/tool-card.mjs +1 -3
|
@@ -38,13 +38,9 @@ function _stripMcpPrefix(name) {
|
|
|
38
38
|
function _isReadTool(name) {
|
|
39
39
|
return _stripMcpPrefix(name) === 'read';
|
|
40
40
|
}
|
|
41
|
-
function _isScalarWriteEditTool(name) {
|
|
42
|
-
const n = _stripMcpPrefix(name);
|
|
43
|
-
return n === 'write' || n === 'edit';
|
|
44
|
-
}
|
|
45
41
|
function _isMutationTool(name) {
|
|
46
42
|
const n = _stripMcpPrefix(name);
|
|
47
|
-
return n === 'apply_patch'
|
|
43
|
+
return n === 'apply_patch';
|
|
48
44
|
}
|
|
49
45
|
const SCOPED_CACHEABLE_TOOLS = new Set([
|
|
50
46
|
'code_graph',
|
|
@@ -375,8 +371,6 @@ function _ensureTranscriptPairing(msgs, sessionId) {
|
|
|
375
371
|
// is the fail-safe reject at call time.
|
|
376
372
|
const READ_BLOCKED_TOOLS = new Set([
|
|
377
373
|
'shell', 'bash_session',
|
|
378
|
-
'write',
|
|
379
|
-
'edit',
|
|
380
374
|
'apply_patch',
|
|
381
375
|
]);
|
|
382
376
|
const MCP_ONLY_ALLOWED_KINDS = new Set(['mcp', 'internal', 'skill']);
|
|
@@ -1848,9 +1842,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1848
1842
|
//
|
|
1849
1843
|
// Intra-turn duplicate suppression: when an LLM emits two tool_use
|
|
1850
1844
|
// blocks with identical (name, args) inside the SAME assistant turn,
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1845
|
+
// re-executing wastes tokens. Restricted to tools with
|
|
1846
|
+
// `readOnlyHint:true` (= isEagerDispatchable) — bash/apply_patch
|
|
1847
|
+
// may be intentional repeats with distinct side effects.
|
|
1854
1848
|
// Pre-pass identifies duplicates BEFORE startEagerRun so eager
|
|
1855
1849
|
// dispatch also skips them, not just the for-body.
|
|
1856
1850
|
const _duplicateCallIds = new Set();
|
|
@@ -2038,11 +2032,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2038
2032
|
}
|
|
2039
2033
|
}
|
|
2040
2034
|
// A failed executed call keeps its FULL argument body in history so the
|
|
2041
|
-
// model can retry against the original (a large apply_patch `patch`
|
|
2042
|
-
//
|
|
2035
|
+
// model can retry against the original (a large apply_patch `patch`
|
|
2036
|
+
// would otherwise be hidden behind a
|
|
2043
2037
|
// `[mixdog compacted …]` placeholder). Restored IMMEDIATELY — not at end
|
|
2044
2038
|
// of loop — so an abort or post-processing throw after this point cannot
|
|
2045
|
-
// leave a failed
|
|
2039
|
+
// leave a failed patch compacted. Cache-safe: _assistantTurnMsg is not
|
|
2046
2040
|
// transmitted until the next provider.send. Early-continue paths (dedup /
|
|
2047
2041
|
// repeat-failure-guard) never reach here and stay compacted.
|
|
2048
2042
|
if ((!_executeOk || _resultKind === 'error') && call?.id) {
|
|
@@ -2059,10 +2053,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2059
2053
|
if (sessionId && _executeOk && _resultKind === 'normal') {
|
|
2060
2054
|
const _toolBare = _stripMcpPrefix(call.name);
|
|
2061
2055
|
if (_readCacheHit === null && _isReadTool(call.name)) {
|
|
2062
|
-
// Post-
|
|
2056
|
+
// Post-patch advisory: handle BOTH scalar and array forms
|
|
2063
2057
|
// of args.path. The array form (path:[a,b,c] or
|
|
2064
2058
|
// path:[{path:a},{path:b}]) was a coverage gap in R1 —
|
|
2065
|
-
// an LLM that
|
|
2059
|
+
// an LLM that patches X then reads [X,Y] should still see
|
|
2066
2060
|
// the advisory for X.
|
|
2067
2061
|
const _argsPath = call.arguments?.path;
|
|
2068
2062
|
const _pathList = [];
|
|
@@ -2116,59 +2110,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
2116
2110
|
} else {
|
|
2117
2111
|
clearScopedToolsForSession(sessionId);
|
|
2118
2112
|
}
|
|
2119
|
-
} else if (_isScalarWriteEditTool(call.name)) {
|
|
2120
|
-
// Scalar `args.path` only: precise invalidate + advisory mark.
|
|
2121
|
-
// Array-form (`edits[]`/`writes[]`): the tool may have partial-
|
|
2122
|
-
// failed across paths and the result string aggregates;
|
|
2123
|
-
// full-clear instead of falsely marking every path.
|
|
2124
|
-
const _scalarPath = call.arguments?.path || call.arguments?.file_path;
|
|
2125
|
-
const _hasArrayForm = Array.isArray(call.arguments?.edits)
|
|
2126
|
-
|| Array.isArray(call.arguments?.writes);
|
|
2127
|
-
if (_hasArrayForm) {
|
|
2128
|
-
clearReadDedupSession(sessionId);
|
|
2129
|
-
clearScopedToolsForSession(sessionId);
|
|
2130
|
-
// R20: array-form — walk each entry, extract its path,
|
|
2131
|
-
// and invalidate the prefetch cache + mark post-edit for
|
|
2132
|
-
// every distinct touched path. Falls back to the top-
|
|
2133
|
-
// level `path` (or `file_path`) when an entry omits its
|
|
2134
|
-
// own path. This covers both edit edits[] and write
|
|
2135
|
-
// writes[] forms; entries without a resolvable path are
|
|
2136
|
-
// silently skipped (their stat-validation safety net at
|
|
2137
|
-
// next lookup still applies).
|
|
2138
|
-
const _topPath = call.arguments?.path || call.arguments?.file_path;
|
|
2139
|
-
const _entries = call.arguments?.edits || call.arguments?.writes || [];
|
|
2140
|
-
const _seenPaths = new Set();
|
|
2141
|
-
for (const _e of _entries) {
|
|
2142
|
-
const _ep = _e?.path || _e?.file_path || _topPath;
|
|
2143
|
-
if (typeof _ep === 'string' && _ep && !_seenPaths.has(_ep)) {
|
|
2144
|
-
_seenPaths.add(_ep);
|
|
2145
|
-
invalidatePathForSession(sessionId, _ep, cwd);
|
|
2146
|
-
markPostEdit({ sessionId, path: _ep, cwd, toolName: _toolBare });
|
|
2147
|
-
invalidatePrefetchCache(_ep, cwd);
|
|
2148
|
-
}
|
|
2149
|
-
}
|
|
2150
|
-
if (_seenPaths.size > 0) {
|
|
2151
|
-
clearScopedToolsForSessionPaths(sessionId, [..._seenPaths], cwd);
|
|
2152
|
-
}
|
|
2153
|
-
} else if (typeof _scalarPath === 'string') {
|
|
2154
|
-
invalidatePathForSession(sessionId, _scalarPath, cwd);
|
|
2155
|
-
markPostEdit({ sessionId, path: _scalarPath, cwd, toolName: _toolBare });
|
|
2156
|
-
// R20: cross-dispatch prefetch cache invalidation.
|
|
2157
|
-
invalidatePrefetchCache(_scalarPath, cwd);
|
|
2158
|
-
// Targeted scoped-cache invalidation for the single touched path (D).
|
|
2159
|
-
clearScopedToolsForSessionPaths(sessionId, [_scalarPath], cwd);
|
|
2160
|
-
} else {
|
|
2161
|
-
// No path extractable — full wipe fallback.
|
|
2162
|
-
clearScopedToolsForSession(sessionId);
|
|
2163
|
-
}
|
|
2164
2113
|
}
|
|
2165
2114
|
} // end _executeOk+_resultKind gate (scoped tool cache set)
|
|
2166
|
-
// E: mutation tools (apply_patch
|
|
2115
|
+
// E: mutation tools (apply_patch) must invalidate caches
|
|
2167
2116
|
// even on returned-error/partial-fail — the file state is unknown after
|
|
2168
2117
|
// an error exit, and some tools report failure as an Error: result string
|
|
2169
2118
|
// rather than throwing.
|
|
2170
2119
|
// This block runs unconditionally (not gated on _executeOk or _resultKind).
|
|
2171
|
-
if (sessionId && (!_executeOk || _resultKind === 'error') &&
|
|
2120
|
+
if (sessionId && (!_executeOk || _resultKind === 'error') && _stripMcpPrefix(call.name) === 'apply_patch') {
|
|
2172
2121
|
clearReadDedupSession(sessionId);
|
|
2173
2122
|
}
|
|
2174
2123
|
if (_isMutationTool(call.name)) {
|
|
@@ -23,7 +23,7 @@ import { CODE_GRAPH_TOOL_DEFS } from '../tools/code-graph-tool-defs.mjs';
|
|
|
23
23
|
import { executeCodeGraphTool } from '../tools/code-graph.mjs';
|
|
24
24
|
import { closeBashSession } from '../tools/bash-session.mjs';
|
|
25
25
|
import { collectSkillsCached, buildSkillToolDefs, loadAgentTemplate, loadRoleTemplate, composeSystemPrompt, collectMixdogMd } from '../context/collect.mjs';
|
|
26
|
-
import { saveSession, saveSessionAsync, loadSession,
|
|
26
|
+
import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
|
|
27
27
|
import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
|
|
28
28
|
import { clearOffloadSession } from './tool-result-offload.mjs';
|
|
29
29
|
import { classifyResultKind } from './result-classification.mjs';
|
|
@@ -251,7 +251,6 @@ const SESSION_ROUTE_TOOL_ORDER = [
|
|
|
251
251
|
'task',
|
|
252
252
|
];
|
|
253
253
|
const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
|
|
254
|
-
const MODEL_HIDDEN_FILE_MUTATION_TOOLS = new Set(['edit', 'write']);
|
|
255
254
|
const FILESYSTEM_TOOL_NAMES = new Set([
|
|
256
255
|
'code_graph',
|
|
257
256
|
'glob',
|
|
@@ -280,7 +279,7 @@ function orderSessionTools(tools) {
|
|
|
280
279
|
}
|
|
281
280
|
|
|
282
281
|
const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
|
|
283
|
-
...BUILTIN_TOOLS
|
|
282
|
+
...BUILTIN_TOOLS,
|
|
284
283
|
...PATCH_TOOL_DEFS,
|
|
285
284
|
...CODE_GRAPH_TOOL_DEFS,
|
|
286
285
|
]));
|
|
@@ -732,6 +731,7 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
732
731
|
const changed = beforeEncoded && afterEncoded
|
|
733
732
|
? beforeEncoded !== afterEncoded
|
|
734
733
|
: (compacted.length !== messages.length || afterMessageTokens !== beforeMessageTokens);
|
|
734
|
+
const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
|
|
735
735
|
const now = Date.now();
|
|
736
736
|
session.messages = compacted;
|
|
737
737
|
session.providerState = undefined;
|
|
@@ -766,6 +766,7 @@ async function runSessionCompaction(session, opts = {}) {
|
|
|
766
766
|
if (changed && mode === 'auto') session.lastContextTokensStaleAfterCompact = true;
|
|
767
767
|
return {
|
|
768
768
|
changed,
|
|
769
|
+
reason: unchangedReason,
|
|
769
770
|
beforeMessages: messages.length,
|
|
770
771
|
afterMessages: compacted.length,
|
|
771
772
|
beforeTokens,
|
|
@@ -2572,12 +2573,13 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
2572
2573
|
// scope session when the caller passes --scope (agent/<name>).
|
|
2573
2574
|
export function findSessionByScopeKey(scopeKey) {
|
|
2574
2575
|
if (!scopeKey) return null;
|
|
2575
|
-
const
|
|
2576
|
+
const summaries = listStoredSessionSummaries();
|
|
2576
2577
|
// Exclude tombstoned sessions (`closed === true`) so callers never receive
|
|
2577
2578
|
// a session whose controller was aborted by closeSession(). The `closed`
|
|
2578
2579
|
// bit is the authoritative tombstone flag; `status === 'error'` is not,
|
|
2579
2580
|
// since transient-error sessions remain resumable.
|
|
2580
|
-
|
|
2581
|
+
const summary = summaries.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
|
|
2582
|
+
return summary?.id ? loadSession(summary.id) : null;
|
|
2581
2583
|
}
|
|
2582
2584
|
// --- resume (reload tools for a stored session) ---
|
|
2583
2585
|
export async function resumeSession(sessionId, preset) {
|
|
@@ -2617,7 +2619,7 @@ export function getSession(id) {
|
|
|
2617
2619
|
}
|
|
2618
2620
|
export function listSessions(opts = {}) {
|
|
2619
2621
|
const includeClosed = opts.includeClosed === true;
|
|
2620
|
-
const sessions =
|
|
2622
|
+
const sessions = listStoredSessionSummaries();
|
|
2621
2623
|
const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
|
|
2622
2624
|
// Tombstoned sessions (closed===true) are excluded unless the caller opts in
|
|
2623
2625
|
// (e.g. bridge list includeClosed:true).
|
|
@@ -2818,7 +2820,7 @@ export function abortSessionTurn(id, reason = 'turn-abort') {
|
|
|
2818
2820
|
|
|
2819
2821
|
// --- Periodic idle session cleanup ---
|
|
2820
2822
|
const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
|
|
2821
|
-
const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS',
|
|
2823
|
+
const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', CLEANUP_INTERVAL_MS > 0 ? CLEANUP_INTERVAL_MS : 0);
|
|
2822
2824
|
const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
|
|
2823
2825
|
const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h — far longer than any realistic ask race window
|
|
2824
2826
|
let _cleanupTimer = null;
|
|
@@ -2922,7 +2924,17 @@ export function sweepTombstones() {
|
|
|
2922
2924
|
}
|
|
2923
2925
|
}
|
|
2924
2926
|
|
|
2927
|
+
function hasActiveRuntimeWork() {
|
|
2928
|
+
for (const [, entry] of _runtimeState) {
|
|
2929
|
+
if (!entry || entry.closed === true) continue;
|
|
2930
|
+
if (entry.controller && !entry.controller.signal?.aborted) return true;
|
|
2931
|
+
if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;
|
|
2932
|
+
}
|
|
2933
|
+
return false;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2925
2936
|
function _runCleanupCycle() {
|
|
2937
|
+
if (hasActiveRuntimeWork()) return;
|
|
2926
2938
|
sweepIdleSessions({ includeTombstones: true });
|
|
2927
2939
|
}
|
|
2928
2940
|
|
|
@@ -9,9 +9,10 @@ import { randomBytes } from 'crypto';
|
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { Worker } from 'worker_threads';
|
|
11
11
|
import { getPluginData } from '../config.mjs';
|
|
12
|
-
import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
|
|
12
|
+
import { renameWithRetrySync, updateJsonAtomicSync, writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
|
|
13
13
|
|
|
14
14
|
const _lastSaveError = new Map(); // id -> { message, at }
|
|
15
|
+
const SESSION_SUMMARY_INDEX_VERSION = 1;
|
|
15
16
|
|
|
16
17
|
function _renameWithRetrySync(tmp, target) {
|
|
17
18
|
return renameWithRetrySync(tmp, target);
|
|
@@ -32,6 +33,11 @@ function sessionPath(id) {
|
|
|
32
33
|
}
|
|
33
34
|
return join(getStoreDir(), `${id}.json`);
|
|
34
35
|
}
|
|
36
|
+
function summaryIndexPath() {
|
|
37
|
+
const dir = getPluginData();
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
return join(dir, 'session-summaries.json');
|
|
40
|
+
}
|
|
35
41
|
/**
|
|
36
42
|
* Ensure generation/closed defaults on every session object.
|
|
37
43
|
* Older persisted sessions predate these fields; we normalise at load and save.
|
|
@@ -44,6 +50,177 @@ function _ensureLifecycleFields(session) {
|
|
|
44
50
|
return session;
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
function _messageText(content) {
|
|
54
|
+
if (typeof content === 'string') return content;
|
|
55
|
+
if (Array.isArray(content)) {
|
|
56
|
+
return content.map((part) => {
|
|
57
|
+
if (typeof part === 'string') return part;
|
|
58
|
+
if (part?.type === 'text') return part.text || '';
|
|
59
|
+
if (typeof part?.text === 'string') return part.text;
|
|
60
|
+
try { return JSON.stringify(part); } catch { return ''; }
|
|
61
|
+
}).filter(Boolean).join('\n');
|
|
62
|
+
}
|
|
63
|
+
if (content && typeof content === 'object') {
|
|
64
|
+
if (typeof content.text === 'string') return content.text;
|
|
65
|
+
try { return JSON.stringify(content); } catch { return ''; }
|
|
66
|
+
}
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _cleanPreview(text, max = 240) {
|
|
71
|
+
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
|
72
|
+
return value.length > max ? value.slice(0, max).replace(/\s+\S*$/, '').trim() : value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _isPreviewNoise(text) {
|
|
76
|
+
const value = String(text || '').trim();
|
|
77
|
+
if (!value) return true;
|
|
78
|
+
if (value.startsWith('<system-reminder>')) return true;
|
|
79
|
+
if (/^Reference files:/i.test(value)) return true;
|
|
80
|
+
if (value.includes('<!-- bp3-sentinel -->')) return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _sessionPreview(session) {
|
|
85
|
+
const messages = Array.isArray(session?.messages) ? session.messages : [];
|
|
86
|
+
let first = '';
|
|
87
|
+
for (const m of messages) {
|
|
88
|
+
if (m?.role !== 'user') continue;
|
|
89
|
+
const text = _cleanPreview(_messageText(m.content));
|
|
90
|
+
if (_isPreviewNoise(text)) continue;
|
|
91
|
+
if (!first) first = text;
|
|
92
|
+
}
|
|
93
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
94
|
+
const m = messages[i];
|
|
95
|
+
if (m?.role !== 'user') continue;
|
|
96
|
+
const text = _cleanPreview(_messageText(m.content));
|
|
97
|
+
if (_isPreviewNoise(text)) continue;
|
|
98
|
+
return text || first;
|
|
99
|
+
}
|
|
100
|
+
return first;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _sessionMessageCount(session) {
|
|
104
|
+
const messages = Array.isArray(session?.messages) ? session.messages : [];
|
|
105
|
+
let count = 0;
|
|
106
|
+
for (const m of messages) {
|
|
107
|
+
if (m && (m.role === 'user' || m.role === 'assistant')) count++;
|
|
108
|
+
}
|
|
109
|
+
return count;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _positiveNumber(value, fallback = 0) {
|
|
113
|
+
const n = Number(value);
|
|
114
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _sessionSummary(session) {
|
|
118
|
+
if (!session?.id) return null;
|
|
119
|
+
return {
|
|
120
|
+
id: String(session.id),
|
|
121
|
+
updatedAt: _positiveNumber(session.updatedAt, Date.now()),
|
|
122
|
+
createdAt: _positiveNumber(session.createdAt, 0),
|
|
123
|
+
lastHeartbeatAt: _positiveNumber(session.lastHeartbeatAt, 0),
|
|
124
|
+
closed: session.closed === true,
|
|
125
|
+
status: String(session.status || (session.closed === true ? 'closed' : 'idle')),
|
|
126
|
+
owner: session.owner || 'user',
|
|
127
|
+
role: session.role || null,
|
|
128
|
+
sourceType: session.sourceType || null,
|
|
129
|
+
sourceName: session.sourceName || null,
|
|
130
|
+
scopeKey: session.scopeKey || null,
|
|
131
|
+
ownerSessionId: session.ownerSessionId || null,
|
|
132
|
+
clientHostPid: _positiveNumber(session.clientHostPid, 0) || null,
|
|
133
|
+
cwd: session.cwd || '',
|
|
134
|
+
provider: session.provider || null,
|
|
135
|
+
model: session.model || null,
|
|
136
|
+
bridgeTag: session.bridgeTag || null,
|
|
137
|
+
task_id: session.task_id || session.taskId || null,
|
|
138
|
+
permission: session.permission || null,
|
|
139
|
+
toolPermission: session.toolPermission || null,
|
|
140
|
+
messageCount: _sessionMessageCount(session),
|
|
141
|
+
preview: _sessionPreview(session),
|
|
142
|
+
generation: typeof session.generation === 'number' ? session.generation : 0,
|
|
143
|
+
implicitBashSessionId: session.implicitBashSessionId || null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _normalizeSummaryRow(row) {
|
|
148
|
+
if (!row?.id || typeof row.id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(row.id)) return null;
|
|
149
|
+
return {
|
|
150
|
+
id: row.id,
|
|
151
|
+
updatedAt: _positiveNumber(row.updatedAt, 0),
|
|
152
|
+
createdAt: _positiveNumber(row.createdAt, 0),
|
|
153
|
+
lastHeartbeatAt: _positiveNumber(row.lastHeartbeatAt, 0),
|
|
154
|
+
closed: row.closed === true,
|
|
155
|
+
status: String(row.status || (row.closed === true ? 'closed' : 'idle')),
|
|
156
|
+
owner: row.owner || 'user',
|
|
157
|
+
role: row.role || null,
|
|
158
|
+
sourceType: row.sourceType || null,
|
|
159
|
+
sourceName: row.sourceName || null,
|
|
160
|
+
scopeKey: row.scopeKey || null,
|
|
161
|
+
ownerSessionId: row.ownerSessionId || null,
|
|
162
|
+
clientHostPid: _positiveNumber(row.clientHostPid, 0) || null,
|
|
163
|
+
cwd: row.cwd || '',
|
|
164
|
+
provider: row.provider || null,
|
|
165
|
+
model: row.model || null,
|
|
166
|
+
bridgeTag: row.bridgeTag || null,
|
|
167
|
+
task_id: row.task_id || null,
|
|
168
|
+
permission: row.permission || null,
|
|
169
|
+
toolPermission: row.toolPermission || null,
|
|
170
|
+
messageCount: Math.max(0, Math.floor(Number(row.messageCount) || 0)),
|
|
171
|
+
preview: _cleanPreview(row.preview || ''),
|
|
172
|
+
generation: typeof row.generation === 'number' ? row.generation : 0,
|
|
173
|
+
implicitBashSessionId: row.implicitBashSessionId || null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function _normalizeSummaryIndex(raw) {
|
|
178
|
+
const rows = Array.isArray(raw?.rows) ? raw.rows.map(_normalizeSummaryRow).filter(Boolean) : [];
|
|
179
|
+
rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
180
|
+
return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: _positiveNumber(raw?.updatedAt, 0), rows };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _writeSummaryIndex(rows) {
|
|
184
|
+
const cleanRows = (rows || []).map(_normalizeSummaryRow).filter(Boolean)
|
|
185
|
+
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
186
|
+
writeJsonAtomicSync(summaryIndexPath(), {
|
|
187
|
+
version: SESSION_SUMMARY_INDEX_VERSION,
|
|
188
|
+
updatedAt: Date.now(),
|
|
189
|
+
rows: cleanRows,
|
|
190
|
+
}, { compact: true, lock: true });
|
|
191
|
+
return cleanRows;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _upsertSessionSummary(session) {
|
|
195
|
+
const row = _sessionSummary(session);
|
|
196
|
+
if (!row) return;
|
|
197
|
+
try {
|
|
198
|
+
updateJsonAtomicSync(summaryIndexPath(), (cur) => {
|
|
199
|
+
const index = _normalizeSummaryIndex(cur);
|
|
200
|
+
const rows = index.rows.filter((r) => r.id !== row.id);
|
|
201
|
+
const cleanRow = _normalizeSummaryRow(row);
|
|
202
|
+
if (!cleanRow) return undefined;
|
|
203
|
+
const existing = index.rows.find((r) => r.id === row.id) || null;
|
|
204
|
+
if (existing && JSON.stringify(existing) === JSON.stringify(cleanRow)) return undefined;
|
|
205
|
+
rows.push(cleanRow);
|
|
206
|
+
rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
207
|
+
return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
|
|
208
|
+
}, { compact: true, lock: true });
|
|
209
|
+
} catch { /* summary index is best-effort */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function _removeSessionSummary(id) {
|
|
213
|
+
if (!id) return;
|
|
214
|
+
try {
|
|
215
|
+
updateJsonAtomicSync(summaryIndexPath(), (cur) => {
|
|
216
|
+
const index = _normalizeSummaryIndex(cur);
|
|
217
|
+
const rows = index.rows.filter((r) => r.id !== id);
|
|
218
|
+
if (rows.length === index.rows.length) return undefined;
|
|
219
|
+
return { version: SESSION_SUMMARY_INDEX_VERSION, updatedAt: Date.now(), rows };
|
|
220
|
+
}, { compact: true, lock: true });
|
|
221
|
+
} catch { /* summary index is best-effort */ }
|
|
222
|
+
}
|
|
223
|
+
|
|
47
224
|
/** Module-level map tracking in-flight saves per session ID to prevent concurrent write corruption. */
|
|
48
225
|
const _savePending = new Map();
|
|
49
226
|
|
|
@@ -297,6 +474,7 @@ function _doSaveSync(payload) {
|
|
|
297
474
|
return;
|
|
298
475
|
}
|
|
299
476
|
_renameWithRetrySync(tmp, target);
|
|
477
|
+
_upsertSessionSummary(session);
|
|
300
478
|
} catch (err) {
|
|
301
479
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
302
480
|
throw err;
|
|
@@ -390,6 +568,7 @@ async function _doSave(payload) {
|
|
|
390
568
|
return;
|
|
391
569
|
}
|
|
392
570
|
_renameWithRetrySync(tmp, target);
|
|
571
|
+
_upsertSessionSummary(session);
|
|
393
572
|
} catch (err) {
|
|
394
573
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
395
574
|
_savePending.delete(id);
|
|
@@ -426,6 +605,7 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
426
605
|
_savePending.delete(id);
|
|
427
606
|
_clearLiveSession(id);
|
|
428
607
|
_deleteHeartbeat(id);
|
|
608
|
+
_upsertSessionSummary(tombstone);
|
|
429
609
|
// Structured close metric. Single emission point because every close
|
|
430
610
|
// path funnels through markSessionClosed. lifeMs = updatedAt-createdAt
|
|
431
611
|
// straddles the tombstone (updatedAt was just set to Date.now()), so
|
|
@@ -488,6 +668,7 @@ export function deleteSession(id) {
|
|
|
488
668
|
catch { /* fall through to .hb cleanup */ }
|
|
489
669
|
}
|
|
490
670
|
_deleteHeartbeat(id);
|
|
671
|
+
if (removed) _removeSessionSummary(id);
|
|
491
672
|
return removed;
|
|
492
673
|
}
|
|
493
674
|
const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
|
|
@@ -518,6 +699,28 @@ export function listStoredSessions() {
|
|
|
518
699
|
return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
519
700
|
}
|
|
520
701
|
|
|
702
|
+
export function rebuildSessionSummaryIndex() {
|
|
703
|
+
const rows = listStoredSessions().map(_sessionSummary).filter(Boolean);
|
|
704
|
+
return _writeSummaryIndex(rows);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function listStoredSessionSummaries(options = {}) {
|
|
708
|
+
const rebuildIfMissing = options.rebuildIfMissing !== false;
|
|
709
|
+
const p = summaryIndexPath();
|
|
710
|
+
if (!existsSync(p)) {
|
|
711
|
+
return rebuildIfMissing ? rebuildSessionSummaryIndex() : [];
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
const index = _normalizeSummaryIndex(JSON.parse(readFileSync(p, 'utf-8')));
|
|
715
|
+
if (index.rows.length > 0) return index.rows;
|
|
716
|
+
const dir = getStoreDir();
|
|
717
|
+
const hasSessionFiles = existsSync(dir) && readdirSync(dir).some((f) => f.endsWith('.json'));
|
|
718
|
+
return hasSessionFiles && rebuildIfMissing ? rebuildSessionSummaryIndex() : index.rows;
|
|
719
|
+
} catch {
|
|
720
|
+
return rebuildIfMissing ? rebuildSessionSummaryIndex() : [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
521
724
|
/**
|
|
522
725
|
* Raw directory scan — returns every parseable session file without any
|
|
523
726
|
* TTL-based inline deletion. Callers (e.g. sweepTombstones) need to own the
|
|
@@ -552,7 +755,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
552
755
|
const dir = getStoreDir();
|
|
553
756
|
if (!existsSync(dir))
|
|
554
757
|
return { cleaned: 0, remaining: 0, details: [], tombstonesCleaned: 0, tombstoneDetails: [], tombstoneErrors: [] };
|
|
555
|
-
const
|
|
758
|
+
const summaries = listStoredSessionSummaries();
|
|
556
759
|
const now = Date.now();
|
|
557
760
|
let cleaned = 0;
|
|
558
761
|
let remaining = 0;
|
|
@@ -560,38 +763,42 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
560
763
|
const details = [];
|
|
561
764
|
const tombstoneDetails = [];
|
|
562
765
|
const tombstoneErrors = [];
|
|
563
|
-
for (const
|
|
766
|
+
for (const row of summaries) {
|
|
564
767
|
try {
|
|
565
|
-
|
|
768
|
+
if (!row?.id) continue;
|
|
769
|
+
const jsonPath = sessionPath(row.id);
|
|
770
|
+
if (!existsSync(jsonPath)) {
|
|
771
|
+
_removeSessionSummary(row.id);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
566
774
|
let jsonMtime = 0;
|
|
567
775
|
let heartbeatMtime = 0;
|
|
568
776
|
try { jsonMtime = statSync(jsonPath).mtimeMs || 0; } catch {}
|
|
569
777
|
try {
|
|
570
|
-
const hbPath = join(dir,
|
|
778
|
+
const hbPath = join(dir, `${row.id}.hb`);
|
|
571
779
|
if (existsSync(hbPath)) heartbeatMtime = statSync(hbPath).mtimeMs || 0;
|
|
572
780
|
} catch { /* .hb unavailable — fall back to JSON fields */ }
|
|
573
781
|
const freshnessGateMs = sweepIdle ? maxAge : (sweepTombstones ? tombstoneMaxAgeMs : 0);
|
|
574
|
-
const
|
|
575
|
-
if (freshnessGateMs > 0 &&
|
|
782
|
+
const newestKnown = Math.max(row.updatedAt || 0, row.lastHeartbeatAt || 0, row.createdAt || 0, jsonMtime, heartbeatMtime);
|
|
783
|
+
if (freshnessGateMs > 0 && newestKnown > 0 && now - newestKnown <= freshnessGateMs) {
|
|
576
784
|
remaining++;
|
|
577
785
|
continue;
|
|
578
786
|
}
|
|
579
|
-
const session = JSON.parse(readFileSync(jsonPath, 'utf-8'));
|
|
580
787
|
// Already-closed tombstones are handled here before heartbeat
|
|
581
788
|
// sidecar checks; they do not need liveness probing.
|
|
582
|
-
if (
|
|
789
|
+
if (row.closed === true || row.status === 'closed') {
|
|
583
790
|
if (sweepTombstones) {
|
|
584
|
-
const updated = Number(
|
|
791
|
+
const updated = Number(row.updatedAt);
|
|
585
792
|
const age = now - updated;
|
|
586
793
|
if (Number.isFinite(updated) && age >= tombstoneMaxAgeMs) {
|
|
587
794
|
try {
|
|
588
|
-
if (deleteSession(
|
|
795
|
+
if (deleteSession(row.id)) {
|
|
589
796
|
tombstonesCleaned++;
|
|
590
|
-
tombstoneDetails.push({ id:
|
|
797
|
+
tombstoneDetails.push({ id: row.id, ageSeconds: Math.floor(age / 1000) });
|
|
591
798
|
continue;
|
|
592
799
|
}
|
|
593
800
|
} catch (err) {
|
|
594
|
-
tombstoneErrors.push({ id:
|
|
801
|
+
tombstoneErrors.push({ id: row.id, message: err?.message || String(err) });
|
|
595
802
|
remaining++;
|
|
596
803
|
continue;
|
|
597
804
|
}
|
|
@@ -602,7 +809,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
602
809
|
}
|
|
603
810
|
// Sweep bridge-owned and ownerless (legacy) sessions; skip explicit
|
|
604
811
|
// user sessions before touching heartbeat sidecars.
|
|
605
|
-
if (typeof
|
|
812
|
+
if (typeof row.owner === 'string' && row.owner.length > 0 && row.owner !== 'bridge') {
|
|
606
813
|
remaining++;
|
|
607
814
|
continue;
|
|
608
815
|
}
|
|
@@ -613,30 +820,30 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
613
820
|
// Prefer .hb sidecar mtime — updated at tight cadence (≤5s) without
|
|
614
821
|
// serialising the full JSON, so it reflects true liveness more
|
|
615
822
|
// accurately than the JSON timestamp fields.
|
|
616
|
-
let lastActive =
|
|
823
|
+
let lastActive = row.lastHeartbeatAt || row.updatedAt || row.createdAt || 0;
|
|
617
824
|
if (heartbeatMtime) lastActive = Math.max(lastActive, heartbeatMtime);
|
|
618
825
|
// Running sessions are normally reaped by the stream-watchdog
|
|
619
826
|
// within ~120s. Skip them here unless they've been silent past
|
|
620
827
|
// RUNNING_STALL_MS, at which point they are treated as zombies.
|
|
621
|
-
if (
|
|
828
|
+
if (row.status === 'running' && now - lastActive <= RUNNING_STALL_MS) {
|
|
622
829
|
remaining++;
|
|
623
830
|
continue;
|
|
624
831
|
}
|
|
625
|
-
const isCompletedBridge =
|
|
626
|
-
&& BRIDGE_TERMINAL_STATUSES.has(
|
|
832
|
+
const isCompletedBridge = row.owner === 'bridge'
|
|
833
|
+
&& BRIDGE_TERMINAL_STATUSES.has(row.status);
|
|
627
834
|
const sessionMaxAge = isCompletedBridge ? BRIDGE_TERMINAL_TTL_MS : maxAge;
|
|
628
835
|
if (now - lastActive > sessionMaxAge) {
|
|
629
|
-
try { markSessionClosed(
|
|
836
|
+
try { markSessionClosed(row.id, 'idle-sweep'); }
|
|
630
837
|
catch (err) {
|
|
631
|
-
process.stderr.write(`[session-store] idle-sweep close failed for ${
|
|
838
|
+
process.stderr.write(`[session-store] idle-sweep close failed for ${row.id}: ${err?.message}\n`);
|
|
632
839
|
continue;
|
|
633
840
|
}
|
|
634
841
|
cleaned++;
|
|
635
842
|
details.push({
|
|
636
|
-
id:
|
|
637
|
-
owner:
|
|
843
|
+
id: row.id,
|
|
844
|
+
owner: row.owner || 'unknown',
|
|
638
845
|
idleMinutes: Math.round((now - lastActive) / 60000),
|
|
639
|
-
bashSessionId:
|
|
846
|
+
bashSessionId: row.implicitBashSessionId || null,
|
|
640
847
|
});
|
|
641
848
|
} else {
|
|
642
849
|
remaining++;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Cross-process advisory lock for write
|
|
1
|
+
// Cross-process advisory lock for patch/write operations. Uses a sibling
|
|
2
2
|
// lockfile written exclusively (`wx`) and a process.kill(pid, 0) liveness
|
|
3
3
|
// check to clean up after crashed holders. Pair with the in-process
|
|
4
4
|
// withPathLock for the same target: in-process serialises async callers
|
|
@@ -3,7 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { writeJsonAtomicSync } from '../../../../shared/atomic-file.mjs';
|
|
4
4
|
import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
|
|
5
5
|
|
|
6
|
-
// Mixdog read/
|
|
6
|
+
// Mixdog read/patch paths share a scoped snapshot store. Value stores the
|
|
7
7
|
// mtime + size at read-time. A missing scope is fail-closed: snapshots without
|
|
8
8
|
// a real scope id are NOT recorded under a shared default bucket (would let
|
|
9
9
|
// worker A's read satisfy worker B's edit-gate across sessions via a persisted
|