mixdog 0.9.37 → 0.9.38
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/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/explore-bench.mjs +65 -8
- package/scripts/explore-prompt-policy-test.mjs +6 -2
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/session-sweep.mjs +266 -0
- package/src/rules/agent/30-explorer.md +35 -17
- package/src/rules/shared/01-tool.md +7 -3
- 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/openai-oauth-ws.mjs +6 -6
- 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/manager/ask-session.mjs +18 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
- package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/runtime-core.mjs +43 -8
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/dist/index.mjs +16 -1
- package/src/tui/engine/agent-job-feed.mjs +10 -0
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/session-flow.mjs +15 -1
- package/src/tui/engine/turn.mjs +6 -1
package/package.json
CHANGED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Regression tests for recoverPending recovery bugs:
|
|
2
|
+
// (a) an entry must survive when notifyFn resolves false (not delivered) and
|
|
3
|
+
// only be removed after a confirmed (truthy) ack.
|
|
4
|
+
// (b) a scoped recovery that matches purely on clientHostPid must NOT stamp
|
|
5
|
+
// the reconnecting filter session's id onto another session's abort — it
|
|
6
|
+
// delivers to the true owner session, and leaves the entry persisted when
|
|
7
|
+
// there is no owner session to target.
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
addPending,
|
|
15
|
+
recoverPending,
|
|
16
|
+
} from '../src/runtime/agent/orchestrator/dispatch-persist.mjs';
|
|
17
|
+
|
|
18
|
+
const FILE = 'pending-dispatches.json';
|
|
19
|
+
|
|
20
|
+
function readMap(dir) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(join(dir, FILE), 'utf8');
|
|
23
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
24
|
+
} catch { return {}; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitFor(fn, { timeout = 3000, step = 20 } = {}) {
|
|
28
|
+
const deadline = Date.now() + timeout;
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (fn()) return true;
|
|
31
|
+
if (Date.now() > deadline) return false;
|
|
32
|
+
await new Promise((r) => setTimeout(r, step));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tmp() {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), 'dpersist-'));
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('(a) only explicit false/0 keeps entry; undefined/void resolve deletes', async () => {
|
|
42
|
+
const dir = tmp();
|
|
43
|
+
try {
|
|
44
|
+
addPending(dir, 'h-a', 'recall', ['q'], 'sid-owner', 1111);
|
|
45
|
+
await waitFor(() => 'h-a' in readMap(dir));
|
|
46
|
+
|
|
47
|
+
// Explicit false → undelivered, entry MUST survive for retry.
|
|
48
|
+
recoverPending(dir, () => Promise.resolve(false), {});
|
|
49
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
50
|
+
assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=false');
|
|
51
|
+
|
|
52
|
+
// Explicit 0 → also undelivered, entry survives.
|
|
53
|
+
recoverPending(dir, () => Promise.resolve(0), {});
|
|
54
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
55
|
+
assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=0');
|
|
56
|
+
|
|
57
|
+
// undefined/void resolve from a delivered notifyFn → entry removed.
|
|
58
|
+
recoverPending(dir, () => Promise.resolve(undefined), {});
|
|
59
|
+
const gone = await waitFor(() => !('h-a' in readMap(dir)));
|
|
60
|
+
assert.ok(gone, 'entry not removed after undefined (delivered) resolve');
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('(b) hostPid-only match delivers to true owner session, never the filter session', async () => {
|
|
67
|
+
const dir = tmp();
|
|
68
|
+
try {
|
|
69
|
+
addPending(dir, 'h-b', 'recall', ['q'], 'owner-A', 4242);
|
|
70
|
+
await waitFor(() => 'h-b' in readMap(dir));
|
|
71
|
+
|
|
72
|
+
let seen = null;
|
|
73
|
+
// Reconnect as a DIFFERENT session that only shares the host pid.
|
|
74
|
+
recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
|
|
75
|
+
sessionId: 'session-B',
|
|
76
|
+
clientHostPid: 4242,
|
|
77
|
+
});
|
|
78
|
+
await waitFor(() => seen != null);
|
|
79
|
+
assert.equal(seen.caller_session_id, 'owner-A', 'stamped filter session instead of true owner');
|
|
80
|
+
assert.notEqual(seen.caller_session_id, 'session-B');
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('(b) hostPid-only match with no owner session is left persisted, not injected', async () => {
|
|
87
|
+
const dir = tmp();
|
|
88
|
+
try {
|
|
89
|
+
addPending(dir, 'h-c', 'recall', ['q'], null, 5353);
|
|
90
|
+
await waitFor(() => 'h-c' in readMap(dir));
|
|
91
|
+
|
|
92
|
+
let called = false;
|
|
93
|
+
recoverPending(dir, () => { called = true; return Promise.resolve(true); }, {
|
|
94
|
+
sessionId: 'session-C',
|
|
95
|
+
clientHostPid: 5353,
|
|
96
|
+
});
|
|
97
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
98
|
+
assert.equal(called, false, 'notifyFn fired for an entry with no owner session');
|
|
99
|
+
assert.ok('h-c' in readMap(dir), 'entry with no owner session was not left persisted');
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('owner-session match still stamps the reconnecting session id', async () => {
|
|
106
|
+
const dir = tmp();
|
|
107
|
+
try {
|
|
108
|
+
addPending(dir, 'h-d', 'recall', ['q'], 'prior-sid', 6464);
|
|
109
|
+
await waitFor(() => 'h-d' in readMap(dir));
|
|
110
|
+
|
|
111
|
+
let seen = null;
|
|
112
|
+
recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
|
|
113
|
+
sessionId: 'new-sid',
|
|
114
|
+
priorSessionId: 'prior-sid',
|
|
115
|
+
clientHostPid: 6464,
|
|
116
|
+
});
|
|
117
|
+
await waitFor(() => seen != null);
|
|
118
|
+
assert.equal(seen.caller_session_id, 'new-sid', 'owner match should stamp reconnecting session id');
|
|
119
|
+
} finally {
|
|
120
|
+
rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('priorSessionId match with no current sessionId keeps the entry owner cid', async () => {
|
|
125
|
+
const dir = tmp();
|
|
126
|
+
try {
|
|
127
|
+
addPending(dir, 'h-e', 'recall', ['q'], 'prior-sid', 7575);
|
|
128
|
+
await waitFor(() => 'h-e' in readMap(dir));
|
|
129
|
+
|
|
130
|
+
let seen = null;
|
|
131
|
+
// priorSessionId matches the owner but no current sessionId is supplied →
|
|
132
|
+
// filterSid is null; must fall back to the entry's known owner cid.
|
|
133
|
+
recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
|
|
134
|
+
priorSessionId: 'prior-sid',
|
|
135
|
+
});
|
|
136
|
+
await waitFor(() => seen != null);
|
|
137
|
+
assert.equal(seen.caller_session_id, 'prior-sid', 'dropped known owner cid when sessionId absent');
|
|
138
|
+
} finally {
|
|
139
|
+
rmSync(dir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
@@ -6,13 +6,41 @@
|
|
|
6
6
|
// node scripts/explore-bench.mjs [roundLabel]
|
|
7
7
|
// Fresh process per run = no in-memory result cache carryover.
|
|
8
8
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
-
import { join } from 'node:path';
|
|
9
|
+
import { join, isAbsolute } from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { runExplore } from '../src/standalone/explore-tool.mjs';
|
|
12
12
|
|
|
13
13
|
const ROUND = process.argv[2] || 'r0';
|
|
14
14
|
const CWD = 'C:/Project/mixdog';
|
|
15
15
|
|
|
16
|
+
// Anchor helpers: an anchor line carries a `path:line`. Strict quality =
|
|
17
|
+
// expected token must land ON such a line (not merely somewhere in the text),
|
|
18
|
+
// and anchors===0 is always a miss (except expectFail). Spot-accuracy verifies
|
|
19
|
+
// the referenced file exists and the line number is within the file length.
|
|
20
|
+
function anchorLinesOf(text) {
|
|
21
|
+
return text.split('\n').filter((l) => /:\d+/.test(l));
|
|
22
|
+
}
|
|
23
|
+
function checkAnchorLine(line, ctxTokens = []) {
|
|
24
|
+
const m = line.match(/([A-Za-z0-9_.\/\\-]+):(\d+)/);
|
|
25
|
+
if (!m) return null; // no parseable path:line
|
|
26
|
+
const p = m[1];
|
|
27
|
+
const ln = Number(m[2]);
|
|
28
|
+
const full = isAbsolute(p) ? p : join(CWD, p);
|
|
29
|
+
if (!existsSync(full)) return false;
|
|
30
|
+
try {
|
|
31
|
+
const fileLines = readFileSync(full, 'utf8').split('\n');
|
|
32
|
+
if (!(ln >= 1 && ln <= fileLines.length)) return false;
|
|
33
|
+
// Fabricated-line guard: the cited line's ±2 window must contain a
|
|
34
|
+
// query/expected token, so a plausible-but-wrong line number fails.
|
|
35
|
+
if (ctxTokens.length) {
|
|
36
|
+
// ±2 lines around the 1-based cited line (indices ln-3 .. ln+1 inclusive).
|
|
37
|
+
const win = fileLines.slice(Math.max(0, ln - 3), ln + 2).join('\n').toLowerCase();
|
|
38
|
+
if (!ctxTokens.some((t) => win.includes(t))) return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
16
44
|
// High-fanout timing attribution: emit lightweight per-iteration send_ms
|
|
17
45
|
// loop rows (no verbose payload estimate) so we can split LLM send time
|
|
18
46
|
// from tool time under the parallel round below. Trace-only; no behavior
|
|
@@ -68,6 +96,13 @@ const QUERIES = [
|
|
|
68
96
|
q: 'where is the mixdog config json file path resolved (data dir)',
|
|
69
97
|
expect: ['config', 'plugin-paths'],
|
|
70
98
|
},
|
|
99
|
+
{
|
|
100
|
+
// path-only class: the correct answer is a verified file/dir location on
|
|
101
|
+
// disk; a bare path (no :line) is an allowed HIT for this query.
|
|
102
|
+
q: 'where does mixdog store background shell job stdout logs on disk',
|
|
103
|
+
expect: ['shell-jobs', 'shell-job-paths', 'getShellJobsDir', 'shellJobStdout'],
|
|
104
|
+
pathOnly: true,
|
|
105
|
+
},
|
|
71
106
|
{
|
|
72
107
|
q: 'GraphQL schema stitching resolver implementation', // not in this repo
|
|
73
108
|
expectFail: true,
|
|
@@ -98,17 +133,38 @@ function readTraceSince(ts) {
|
|
|
98
133
|
const t0 = Date.now();
|
|
99
134
|
// Parallel round: all queries fire at once. Per-session trace attribution is
|
|
100
135
|
// by session_id so counts stay correct; per-query ms includes queueing.
|
|
101
|
-
const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail }) => {
|
|
136
|
+
const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail, pathOnly }) => {
|
|
102
137
|
const qt0 = Date.now();
|
|
103
138
|
const res = await runExplore({ query: q, cwd: CWD }, { callerCwd: CWD });
|
|
104
139
|
const text = res?.content?.[0]?.text || '';
|
|
105
|
-
const
|
|
140
|
+
const aLines = anchorLinesOf(text);
|
|
141
|
+
const anchors = aLines.length;
|
|
106
142
|
const failed = /EXPLORATION_FAILED/.test(text);
|
|
107
|
-
|
|
143
|
+
// Context tokens for the fabricated-line guard: expanded expected + query
|
|
144
|
+
// words, length>=4 to drop filler.
|
|
145
|
+
const ctxTokens = [
|
|
146
|
+
...(expect || []).flatMap((e) => e.toLowerCase().split(/[^a-z0-9]+/)),
|
|
147
|
+
...q.toLowerCase().split(/[^a-z0-9]+/),
|
|
148
|
+
].filter((t) => t.length >= 4);
|
|
149
|
+
// Spot-check up to 2 anchors per query for file/line validity + line context.
|
|
150
|
+
let badAnchors = 0;
|
|
151
|
+
for (const l of aLines.slice(0, 2)) {
|
|
152
|
+
if (checkAnchorLine(l, ctxTokens) === false) badAnchors++;
|
|
153
|
+
}
|
|
154
|
+
const allLines = text.split('\n').filter((l) => l.trim());
|
|
155
|
+
// Strict hit: expected token must appear ON an anchor line (>=1 anchor). For
|
|
156
|
+
// the path-only class, an expected token on any verified path line HITs even
|
|
157
|
+
// without :line. expectFail wants EXPLORATION_FAILED with zero anchors.
|
|
108
158
|
const hit = expectFail
|
|
109
159
|
? (failed && anchors === 0)
|
|
110
|
-
:
|
|
111
|
-
|
|
160
|
+
: pathOnly
|
|
161
|
+
? (!failed && (expect || []).some(
|
|
162
|
+
(e) => allLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
|
|
163
|
+
))
|
|
164
|
+
: (!failed && anchors > 0 && (expect || []).some(
|
|
165
|
+
(e) => aLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
|
|
166
|
+
));
|
|
167
|
+
return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, badAnchors, bytes: text.length };
|
|
112
168
|
}));
|
|
113
169
|
|
|
114
170
|
// Identify explorer sessions from trace rows, then attribute both tool calls
|
|
@@ -145,9 +201,10 @@ const sendCount = [...sendBySession.values()].reduce((a, s) => a + s.sends, 0);
|
|
|
145
201
|
|
|
146
202
|
console.log(`\n=== explore-bench round=${ROUND} ===`);
|
|
147
203
|
for (const r of results) {
|
|
148
|
-
console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
|
|
204
|
+
console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} bad=${r.badAnchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
|
|
149
205
|
}
|
|
150
|
-
|
|
206
|
+
const badTotal = results.reduce((a, r) => a + r.badAnchors, 0);
|
|
207
|
+
console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits (strict) bad-anchors=${badTotal}`);
|
|
151
208
|
console.log(` sessions=${bySession.size} toolcalls p50=${p50} max=${callCounts[callCounts.length - 1] ?? 0} total=${callCounts.reduce((a, b) => a + b, 0)}`);
|
|
152
209
|
for (const [id, s] of bySession) console.log(` ${id.slice(-8)}: ${s.tools.join(',')}`);
|
|
153
210
|
const wallMs = Date.now() - t0;
|
|
@@ -13,9 +13,13 @@ test('explore per-query prompt leaves path policy to tool schemas', () => {
|
|
|
13
13
|
|
|
14
14
|
test('grep glob find tool schemas carry unverified path policy', () => {
|
|
15
15
|
const byName = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool]));
|
|
16
|
-
|
|
16
|
+
// Conditional find-first: only genuinely guessed fragments route through
|
|
17
|
+
// find, and in the SAME turn — project root itself is a verified scope.
|
|
18
|
+
assert.match(byName.grep.description, /project root counts as verified/i);
|
|
19
|
+
assert.match(byName.grep.description, /guessed path fragment → find first, same turn/i);
|
|
17
20
|
assert.match(byName.grep.description, /no path "\." \+ guessed src\/\*\*/i);
|
|
18
|
-
assert.match(byName.glob.description, /
|
|
21
|
+
assert.match(byName.glob.description, /project root is verified/i);
|
|
22
|
+
assert.match(byName.glob.description, /Guessed root\/name → find first, same turn/i);
|
|
19
23
|
assert.match(byName.find.description, /verify roots before grep\/glob/i);
|
|
20
24
|
});
|
|
21
25
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// notifyFnForSession must not double-inject a background-task completion. The
|
|
2
|
+
// mirror skip is gated on an EXPLICIT model-visible-delivery ack
|
|
3
|
+
// (modelVisibleDelivered), set ONLY by the TUI execution-ui path that injects
|
|
4
|
+
// the completion body into the active loop — never on a generic truthy
|
|
5
|
+
// onNotification return. So: delivered ack → mirror suppressed; headless /
|
|
6
|
+
// display-only / API listener (no ack) → mirror kept as the sole delivery.
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
|
|
10
|
+
import { shouldMirrorCompletionToPendingQueue } from '../src/session-runtime/runtime-core.mjs';
|
|
11
|
+
|
|
12
|
+
// A terminal agent completion with a Result body — accepted by
|
|
13
|
+
// shouldPersistModelVisibleToolCompletion.
|
|
14
|
+
const completionText = 'Async agent task task_1 completed finished.\n\nResult:\n> ok';
|
|
15
|
+
const completionMeta = { type: 'agent_task_result', execution_id: 'task_1', status: 'completed' };
|
|
16
|
+
|
|
17
|
+
test('delivered-live completion: no pending mirror (explicit model-visible ack)', () => {
|
|
18
|
+
const mirror = shouldMirrorCompletionToPendingQueue({
|
|
19
|
+
callerSessionId: 'sess_live',
|
|
20
|
+
modelVisibleDelivered: true,
|
|
21
|
+
hasEnqueue: true,
|
|
22
|
+
text: completionText,
|
|
23
|
+
meta: completionMeta,
|
|
24
|
+
});
|
|
25
|
+
assert.equal(mirror, false, 'live twin already injected → mirror suppressed');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('headless completion: pending mirror kept (no ack)', () => {
|
|
29
|
+
const mirror = shouldMirrorCompletionToPendingQueue({
|
|
30
|
+
callerSessionId: 'sess_headless',
|
|
31
|
+
modelVisibleDelivered: false,
|
|
32
|
+
hasEnqueue: true,
|
|
33
|
+
text: completionText,
|
|
34
|
+
meta: completionMeta,
|
|
35
|
+
});
|
|
36
|
+
assert.equal(mirror, true, 'no listener delivered → queue copy is the sole delivery');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('display-only listener returning true (no ack): pending mirror kept', () => {
|
|
40
|
+
// A generic display/API listener consumes the event (would return true) but
|
|
41
|
+
// never injects the model-visible body, so it sets NO modelVisibleDelivered
|
|
42
|
+
// ack. The mirror must stay, or the model never sees the completion body.
|
|
43
|
+
const mirror = shouldMirrorCompletionToPendingQueue({
|
|
44
|
+
callerSessionId: 'sess_display_only',
|
|
45
|
+
modelVisibleDelivered: false,
|
|
46
|
+
hasEnqueue: true,
|
|
47
|
+
text: completionText,
|
|
48
|
+
meta: completionMeta,
|
|
49
|
+
});
|
|
50
|
+
assert.equal(mirror, true, 'generic handled===true is NOT an ack → mirror kept');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('unhandled but non-terminal notification is never mirrored', () => {
|
|
54
|
+
const mirror = shouldMirrorCompletionToPendingQueue({
|
|
55
|
+
callerSessionId: 'sess_headless',
|
|
56
|
+
modelVisibleDelivered: false,
|
|
57
|
+
hasEnqueue: true,
|
|
58
|
+
text: 'still running...',
|
|
59
|
+
meta: { status: 'running' },
|
|
60
|
+
});
|
|
61
|
+
assert.equal(mirror, false, 'only persistable terminal completions mirror');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('missing session id or enqueue capability never mirrors', () => {
|
|
65
|
+
assert.equal(shouldMirrorCompletionToPendingQueue({
|
|
66
|
+
callerSessionId: '', modelVisibleDelivered: false, hasEnqueue: true,
|
|
67
|
+
text: completionText, meta: completionMeta,
|
|
68
|
+
}), false, 'no caller session → nothing to mirror into');
|
|
69
|
+
assert.equal(shouldMirrorCompletionToPendingQueue({
|
|
70
|
+
callerSessionId: 'sess_headless', modelVisibleDelivered: false, hasEnqueue: false,
|
|
71
|
+
text: completionText, meta: completionMeta,
|
|
72
|
+
}), false, 'no enqueue capability → cannot mirror');
|
|
73
|
+
});
|
|
@@ -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();
|