mixdog 0.9.37 → 0.9.39
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 +9 -4
- package/scripts/abort-recovery-test.mjs +43 -2
- package/scripts/agent-tag-reuse-smoke.mjs +146 -6
- package/scripts/agent-terminal-reap-test.mjs +127 -0
- package/scripts/agent-trace-io-test.mjs +69 -0
- package/scripts/code-graph-disk-hit-test.mjs +224 -0
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/execution-completion-dedup-test.mjs +157 -0
- package/scripts/execution-pending-resume-kick-test.mjs +57 -2
- package/scripts/execution-resume-esc-integration-test.mjs +174 -0
- package/scripts/explore-bench.mjs +101 -8
- package/scripts/explore-prompt-policy-test.mjs +156 -11
- package/scripts/find-fuzzy-hidden-test.mjs +122 -0
- package/scripts/internal-comms-bench-test.mjs +226 -0
- package/scripts/internal-comms-bench.mjs +185 -58
- package/scripts/internal-comms-smoke.mjs +171 -23
- package/scripts/live-worker-smoke.mjs +38 -2
- package/scripts/memory-cycle-routing-test.mjs +111 -0
- package/scripts/memory-rule-contract-test.mjs +93 -0
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/rg-runner-test.mjs +240 -0
- package/scripts/routing-corpus-test.mjs +349 -0
- package/scripts/routing-corpus.mjs +211 -32
- package/scripts/session-orphan-sweep-test.mjs +83 -0
- package/scripts/session-sweep.mjs +266 -0
- package/scripts/steering-drain-buckets-test.mjs +179 -0
- package/scripts/tool-smoke.mjs +21 -13
- package/scripts/tool-tui-presentation-test.mjs +202 -0
- package/src/agents/heavy-worker/AGENT.md +10 -7
- package/src/agents/reviewer/AGENT.md +6 -4
- package/src/agents/worker/AGENT.md +7 -5
- package/src/rules/agent/00-common.md +4 -4
- package/src/rules/agent/00-core.md +11 -14
- package/src/rules/agent/20-skip-protocol.md +3 -3
- package/src/rules/agent/30-explorer.md +56 -48
- package/src/rules/agent/40-cycle1-agent.md +15 -24
- package/src/rules/agent/41-cycle2-agent.md +33 -57
- package/src/rules/agent/42-cycle3-agent.md +28 -42
- package/src/rules/lead/01-general.md +7 -10
- package/src/rules/lead/lead-brief.md +11 -14
- package/src/rules/lead/lead-tool.md +6 -5
- package/src/rules/shared/01-tool.md +44 -41
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
- package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
- package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
- package/src/runtime/shared/tool-primitives.mjs +4 -1
- package/src/runtime/shared/tool-status.mjs +27 -0
- package/src/runtime/shared/tool-surface.mjs +6 -3
- package/src/session-runtime/config-helpers.mjs +14 -0
- package/src/session-runtime/context-status.mjs +1 -0
- package/src/session-runtime/effort.mjs +6 -2
- package/src/session-runtime/model-recency.mjs +5 -2
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/runtime-core.mjs +78 -10
- package/src/session-runtime/tool-catalog.mjs +34 -0
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/agent-tool/notify.mjs +13 -0
- package/src/standalone/agent-tool.mjs +45 -69
- package/src/standalone/explore-tool.mjs +6 -7
- package/src/tui/App.jsx +31 -0
- package/src/tui/app/model-options.mjs +5 -3
- package/src/tui/app/model-picker.mjs +12 -24
- package/src/tui/app/transcript-window.mjs +1 -0
- package/src/tui/components/ToolExecution.jsx +11 -6
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
- package/src/tui/components/tool-execution/text-format.mjs +10 -19
- package/src/tui/dist/index.mjs +533 -143
- package/src/tui/engine/agent-job-feed.mjs +153 -16
- package/src/tui/engine/agent-response-tail.mjs +68 -0
- package/src/tui/engine/notification-plan.mjs +16 -0
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/session-api.mjs +8 -2
- package/src/tui/engine/session-flow.mjs +34 -2
- package/src/tui/engine/tool-card-results.mjs +54 -32
- package/src/tui/engine/tool-result-status.mjs +75 -21
- package/src/tui/engine/turn.mjs +83 -43
- package/src/tui/engine.mjs +63 -2
- package/src/workflows/bench/WORKFLOW.md +25 -35
- package/src/workflows/default/WORKFLOW.md +38 -32
- package/src/workflows/solo/WORKFLOW.md +19 -22
- package/scripts/_jitter-fuzz.mjs +0 -44410
- package/scripts/_jitter-fuzz2.mjs +0 -44400
- package/scripts/_jitter-probe.mjs +0 -44397
- package/scripts/_jp2.mjs +0 -45614
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// set by re-running --eval before/after (input constant, only rules change).
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
|
-
import { basename, resolve } from 'node:path';
|
|
8
|
+
import { basename, resolve, win32 } from 'node:path';
|
|
9
9
|
import { resolvePluginData } from '../src/runtime/shared/plugin-paths.mjs';
|
|
10
10
|
|
|
11
11
|
function argValue(name, fallback = null) {
|
|
@@ -64,7 +64,10 @@ function argsSummary(tool, args) {
|
|
|
64
64
|
switch (tool) {
|
|
65
65
|
case 'grep': return clip(Array.isArray(args.pattern) ? `[${args.pattern.length}]${args.pattern[0]}` : args.pattern);
|
|
66
66
|
case 'read': return clip(basename(String(Array.isArray(args.path) ? args.path[0] : args.path || '')));
|
|
67
|
-
case 'code_graph':
|
|
67
|
+
case 'code_graph': {
|
|
68
|
+
const values = args.symbols || args.files || args.symbol || args.file || '';
|
|
69
|
+
return clip(`${args.mode || '?'}:${Array.isArray(values) ? `[${values.length}]${values[0] || ''}` : values}`);
|
|
70
|
+
}
|
|
68
71
|
case 'explore': return arr(args.query);
|
|
69
72
|
case 'find': return clip(args.query);
|
|
70
73
|
case 'glob': return clip(Array.isArray(args.pattern) ? args.pattern[0] : args.pattern);
|
|
@@ -75,9 +78,119 @@ function argsSummary(tool, args) {
|
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
function isMutation(tool) {
|
|
82
|
+
return ['apply_patch', 'edit', 'edit_many', 'write', 'shell'].includes(tool);
|
|
83
|
+
}
|
|
84
|
+
function failed(row) {
|
|
85
|
+
const kind = field(row, 'result_kind');
|
|
86
|
+
return /^(error|failed|failure|command-exit|timeout|permission)$/i.test(String(kind || ''));
|
|
87
|
+
}
|
|
88
|
+
function targetValues(tool, args) {
|
|
89
|
+
if (!args || typeof args !== 'object') return [];
|
|
90
|
+
let key = 'path';
|
|
91
|
+
if (tool === 'code_graph') {
|
|
92
|
+
const fileMode = ['overview', 'imports', 'dependents', 'related', 'impact'].includes(args.mode);
|
|
93
|
+
const symbolMode = ['find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'].includes(args.mode);
|
|
94
|
+
key = fileMode || (args.mode === 'symbols' && (args.files != null || args.file != null))
|
|
95
|
+
? (args.files != null ? 'files' : 'file')
|
|
96
|
+
: symbolMode || args.mode === 'symbols' ? (args.symbols != null ? 'symbols' : 'symbol') : 'file';
|
|
97
|
+
} else if (tool === 'grep' || tool === 'glob') key = 'pattern';
|
|
98
|
+
else if (tool === 'find' || tool === 'explore') key = 'query';
|
|
99
|
+
const value = args[key];
|
|
100
|
+
return Array.isArray(value) ? value : value == null ? [] : [value];
|
|
101
|
+
}
|
|
102
|
+
function batchFields(tool, args) {
|
|
103
|
+
if (tool === 'grep' || tool === 'glob') return ['pattern', 'path'];
|
|
104
|
+
if (tool === 'code_graph') {
|
|
105
|
+
const fileMode = ['overview', 'imports', 'dependents', 'related', 'impact'].includes(args?.mode);
|
|
106
|
+
const symbolMode = ['find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'].includes(args?.mode);
|
|
107
|
+
if (symbolMode && args?.files == null && args?.file == null) return ['symbols', 'symbol'];
|
|
108
|
+
if (symbolMode) return [];
|
|
109
|
+
return [fileMode || args?.mode === 'symbols'
|
|
110
|
+
? (args?.files != null ? 'files' : args?.file != null ? 'file' : args?.symbols != null ? 'symbols' : 'symbol')
|
|
111
|
+
: null].filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
return [tool === 'read' || tool === 'list' ? 'path' : tool === 'find' || tool === 'explore' ? 'query' : null].filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
function compatibleBatchCalls(tool, left, right) {
|
|
116
|
+
if (tool === 'read') {
|
|
117
|
+
const nonBatch = Object.keys({ ...left, ...right }).filter((key) => !['path', 'offset', 'limit'].includes(key));
|
|
118
|
+
return nonBatch.every((key) => JSON.stringify(left?.[key]) === JSON.stringify(right?.[key]));
|
|
119
|
+
}
|
|
120
|
+
const fields = batchFields(tool, left);
|
|
121
|
+
if (!fields.length) return false;
|
|
122
|
+
const differing = fields.filter((key) => JSON.stringify(left?.[key]) !== JSON.stringify(right?.[key]));
|
|
123
|
+
if (differing.length !== 1) return false;
|
|
124
|
+
const batchKey = differing[0];
|
|
125
|
+
if ((Array.isArray(left?.[batchKey]) && left[batchKey].length !== 1) || (Array.isArray(right?.[batchKey]) && right[batchKey].length !== 1)) return false;
|
|
126
|
+
return Object.keys({ ...left, ...right }).filter((key) => !fields.includes(key))
|
|
127
|
+
.every((key) => JSON.stringify(left?.[key]) === JSON.stringify(right?.[key]));
|
|
128
|
+
}
|
|
129
|
+
function readTargets(args) {
|
|
130
|
+
if (!args || typeof args !== 'object') return null;
|
|
131
|
+
const values = Array.isArray(args.path) ? args.path : [args.path];
|
|
132
|
+
return values.map((value) => {
|
|
133
|
+
if (typeof value === 'string') return { path: value, offset: args.offset ?? null, limit: args.limit ?? null };
|
|
134
|
+
if (value && typeof value === 'object' && typeof value.path === 'string') {
|
|
135
|
+
return { path: value.path, offset: value.offset ?? null, limit: value.limit ?? null };
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}).filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
function batchSpec(tool, args, forcedField = null) {
|
|
141
|
+
if (!args || typeof args !== 'object') return null;
|
|
142
|
+
if (tool === 'code_graph') {
|
|
143
|
+
if (!['symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees', 'overview', 'imports', 'dependents', 'related', 'impact'].includes(args.mode)) return null;
|
|
144
|
+
const fileMode = ['overview', 'imports', 'dependents', 'related', 'impact'].includes(args.mode);
|
|
145
|
+
const symbolMode = ['find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'].includes(args.mode);
|
|
146
|
+
if (symbolMode && (args.files != null || args.file != null)) return null;
|
|
147
|
+
const field = fileMode || (args.mode === 'symbols' && (args.files != null || args.file != null))
|
|
148
|
+
? (args.files != null ? 'files' : 'file')
|
|
149
|
+
: (args.symbols != null ? 'symbols' : 'symbol');
|
|
150
|
+
return { field, values: targetValues(tool, args) };
|
|
151
|
+
}
|
|
152
|
+
const fieldName = forcedField || (tool === 'read' || tool === 'list' ? 'path' : tool === 'grep' || tool === 'glob' ? 'pattern' : tool === 'find' || tool === 'explore' ? 'query' : null);
|
|
153
|
+
if (!fieldName) return null;
|
|
154
|
+
const value = args[fieldName];
|
|
155
|
+
return { field: fieldName, values: Array.isArray(value) ? value : value == null ? [] : [value] };
|
|
156
|
+
}
|
|
157
|
+
function sameIterationBatchObservations(sequence) {
|
|
158
|
+
let found = false;
|
|
159
|
+
for (let i = 0; i < sequence.length; i += 1) {
|
|
160
|
+
const first = sequence[i];
|
|
161
|
+
if (isMutation(first.tool) || first.it == null) continue;
|
|
162
|
+
const group = [first];
|
|
163
|
+
for (let j = i + 1; j < sequence.length; j += 1) {
|
|
164
|
+
const next = sequence[j];
|
|
165
|
+
if (next.it !== first.it) break;
|
|
166
|
+
if (isMutation(next.tool)) break;
|
|
167
|
+
group.push(next);
|
|
168
|
+
}
|
|
169
|
+
for (const candidate of ['read', 'grep', 'find', 'glob', 'list', 'explore', 'code_graph']) {
|
|
170
|
+
if (candidate === 'read') {
|
|
171
|
+
const calls = group.filter((entry) => entry.tool === 'read' && !entry.failed).map((entry) => ({ entry, targets: readTargets(entry.rawArgs) })).filter(({ targets }) => targets?.length);
|
|
172
|
+
if (calls.some(({ entry, targets }, index) => calls.some(({ entry: other, targets: otherTargets }, otherIndex) => (
|
|
173
|
+
index < otherIndex
|
|
174
|
+
&& compatibleBatchCalls('read', entry.rawArgs, other.rawArgs)
|
|
175
|
+
&& targets.some((target) => otherTargets.some((otherTarget) => JSON.stringify(target) !== JSON.stringify(otherTarget)))
|
|
176
|
+
)))) found = true;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const firstArgs = group.find((entry) => entry.tool === candidate && !entry.failed)?.rawArgs;
|
|
180
|
+
for (const field of batchFields(candidate, firstArgs || {})) {
|
|
181
|
+
const calls = group.filter((entry) => entry.tool === candidate && !entry.failed).map((entry) => ({ entry, spec: batchSpec(candidate, entry.rawArgs, field) })).filter(({ spec }) => spec && spec.values.length === 1);
|
|
182
|
+
if (calls.length < 2) continue;
|
|
183
|
+
const distinct = new Set(calls.map(({ spec }) => JSON.stringify(spec.values[0])));
|
|
184
|
+
const compatiblePair = calls.some(({ entry, spec }, index) => calls.some(({ entry: other, spec: otherSpec }, otherIndex) => (
|
|
185
|
+
index < otherIndex
|
|
186
|
+
&& JSON.stringify(spec.values[0]) !== JSON.stringify(otherSpec.values[0])
|
|
187
|
+
&& compatibleBatchCalls(candidate, entry.rawArgs, other.rawArgs)
|
|
188
|
+
)));
|
|
189
|
+
if (distinct.size >= 2 && compatiblePair) found = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return found ? ['same_turn_batch_opportunity'] : [];
|
|
81
194
|
}
|
|
82
195
|
|
|
83
196
|
// shell used to INSPECT the filesystem (read/list/search/exists) instead of a
|
|
@@ -104,32 +217,98 @@ function buildCase(sid, toolRows) {
|
|
|
104
217
|
const it = field(r, 'iteration');
|
|
105
218
|
if (it != null) iters.add(it);
|
|
106
219
|
const tool = field(r, 'tool_name') || '?';
|
|
107
|
-
|
|
220
|
+
const rawArgs = field(r, 'tool_args');
|
|
221
|
+
const summaryArgs = rawArgs;
|
|
222
|
+
sequence.push({
|
|
223
|
+
it: it ?? null,
|
|
224
|
+
tool,
|
|
225
|
+
args: argsSummary(tool, rawArgs),
|
|
226
|
+
rawArgs,
|
|
227
|
+
inspectArgs: summaryArgs,
|
|
228
|
+
argsHash: field(r, 'tool_args_hash'),
|
|
229
|
+
resultKind: field(r, 'result_kind'),
|
|
230
|
+
resultLines: Number(field(r, 'result_lines_est') || 0),
|
|
231
|
+
coverage: field(r, 'grep_coverage'),
|
|
232
|
+
cwd: field(r, 'cwd') || '',
|
|
233
|
+
failed: failed(r),
|
|
234
|
+
});
|
|
108
235
|
}
|
|
109
|
-
// per-iteration tool counts for serial detection
|
|
110
|
-
const perIt = new Map();
|
|
111
|
-
for (const s of sequence) { if (s.it == null) continue; perIt.set(s.it, (perIt.get(s.it) || 0) + 1); }
|
|
112
|
-
const itOrder = [...perIt.keys()].sort((a, b) => a - b);
|
|
113
|
-
let longestSingleRun = 0, run = 0;
|
|
114
|
-
for (const it of itOrder) { if (perIt.get(it) === 1) { run += 1; longestSingleRun = Math.max(longestSingleRun, run); } else run = 0; }
|
|
115
|
-
|
|
116
236
|
const names = sequence.map((s) => s.tool);
|
|
117
|
-
const hasAnchor = names.some((n) => n === 'grep' || n === 'code_graph' || n === 'find' || n === 'read');
|
|
118
|
-
const exploreCalls = sequence.filter((s) => s.tool === 'explore');
|
|
119
237
|
const flags = [];
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
238
|
+
const observations = [];
|
|
239
|
+
if (sequence.some((s) => s.tool === 'code_graph' && s.rawArgs?.mode === 'find_symbol' && !s.rawArgs?.file && !s.rawArgs?.files)) flags.push('find_symbol_noscope');
|
|
240
|
+
// Exact duplicate requests are the only relookup signal available in tool
|
|
241
|
+
// traces. Do not infer waste from counts, roles, turns, or explore→inspection:
|
|
242
|
+
// exploration followed by inspection can be the intended route.
|
|
243
|
+
const seenRequests = new Set();
|
|
244
|
+
const readWindows = [];
|
|
245
|
+
let pendingContextGrep = null;
|
|
246
|
+
for (const s of sequence) {
|
|
247
|
+
if (isMutation(s.tool)) {
|
|
248
|
+
seenRequests.clear();
|
|
249
|
+
readWindows.length = 0;
|
|
250
|
+
pendingContextGrep = null;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (s.failed) continue;
|
|
254
|
+
if (['grep', 'read'].includes(s.tool)) {
|
|
255
|
+
const key = s.argsHash ? `${s.tool}:${s.argsHash}` : null;
|
|
256
|
+
const duplicate = key && seenRequests.has(key);
|
|
257
|
+
if (duplicate) flags.push(`${s.tool}_relookup`);
|
|
258
|
+
if (key) seenRequests.add(key);
|
|
259
|
+
if (s.tool === 'read' && !duplicate) {
|
|
260
|
+
const normalize = (value, cwd) => {
|
|
261
|
+
const raw = String(value);
|
|
262
|
+
const base = String(cwd || process.cwd());
|
|
263
|
+
const winStyle = /^[A-Za-z]:[\\/]/.test(raw) || /^[A-Za-z]:[\\/]/.test(base) || raw.includes('\\');
|
|
264
|
+
const resolved = (winStyle ? win32.resolve(base, raw) : resolve(base, raw)).replace(/\\/g, '/');
|
|
265
|
+
return winStyle ? resolved.toLowerCase() : resolved;
|
|
266
|
+
};
|
|
267
|
+
const bounded = (offset, limit) => Number.isFinite(Number(offset)) && Number(offset) >= 0
|
|
268
|
+
&& Number.isFinite(Number(limit)) && Number(limit) > 0;
|
|
269
|
+
for (const target of readTargets(s.rawArgs) || []) {
|
|
270
|
+
const start = bounded(target.offset, target.limit) ? Number(target.offset) : 0;
|
|
271
|
+
const end = bounded(target.offset, target.limit) ? start + Number(target.limit) : Infinity;
|
|
272
|
+
const path = normalize(target.path, s.cwd);
|
|
273
|
+
if (!flags.includes('read_overlap') && readWindows.some((prior) => prior.path === path && start < prior.end && prior.start < end)) flags.push('read_overlap');
|
|
274
|
+
readWindows.push({ path, start, end });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (s.tool === 'grep' && Array.isArray(s.coverage) && s.coverage.length) {
|
|
279
|
+
pendingContextGrep = s;
|
|
280
|
+
} else if (s.tool === 'read' && pendingContextGrep) {
|
|
281
|
+
// Coverage is emitted by the trace formatter from actual path:line
|
|
282
|
+
// output. Missing/boundedness-unknown coverage intentionally stays
|
|
283
|
+
// unclassified; whole-file reads are a documented residual limitation.
|
|
284
|
+
const pathArg = s.rawArgs?.path;
|
|
285
|
+
const bounded = (offset, limit) => Number.isFinite(Number(offset)) && Number(offset) >= 0
|
|
286
|
+
&& Number.isFinite(Number(limit)) && Number(limit) > 0;
|
|
287
|
+
const region = typeof pathArg === 'string' && bounded(s.rawArgs.offset, s.rawArgs.limit)
|
|
288
|
+
? { path: pathArg, start: Number(s.rawArgs.offset) + 1, end: Number(s.rawArgs.offset) + Number(s.rawArgs.limit) }
|
|
289
|
+
: pathArg && typeof pathArg === 'object' && pathArg.path && bounded(pathArg.offset, pathArg.limit)
|
|
290
|
+
? { path: pathArg.path, start: Number(pathArg.offset) + 1, end: Number(pathArg.offset) + Number(pathArg.limit) }
|
|
291
|
+
: null;
|
|
292
|
+
if (region) {
|
|
293
|
+
const normalize = (value, cwd) => {
|
|
294
|
+
const raw = String(value);
|
|
295
|
+
const base = String(cwd || process.cwd());
|
|
296
|
+
const winStyle = /^[A-Za-z]:[\\/]/.test(raw) || /^[A-Za-z]:[\\/]/.test(base) || raw.includes('\\');
|
|
297
|
+
const resolved = resolve(base, raw).replace(/\\/g, '/');
|
|
298
|
+
return winStyle ? resolved.toLowerCase() : resolved;
|
|
299
|
+
};
|
|
300
|
+
const wanted = normalize(region.path, s.cwd || pendingContextGrep.cwd);
|
|
301
|
+
const covered = new Set((pendingContextGrep.coverage || [])
|
|
302
|
+
.filter((item) => normalize(item.path, pendingContextGrep.cwd) === wanted)
|
|
303
|
+
.map((item) => Number(item.line)));
|
|
304
|
+
let complete = true;
|
|
305
|
+
for (let line = region.start; line <= region.end; line += 1) if (!covered.has(line)) { complete = false; break; }
|
|
306
|
+
if (complete) flags.push('grep_context_then_read');
|
|
307
|
+
}
|
|
308
|
+
pendingContextGrep = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
observations.push(...sameIterationBatchObservations(sequence));
|
|
133
312
|
// shell used for filesystem inspection instead of dedicated tools
|
|
134
313
|
if (sequence.some((s) => s.tool === 'shell' && isShellInspect(s.rawArgs?.command))) flags.push('shell_inspect');
|
|
135
314
|
|
|
@@ -137,17 +316,17 @@ function buildCase(sid, toolRows) {
|
|
|
137
316
|
session_id: sid,
|
|
138
317
|
short_id: shortId(sid),
|
|
139
318
|
agent, model,
|
|
140
|
-
turns: iters.size
|
|
319
|
+
turns: iters.size,
|
|
141
320
|
tools: sequence.length,
|
|
142
321
|
first_tool: names[0] || null,
|
|
143
|
-
longest_single_run: longestSingleRun,
|
|
144
322
|
flags,
|
|
323
|
+
observations,
|
|
145
324
|
max_ts: Math.max(...sorted.map((r) => Number(r.ts || 0))),
|
|
146
|
-
sequence: sequence.map((s) => ({ it: s.it, tool: s.tool, args: s.args })),
|
|
325
|
+
sequence: sequence.map((s) => ({ it: s.it, tool: s.tool, args: s.args, result_kind: s.resultKind, result_lines_est: s.resultLines, grep_coverage: s.coverage })),
|
|
147
326
|
};
|
|
148
327
|
}
|
|
149
328
|
|
|
150
|
-
const FLAG_KEYS = ['
|
|
329
|
+
const FLAG_KEYS = ['find_symbol_noscope', 'read_relookup', 'read_overlap', 'grep_relookup', 'grep_context_then_read', 'missed_array_batch', 'shell_inspect'];
|
|
151
330
|
|
|
152
331
|
function buildCorpus(rows, { limit, sinceTs, agentFilter }) {
|
|
153
332
|
let filtered = rows.filter((r) => r.kind === 'tool' && sessionId(r));
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-orphan-sweep-test.mjs — proves the periodic sweep reclaims mature
|
|
3
|
+
* closed tombstones that exist ON DISK but are ABSENT from the summary index.
|
|
4
|
+
*
|
|
5
|
+
* Regression guard for the pre-fix bug where sweepStaleSessions iterated only
|
|
6
|
+
* listStoredSessionSummaries() rows, so on-disk session files missing from a
|
|
7
|
+
* stale index were never visited and their tombstones accumulated forever.
|
|
8
|
+
*/
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
test('mature closed on-disk orphan absent from summary index gets tombstone-swept', async () => {
|
|
16
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-orphan-sweep-'));
|
|
17
|
+
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
18
|
+
try {
|
|
19
|
+
const store = await import('../src/runtime/agent/orchestrator/session/store.mjs');
|
|
20
|
+
const { sweepStaleSessions, _sessionSummary, _writeSummaryIndex } = store;
|
|
21
|
+
|
|
22
|
+
const sessionsDir = join(dataDir, 'sessions');
|
|
23
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
24
|
+
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const TWO_HOURS = 2 * 60 * 60 * 1000;
|
|
27
|
+
const ONE_HOUR = 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
// (1) An INDEXED, fresh, open agent session — present in the summary
|
|
30
|
+
// index so the index is non-empty (blocking any rebuild) and must
|
|
31
|
+
// survive the sweep.
|
|
32
|
+
const indexed = {
|
|
33
|
+
id: 'sess_indexed_keep',
|
|
34
|
+
closed: false,
|
|
35
|
+
status: 'idle',
|
|
36
|
+
owner: 'agent:test',
|
|
37
|
+
updatedAt: now,
|
|
38
|
+
createdAt: now,
|
|
39
|
+
lastHeartbeatAt: now,
|
|
40
|
+
messages: [],
|
|
41
|
+
};
|
|
42
|
+
writeFileSync(join(sessionsDir, `${indexed.id}.json`), JSON.stringify(indexed));
|
|
43
|
+
|
|
44
|
+
// (2) An ORPHAN closed+mature tombstone — on disk, NOT in the index,
|
|
45
|
+
// closed >1h ago. Only reachable if the sweep reconciles the index
|
|
46
|
+
// candidate set with a direct directory scan.
|
|
47
|
+
const orphan = {
|
|
48
|
+
id: 'sess_orphan_tombstone',
|
|
49
|
+
closed: true,
|
|
50
|
+
status: 'closed',
|
|
51
|
+
owner: 'agent:test',
|
|
52
|
+
updatedAt: now - TWO_HOURS,
|
|
53
|
+
createdAt: now - TWO_HOURS,
|
|
54
|
+
messages: [],
|
|
55
|
+
};
|
|
56
|
+
writeFileSync(join(sessionsDir, `${orphan.id}.json`), JSON.stringify(orphan));
|
|
57
|
+
|
|
58
|
+
// Index contains ONLY the indexed session — the orphan is absent.
|
|
59
|
+
const rows = _writeSummaryIndex([_sessionSummary(indexed)]);
|
|
60
|
+
assert.equal(rows.length, 1, 'index seeded with exactly one row');
|
|
61
|
+
assert.ok(!rows.some((r) => r.id === orphan.id), 'orphan is absent from the summary index');
|
|
62
|
+
|
|
63
|
+
// Tombstone-only pass (no idle sweep) with a 1h maturity threshold.
|
|
64
|
+
const result = sweepStaleSessions({ sweepIdle: false, tombstoneMaxAgeMs: ONE_HOUR });
|
|
65
|
+
|
|
66
|
+
assert.equal(result.tombstonesCleaned, 1, 'the orphan tombstone was reclaimed');
|
|
67
|
+
assert.ok(
|
|
68
|
+
result.tombstoneDetails.some((d) => d.id === orphan.id),
|
|
69
|
+
'sweep reports the orphan id among tombstone deletions',
|
|
70
|
+
);
|
|
71
|
+
assert.ok(
|
|
72
|
+
!existsSync(join(sessionsDir, `${orphan.id}.json`)),
|
|
73
|
+
'orphan session file was unlinked from disk',
|
|
74
|
+
);
|
|
75
|
+
assert.ok(
|
|
76
|
+
existsSync(join(sessionsDir, `${indexed.id}.json`)),
|
|
77
|
+
'fresh open indexed session was preserved',
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
81
|
+
delete process.env.MIXDOG_DATA_DIR;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* session-sweep.mjs — DRY-RUN ONLY session-store retention reporter.
|
|
4
|
+
*
|
|
5
|
+
* Reports which session files a prune would reclaim WITHOUT touching disk:
|
|
6
|
+
* NO unlink, NO writes to session-summaries.json, NO mutation of store.mjs
|
|
7
|
+
* runtime behavior. It only reads.
|
|
8
|
+
*
|
|
9
|
+
* Reuses the real store helpers read-only:
|
|
10
|
+
* - getPluginData() → resolves the live data dir (…/.mixdog/data)
|
|
11
|
+
* - summaryIndexPath()/listStoredSessionSummaries() → authoritative
|
|
12
|
+
* per-session lifecycle rows (updatedAt/closed/status), avoiding a full
|
|
13
|
+
* 476MB JSON.parse of every session file.
|
|
14
|
+
*
|
|
15
|
+
* Candidate policy (conservative; keep everything else):
|
|
16
|
+
* - closed/tombstoned sessions (row.closed === true || status === 'closed')
|
|
17
|
+
* ONLY when their closedAt/updatedAt is older than --min-closed-age-days
|
|
18
|
+
* (default 7d) — a recently-closed session may still be resumed, so it is
|
|
19
|
+
* kept until the gate elapses.
|
|
20
|
+
* - OR sessions older than --max-age-days by updatedAt (default 30d)
|
|
21
|
+
* Thresholds are params: --max-age-days=<n> --min-closed-age-days=<n> --now=<epochMs>
|
|
22
|
+
*
|
|
23
|
+
* This tool NEVER deletes. It only prints a report.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
26
|
+
import { readFileSync } from 'fs';
|
|
27
|
+
import { join } from 'path';
|
|
28
|
+
import { getPluginData } from '../src/runtime/agent/orchestrator/config.mjs';
|
|
29
|
+
import {
|
|
30
|
+
summaryIndexPath,
|
|
31
|
+
listStoredSessionSummaries,
|
|
32
|
+
} from '../src/runtime/agent/orchestrator/session/store.mjs';
|
|
33
|
+
|
|
34
|
+
// ── Cheap top-level-only scalar scan ────────────────────────────────────────
|
|
35
|
+
// Same depth-1 tokenizer idea as lifecycle-scan.mjs's scanTopLevelLifecycle
|
|
36
|
+
// (bracket-depth + string-escape aware; the whole `messages` array is skipped
|
|
37
|
+
// by depth counting, never parsed), extended to capture the lifecycle+age
|
|
38
|
+
// scalars we classify on (closed/status/updatedAt/createdAt). This lets the
|
|
39
|
+
// report read the AUTHORITATIVE per-file lifecycle without a full 476MB
|
|
40
|
+
// JSON.parse and without depending on the (stale) summary index.
|
|
41
|
+
const WANT = new Set(['closed', 'status', 'updatedAt', 'createdAt', 'closedAt']);
|
|
42
|
+
function isWs(ch) { return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; }
|
|
43
|
+
function skipString(raw, i) {
|
|
44
|
+
const len = raw.length;
|
|
45
|
+
i++;
|
|
46
|
+
while (i < len) {
|
|
47
|
+
const ch = raw[i];
|
|
48
|
+
if (ch === '\\') { i += 2; continue; }
|
|
49
|
+
if (ch === '"') return i + 1;
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
return i;
|
|
53
|
+
}
|
|
54
|
+
function skipValue(raw, i) {
|
|
55
|
+
const len = raw.length;
|
|
56
|
+
const c = raw[i];
|
|
57
|
+
if (c === '"') return skipString(raw, i);
|
|
58
|
+
if (c === '{' || c === '[') {
|
|
59
|
+
let depth = 1; i++;
|
|
60
|
+
while (i < len && depth > 0) {
|
|
61
|
+
const ch = raw[i];
|
|
62
|
+
if (ch === '"') { i = skipString(raw, i); continue; }
|
|
63
|
+
if (ch === '{' || ch === '[') depth++;
|
|
64
|
+
else if (ch === '}' || ch === ']') depth--;
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
return i;
|
|
68
|
+
}
|
|
69
|
+
while (i < len && raw[i] !== ',' && raw[i] !== '}' && raw[i] !== ']' && !isWs(raw[i])) i++;
|
|
70
|
+
return i;
|
|
71
|
+
}
|
|
72
|
+
function scanTopLevelScalars(raw) {
|
|
73
|
+
const len = raw.length;
|
|
74
|
+
let i = 0;
|
|
75
|
+
while (i < len && isWs(raw[i])) i++;
|
|
76
|
+
if (raw[i] !== '{') return null;
|
|
77
|
+
i++;
|
|
78
|
+
const out = {};
|
|
79
|
+
while (i < len) {
|
|
80
|
+
while (i < len && isWs(raw[i])) i++;
|
|
81
|
+
if (i >= len) return out;
|
|
82
|
+
if (raw[i] === '}') return out;
|
|
83
|
+
if (raw[i] === ',') { i++; continue; }
|
|
84
|
+
if (raw[i] !== '"') return out;
|
|
85
|
+
const keyStart = i;
|
|
86
|
+
i = skipString(raw, i);
|
|
87
|
+
let key;
|
|
88
|
+
try { key = JSON.parse(raw.slice(keyStart, i)); } catch { return out; }
|
|
89
|
+
while (i < len && isWs(raw[i])) i++;
|
|
90
|
+
if (raw[i] !== ':') return out;
|
|
91
|
+
i++;
|
|
92
|
+
while (i < len && isWs(raw[i])) i++;
|
|
93
|
+
const valStart = i;
|
|
94
|
+
i = skipValue(raw, i);
|
|
95
|
+
if (WANT.has(key)) {
|
|
96
|
+
try { out[key] = JSON.parse(raw.slice(valStart, i)); } catch { /* ignore */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseArgs(argv) {
|
|
103
|
+
const out = { maxAgeDays: 30, minClosedAgeDays: 7, now: Date.now() };
|
|
104
|
+
for (const arg of argv) {
|
|
105
|
+
const m = /^--([^=]+)=(.*)$/.exec(arg);
|
|
106
|
+
if (!m) continue;
|
|
107
|
+
const [, key, val] = m;
|
|
108
|
+
if (key === 'max-age-days') out.maxAgeDays = Number(val);
|
|
109
|
+
else if (key === 'min-closed-age-days') out.minClosedAgeDays = Number(val);
|
|
110
|
+
else if (key === 'now') out.now = Number(val);
|
|
111
|
+
}
|
|
112
|
+
if (!Number.isFinite(out.maxAgeDays) || out.maxAgeDays < 0) out.maxAgeDays = 30;
|
|
113
|
+
if (!Number.isFinite(out.minClosedAgeDays) || out.minClosedAgeDays < 0) out.minClosedAgeDays = 7;
|
|
114
|
+
if (!Number.isFinite(out.now) || out.now <= 0) out.now = Date.now();
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fmtBytes(n) {
|
|
119
|
+
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
|
120
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
121
|
+
let i = 0;
|
|
122
|
+
let v = n;
|
|
123
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
|
|
124
|
+
return `${v.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fmtTs(ms) {
|
|
128
|
+
if (!Number.isFinite(ms) || ms <= 0) return 'n/a';
|
|
129
|
+
try { return new Date(ms).toISOString(); } catch { return String(ms); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function main() {
|
|
133
|
+
const { maxAgeDays, minClosedAgeDays, now } = parseArgs(process.argv.slice(2));
|
|
134
|
+
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
|
135
|
+
const minClosedAgeMs = minClosedAgeDays * 24 * 60 * 60 * 1000;
|
|
136
|
+
|
|
137
|
+
const dir = join(getPluginData(), 'sessions');
|
|
138
|
+
if (!existsSync(dir)) {
|
|
139
|
+
process.stdout.write(`[session-sweep] no sessions dir at ${dir}\n`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Authoritative lifecycle rows (read-only). rebuildIfMissing:false so this
|
|
144
|
+
// report never triggers a summary-index write.
|
|
145
|
+
const rows = listStoredSessionSummaries({ rebuildIfMissing: false });
|
|
146
|
+
const rowById = new Map();
|
|
147
|
+
for (const r of rows) if (r?.id) rowById.set(r.id, r);
|
|
148
|
+
|
|
149
|
+
// Disk scan for sizes + mtime fallback. `.hb` sidecar bytes are attributed
|
|
150
|
+
// to their session so reclaimable bytes reflect the full on-disk footprint.
|
|
151
|
+
const files = readdirSync(dir);
|
|
152
|
+
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
|
153
|
+
const hbSizeById = new Map();
|
|
154
|
+
for (const f of files) {
|
|
155
|
+
if (!f.endsWith('.hb')) continue;
|
|
156
|
+
const id = f.slice(0, -3);
|
|
157
|
+
try { hbSizeById.set(id, statSync(join(dir, f)).size || 0); } catch { /* ignore */ }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let totalFiles = 0;
|
|
161
|
+
let totalBytes = 0;
|
|
162
|
+
const candidates = []; // { id, reason, updatedAt, bytes, inIndex }
|
|
163
|
+
let closedCount = 0;
|
|
164
|
+
let ageOnlyCount = 0;
|
|
165
|
+
let closedBytes = 0;
|
|
166
|
+
let ageOnlyBytes = 0;
|
|
167
|
+
let reclaimBytes = 0;
|
|
168
|
+
let scanFailFiles = 0;
|
|
169
|
+
let closedButFreshCount = 0; // closed, but within the min-closed-age gate → kept
|
|
170
|
+
let closedButFreshBytes = 0;
|
|
171
|
+
const fileIds = new Set();
|
|
172
|
+
|
|
173
|
+
for (const f of jsonFiles) {
|
|
174
|
+
const id = f.slice(0, -5);
|
|
175
|
+
fileIds.add(id);
|
|
176
|
+
const full = join(dir, f);
|
|
177
|
+
let size = 0;
|
|
178
|
+
let mtimeMs = 0;
|
|
179
|
+
try { const st = statSync(full); size = st.size || 0; mtimeMs = st.mtimeMs || 0; } catch { /* skip */ }
|
|
180
|
+
const hb = hbSizeById.get(id) || 0;
|
|
181
|
+
const bytes = size + hb;
|
|
182
|
+
totalFiles += 1;
|
|
183
|
+
totalBytes += bytes;
|
|
184
|
+
|
|
185
|
+
const row = rowById.get(id) || null;
|
|
186
|
+
// Authoritative per-file lifecycle via cheap top-level scan; the summary
|
|
187
|
+
// index is stale here (most on-disk files are unindexed), so the file
|
|
188
|
+
// itself — not the index — decides closed/age. Fall back to the index
|
|
189
|
+
// row, then to file mtime, only when the scan can't resolve a field.
|
|
190
|
+
let scan = null;
|
|
191
|
+
try { scan = scanTopLevelScalars(readFileSync(full, 'utf-8')); } catch { scan = null; }
|
|
192
|
+
if (!scan) scanFailFiles += 1;
|
|
193
|
+
const closedScan = scan && (scan.closed === true || scan.status === 'closed');
|
|
194
|
+
const closedRow = row && (row.closed === true || row.status === 'closed');
|
|
195
|
+
const closed = scan ? !!closedScan : !!closedRow;
|
|
196
|
+
let updatedAt = scan && Number(scan.updatedAt) > 0 ? Number(scan.updatedAt) : 0;
|
|
197
|
+
if (!updatedAt && row && Number(row.updatedAt) > 0) updatedAt = Number(row.updatedAt);
|
|
198
|
+
if (!updatedAt) updatedAt = mtimeMs;
|
|
199
|
+
const ageMs = now - updatedAt;
|
|
200
|
+
// Min-closed-age gate: a closed session only qualifies once its close
|
|
201
|
+
// timestamp (closedAt when present, else updatedAt — markSessionClosed
|
|
202
|
+
// sets updatedAt=Date.now() at tombstone time) is older than the gate.
|
|
203
|
+
// Recently-closed sessions may still be resumed, so keep them.
|
|
204
|
+
const closedAt = scan && Number(scan.closedAt) > 0 ? Number(scan.closedAt) : updatedAt;
|
|
205
|
+
const closedAge = now - closedAt;
|
|
206
|
+
const closedQualifies = closed
|
|
207
|
+
&& (minClosedAgeMs <= 0
|
|
208
|
+
|| (Number.isFinite(closedAt) && closedAt > 0 && closedAge > minClosedAgeMs));
|
|
209
|
+
const ageOnly = !closed && maxAgeMs > 0 && Number.isFinite(updatedAt) && updatedAt > 0 && ageMs > maxAgeMs;
|
|
210
|
+
|
|
211
|
+
if (closed && !closedQualifies) {
|
|
212
|
+
closedButFreshCount += 1;
|
|
213
|
+
closedButFreshBytes += bytes;
|
|
214
|
+
}
|
|
215
|
+
if (!closedQualifies && !ageOnly) continue; // keep
|
|
216
|
+
|
|
217
|
+
const reason = closedQualifies ? 'closed' : 'age';
|
|
218
|
+
candidates.push({ id, reason, updatedAt, bytes, inIndex: !!row });
|
|
219
|
+
reclaimBytes += bytes;
|
|
220
|
+
if (closedQualifies) { closedCount += 1; closedBytes += bytes; }
|
|
221
|
+
else { ageOnlyCount += 1; ageOnlyBytes += bytes; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
candidates.sort((a, b) => (a.updatedAt || 0) - (b.updatedAt || 0));
|
|
225
|
+
const oldest = candidates[0] || null;
|
|
226
|
+
const newest = candidates[candidates.length - 1] || null;
|
|
227
|
+
const dropRows = candidates.filter((c) => c.inIndex).length;
|
|
228
|
+
const remainFiles = totalFiles - candidates.length;
|
|
229
|
+
// Rows in the index whose session file is already gone from disk — a
|
|
230
|
+
// rebuild (scans existing files only) drops these regardless of retention.
|
|
231
|
+
const staleRows = rows.filter((r) => r?.id && !fileIds.has(r.id)).length;
|
|
232
|
+
// A real prune would delete candidate files then rebuild the index from the
|
|
233
|
+
// surviving files, so the post-rebuild index equals the surviving-file count
|
|
234
|
+
// (every remaining file gets a row, including currently-unindexed ones).
|
|
235
|
+
const rebuiltIndexRows = remainFiles;
|
|
236
|
+
|
|
237
|
+
const L = [];
|
|
238
|
+
L.push('══ session-sweep DRY-RUN report (NO files deleted, NO writes) ══');
|
|
239
|
+
L.push(`data dir : ${dir}`);
|
|
240
|
+
L.push(`summary index : ${summaryIndexPath()} (${rows.length} rows)`);
|
|
241
|
+
L.push(`now : ${fmtTs(now)}`);
|
|
242
|
+
L.push(`retention (age) : > ${maxAgeDays} days by updatedAt`);
|
|
243
|
+
L.push(`retention (closed) : (closed=true OR status='closed') AND closed > ${minClosedAgeDays}d ago`);
|
|
244
|
+
L.push('');
|
|
245
|
+
L.push(`total session files : ${totalFiles} (${fmtBytes(totalBytes)})`);
|
|
246
|
+
L.push(`candidates (drop) : ${candidates.length} (${fmtBytes(reclaimBytes)} reclaimable)`);
|
|
247
|
+
L.push(` ├─ closed : ${closedCount} (${fmtBytes(closedBytes)})`);
|
|
248
|
+
L.push(` └─ age-only >${maxAgeDays}d : ${ageOnlyCount} (${fmtBytes(ageOnlyBytes)})`);
|
|
249
|
+
L.push(`kept: closed <${minClosedAgeDays}d : ${closedButFreshCount} (${fmtBytes(closedButFreshBytes)}) — within min-closed-age gate`);
|
|
250
|
+
L.push(`would remain : ${remainFiles} files (${fmtBytes(totalBytes - reclaimBytes)})`);
|
|
251
|
+
L.push(`oldest candidate : ${oldest ? `${oldest.id} @ ${fmtTs(oldest.updatedAt)} [${oldest.reason}]` : 'n/a'}`);
|
|
252
|
+
L.push(`newest candidate : ${newest ? `${newest.id} @ ${fmtTs(newest.updatedAt)} [${newest.reason}]` : 'n/a'}`);
|
|
253
|
+
if (scanFailFiles > 0) L.push(`unparsable files : ${scanFailFiles} (fell back to index/mtime)`);
|
|
254
|
+
L.push('');
|
|
255
|
+
L.push('── summary-index rebuild plan ──');
|
|
256
|
+
L.push(`index rows total : ${rows.length}`);
|
|
257
|
+
L.push(`stale rows (no file): ${staleRows} (already-deleted sessions; drop on rebuild)`);
|
|
258
|
+
L.push(`candidate rows drop : ${dropRows} (candidate files that also have an index row)`);
|
|
259
|
+
L.push(`orphan candidates : ${candidates.length - dropRows} (candidate file present, no index row)`);
|
|
260
|
+
L.push(`index rows after : ${rebuiltIndexRows} (= surviving files; rebuild reindexes all remaining)`);
|
|
261
|
+
L.push('');
|
|
262
|
+
L.push('DRY-RUN ONLY — this tool performed no unlink and no disk writes.');
|
|
263
|
+
process.stdout.write(L.join('\n') + '\n');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
main();
|