@yugabytedb/meko-mcp 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +233 -40
- package/assets/hooks-handlers/codex-session-end.sh +4 -69
- package/assets/hooks-handlers/cursor-session-start.sh +19 -1
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
- package/assets/hooks-handlers/lib/capture-notices.js +438 -0
- package/assets/hooks-handlers/lib/capture-state.js +2173 -0
- package/assets/hooks-handlers/lib/capture-status-cli.js +86 -0
- package/assets/hooks-handlers/lib/capture.js +2782 -403
- package/assets/hooks-handlers/lib/checkpoint-timer.js +485 -81
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +760 -0
- package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +427 -0
- package/assets/hooks-handlers/session-end.sh +3 -34
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +123 -16
- package/assets/skills/meko-mcp-tools/SKILL.md +116 -126
- package/assets/skills/meko-mcp-tools/references/_sections.md +3 -3
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +25 -18
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +99 -24
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +53 -6
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +25 -79
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +37 -26
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +66 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +7 -40
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +56 -20
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +15 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +99 -36
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +18 -14
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +39 -28
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
- package/bin/create-meko-setup.mjs +113 -12
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/capture-status.mjs +67 -0
- package/lib/clients/aliases.mjs +5 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/claude-desktop.mjs +120 -5
- package/lib/clients/codex.mjs +85 -37
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +4 -0
- package/lib/clients/kiro.mjs +705 -0
- package/lib/clients/opencode.mjs +532 -0
- package/lib/config.mjs +158 -1
- package/lib/installer.mjs +18 -1
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +497 -0
- package/lib/migrate/adapters/memory-md.mjs +27 -8
- package/lib/migrate/adapters/opencode.mjs +148 -0
- package/lib/migrate/claude-parser.mjs +153 -9
- package/lib/migrate/id.mjs +217 -10
- package/lib/migrate/mcp-client.mjs +25 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +299 -56
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +100 -10
- package/lib/prompt.mjs +16 -1
- package/package.json +4 -4
- package/scripts/bundle-assets.mjs +4 -0
|
@@ -0,0 +1,1473 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* capture-cleanup-cli.js: inspect and clean up stuck conversation capture.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node capture-cleanup-cli.js <action> [--session <id>]... [--all]
|
|
7
|
+
* [--dry-run] [--json] [--yes]
|
|
8
|
+
* [--max-exchanges N] [--pace-ms N]
|
|
9
|
+
*
|
|
10
|
+
* Actions:
|
|
11
|
+
* status Read-only. Sessions with unsent work, parked sessions, and the
|
|
12
|
+
* background capture processes that are running.
|
|
13
|
+
* stop Stop the background capture work that can retry (SessionEnd
|
|
14
|
+
* workers, in-flight drains, timers of sessions that are no
|
|
15
|
+
* longer active), then park every session with unsent work so no
|
|
16
|
+
* hook retries it. Timers of open sessions keep running: they send
|
|
17
|
+
* nothing for a parked session, and drain uses them to tell an
|
|
18
|
+
* open session from a dead one. Nothing is deleted.
|
|
19
|
+
* resume Unpark sessions and hand them back to automatic capture.
|
|
20
|
+
* drain Send parked or pending sessions now, one session at a time, and
|
|
21
|
+
* stop at the first failure. A session that fails is parked again.
|
|
22
|
+
* discard Give up on the unsent exchanges of the named sessions: move the
|
|
23
|
+
* cursor to the end of the transcript and mark them complete.
|
|
24
|
+
* Requires --yes. The exchanges are never sent.
|
|
25
|
+
* repair Clear failure fields left on fully delivered sessions, and
|
|
26
|
+
* remove timer PID files, leases, and SessionEnd job files whose
|
|
27
|
+
* process is gone.
|
|
28
|
+
*
|
|
29
|
+
* Parking works with the capture hooks already installed (2.2.0 and later):
|
|
30
|
+
* a drain skips the network while `failure_class` is set and
|
|
31
|
+
* `next_retry_at` is in the future, and recovery never selects such a
|
|
32
|
+
* session unless its failure class is `persistent`. A parked session keeps
|
|
33
|
+
* its cursor, so `resume` or `drain` loses nothing.
|
|
34
|
+
*
|
|
35
|
+
* Exit codes: 0 done, 1 usage or I/O error, 2 partly done (a session was
|
|
36
|
+
* busy, or drain stopped at a failure).
|
|
37
|
+
*
|
|
38
|
+
* Honors MEKO_WATERMARK_DIR. Invoked by installer --capture-cleanup.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
"use strict";
|
|
42
|
+
|
|
43
|
+
const { spawnSync } = require("child_process");
|
|
44
|
+
const fs = require("fs");
|
|
45
|
+
const os = require("os");
|
|
46
|
+
const path = require("path");
|
|
47
|
+
|
|
48
|
+
const cs = require("./capture-state");
|
|
49
|
+
const { PARKED_REASON_PREFIX } = require("./capture-notices");
|
|
50
|
+
|
|
51
|
+
const ACTIONS = ["status", "stop", "resume", "drain", "discard", "repair"];
|
|
52
|
+
const PARK_UNTIL = "9999-12-31T00:00:00.000Z";
|
|
53
|
+
const PARKED_BY = "meko-mcp --capture-cleanup";
|
|
54
|
+
const RESUME_HINT =
|
|
55
|
+
`${PARKED_REASON_PREFIX}; run \`npx @yugabytedb/meko-mcp@latest ` +
|
|
56
|
+
"--capture-cleanup drain --all` to send, or `resume` to hand back to the hooks";
|
|
57
|
+
const PENDING_DELIVERIES = [
|
|
58
|
+
"needs_conversation",
|
|
59
|
+
"pending",
|
|
60
|
+
"draining",
|
|
61
|
+
"retry_wait",
|
|
62
|
+
"blocked",
|
|
63
|
+
];
|
|
64
|
+
// capture.js modes that run in the background. Foreground hook modes
|
|
65
|
+
// (session-start, pre-compact) belong to a live client and are left alone.
|
|
66
|
+
const BACKGROUND_CAPTURE_MODES = ["drain", "recover", "checkpoint", "session-end"];
|
|
67
|
+
const DEFAULT_MAX_EXCHANGES = 200;
|
|
68
|
+
const DEFAULT_PACE_MS = 1000;
|
|
69
|
+
const KILL_WAIT_MS = 3000;
|
|
70
|
+
// A lease file that doesn't parse may be mid-write (acquireLease creates it
|
|
71
|
+
// with `wx`, then writes); only an old one is debris.
|
|
72
|
+
const UNPARSABLE_LEASE_MIN_AGE_MS = 60_000;
|
|
73
|
+
const REPARK_ATTEMPTS = 10;
|
|
74
|
+
const REPARK_RETRY_MS = 1000;
|
|
75
|
+
const LEASE_POLL_MS = 500;
|
|
76
|
+
const MAX_LEASE_WAITS = 5;
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Args
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function usage() {
|
|
83
|
+
return (
|
|
84
|
+
"Usage: capture-cleanup-cli.js <status|stop|resume|drain|discard|repair>\n" +
|
|
85
|
+
" [--session <id>]... [--all] [--dry-run] [--json] [--yes]\n" +
|
|
86
|
+
" [--max-exchanges N] [--pace-ms N]\n"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseArgs(argv) {
|
|
91
|
+
const out = {
|
|
92
|
+
action: null,
|
|
93
|
+
sessions: [],
|
|
94
|
+
all: false,
|
|
95
|
+
dryRun: false,
|
|
96
|
+
json: false,
|
|
97
|
+
yes: false,
|
|
98
|
+
maxExchanges: DEFAULT_MAX_EXCHANGES,
|
|
99
|
+
paceMs: DEFAULT_PACE_MS,
|
|
100
|
+
};
|
|
101
|
+
const intArg = (name, raw) => {
|
|
102
|
+
const n = Number(raw);
|
|
103
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
104
|
+
throw new Error(`${name} must be a non-negative integer, got "${raw}"`);
|
|
105
|
+
}
|
|
106
|
+
return n;
|
|
107
|
+
};
|
|
108
|
+
for (let i = 0; i < argv.length; i++) {
|
|
109
|
+
const a = argv[i];
|
|
110
|
+
const next = () => {
|
|
111
|
+
if (i + 1 >= argv.length) throw new Error(`${a} requires a value`);
|
|
112
|
+
return argv[++i];
|
|
113
|
+
};
|
|
114
|
+
if (a === "--help" || a === "-h") out.help = true;
|
|
115
|
+
else if (a === "--session") out.sessions.push(next());
|
|
116
|
+
else if (a.startsWith("--session=")) out.sessions.push(a.slice(10));
|
|
117
|
+
else if (a === "--all") out.all = true;
|
|
118
|
+
else if (a === "--dry-run") out.dryRun = true;
|
|
119
|
+
else if (a === "--json") out.json = true;
|
|
120
|
+
else if (a === "--yes") out.yes = true;
|
|
121
|
+
else if (a === "--max-exchanges") out.maxExchanges = intArg(a, next());
|
|
122
|
+
else if (a === "--pace-ms") out.paceMs = intArg(a, next());
|
|
123
|
+
else if (!a.startsWith("-") && !out.action) out.action = a;
|
|
124
|
+
else throw new Error(`unknown argument: ${a}`);
|
|
125
|
+
}
|
|
126
|
+
if (!out.help) {
|
|
127
|
+
if (!out.action) out.action = "status";
|
|
128
|
+
if (!ACTIONS.includes(out.action)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`unknown action "${out.action}"; expected one of ${ACTIONS.join(", ")}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Session helpers
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function isPidAlive(pid) {
|
|
142
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
143
|
+
try {
|
|
144
|
+
process.kill(pid, 0);
|
|
145
|
+
return true;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// EPERM means the process exists but belongs to someone else.
|
|
148
|
+
return Boolean(err && err.code === "EPERM");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function hasPendingWork(state) {
|
|
153
|
+
if (!state || state.delivery === "complete") return false;
|
|
154
|
+
return (
|
|
155
|
+
(Number(state.queued_exchanges) || 0) > 0 ||
|
|
156
|
+
PENDING_DELIVERIES.includes(state.delivery)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* True only while the park is still in effect. Hook writes merge the file on
|
|
162
|
+
* disk under their candidate, so a `parked` object survives a rebucket or a
|
|
163
|
+
* successful drain that has already reset the retry fields.
|
|
164
|
+
*/
|
|
165
|
+
function isParked(state) {
|
|
166
|
+
return Boolean(
|
|
167
|
+
state &&
|
|
168
|
+
state.parked &&
|
|
169
|
+
typeof state.parked === "object" &&
|
|
170
|
+
state.failure_class &&
|
|
171
|
+
state.next_retry_at === PARK_UNTIL,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Failure fields left behind on a session that has nothing left to send. */
|
|
176
|
+
function hasStaleFailure(state) {
|
|
177
|
+
if (!state || isParked(state)) return false;
|
|
178
|
+
if (!["complete", "idle"].includes(state.delivery)) return false;
|
|
179
|
+
if ((Number(state.queued_exchanges) || 0) > 0) return false;
|
|
180
|
+
return Boolean(
|
|
181
|
+
state.failure_class || state.next_retry_at || state.last_error,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function transcriptInfo(state) {
|
|
186
|
+
const p = state && state.transcript_path;
|
|
187
|
+
if (!p) return { exists: false, bytes: null, mtime_ms: null };
|
|
188
|
+
try {
|
|
189
|
+
const st = fs.statSync(p);
|
|
190
|
+
return { exists: true, bytes: st.size, mtime_ms: st.mtimeMs };
|
|
191
|
+
} catch {
|
|
192
|
+
return { exists: false, bytes: null, mtime_ms: null };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function timerAlive(sessionId) {
|
|
197
|
+
try {
|
|
198
|
+
const pid = Number.parseInt(
|
|
199
|
+
fs.readFileSync(cs.timerPidPath(sessionId), "utf8"),
|
|
200
|
+
10,
|
|
201
|
+
);
|
|
202
|
+
return isPidAlive(pid);
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The transcript changed within the recovery idle window. */
|
|
209
|
+
function recentlyWritten(info, nowMs) {
|
|
210
|
+
if (!info.exists || info.mtime_ms == null) return false;
|
|
211
|
+
return nowMs - info.mtime_ms < cs.recoveryMinIdle() * 1000;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* An active session whose client may still be writing turns. The timer only
|
|
216
|
+
* treats an active session as abandoned when it is idle AND no checkpoint
|
|
217
|
+
* timer is alive (buildEligibility in capture-state.js); a session counts as
|
|
218
|
+
* live here if either says so.
|
|
219
|
+
*/
|
|
220
|
+
function looksLive(state, info, nowMs) {
|
|
221
|
+
if (!state || state.lifecycle !== "active") return false;
|
|
222
|
+
return recentlyWritten(info, nowMs) || timerAlive(state.session_id);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function countLines(filePath) {
|
|
226
|
+
const buf = fs.readFileSync(filePath);
|
|
227
|
+
if (buf.length === 0) return 0;
|
|
228
|
+
let n = 0;
|
|
229
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n++;
|
|
230
|
+
if (buf[buf.length - 1] !== 10) n++;
|
|
231
|
+
return n;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function summarize(entry, nowMs) {
|
|
235
|
+
if (!entry.ok) {
|
|
236
|
+
return {
|
|
237
|
+
session_id: entry.session_id,
|
|
238
|
+
readable: false,
|
|
239
|
+
health: entry.health,
|
|
240
|
+
error: entry.error || null,
|
|
241
|
+
path: entry.path || null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const s = entry.state;
|
|
245
|
+
const info = transcriptInfo(s);
|
|
246
|
+
return {
|
|
247
|
+
session_id: s.session_id,
|
|
248
|
+
readable: true,
|
|
249
|
+
health: entry.health,
|
|
250
|
+
client: s.client || null,
|
|
251
|
+
agent_id: s.agent_id || null,
|
|
252
|
+
conversation_id: s.conversation_id || null,
|
|
253
|
+
lifecycle: s.lifecycle,
|
|
254
|
+
delivery: s.delivery,
|
|
255
|
+
queued_exchanges: Number(s.queued_exchanges) || 0,
|
|
256
|
+
attempt_count: Number(s.attempt_count) || 0,
|
|
257
|
+
failure_class: s.failure_class || null,
|
|
258
|
+
next_retry_at: s.next_retry_at || null,
|
|
259
|
+
last_error: s.last_error || null,
|
|
260
|
+
last_success_at: s.last_success_at || null,
|
|
261
|
+
parked: isParked(s) ? s.parked : null,
|
|
262
|
+
pending_work: hasPendingWork(s),
|
|
263
|
+
stale_failure: hasStaleFailure(s),
|
|
264
|
+
transcript_path: s.transcript_path || null,
|
|
265
|
+
transcript_exists: info.exists,
|
|
266
|
+
transcript_bytes: info.bytes,
|
|
267
|
+
live: looksLive(s, info, nowMs),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function needsAttention(summary) {
|
|
272
|
+
if (!summary.readable) return true;
|
|
273
|
+
return (
|
|
274
|
+
summary.pending_work ||
|
|
275
|
+
Boolean(summary.parked) ||
|
|
276
|
+
summary.stale_failure ||
|
|
277
|
+
summary.health !== "healthy"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Resolve --session values (full IDs or unique prefixes) against the scan.
|
|
283
|
+
* Returns { entries, errors }.
|
|
284
|
+
*/
|
|
285
|
+
function selectEntries(scanEntries, opts, defaultFilter) {
|
|
286
|
+
if (opts.sessions.length === 0) {
|
|
287
|
+
const filtered = opts.all || !defaultFilter
|
|
288
|
+
? scanEntries
|
|
289
|
+
: scanEntries.filter(defaultFilter);
|
|
290
|
+
return { entries: filtered, errors: [] };
|
|
291
|
+
}
|
|
292
|
+
const entries = [];
|
|
293
|
+
const errors = [];
|
|
294
|
+
for (const wanted of opts.sessions) {
|
|
295
|
+
const exact = scanEntries.find((e) => e.session_id === wanted);
|
|
296
|
+
const matches = exact
|
|
297
|
+
? [exact]
|
|
298
|
+
: scanEntries.filter((e) => String(e.session_id).startsWith(wanted));
|
|
299
|
+
if (matches.length === 1) {
|
|
300
|
+
if (!entries.includes(matches[0])) entries.push(matches[0]);
|
|
301
|
+
} else if (matches.length === 0) {
|
|
302
|
+
errors.push(`no watermark matches session "${wanted}"`);
|
|
303
|
+
} else {
|
|
304
|
+
errors.push(
|
|
305
|
+
`session "${wanted}" is ambiguous (${matches.length} matches); use more characters`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { entries, errors };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* mutate(), retried while a hook drain holds the lease. Used to park a
|
|
314
|
+
* session again after a failed drain, when a hook may have picked it up in
|
|
315
|
+
* the moment it was unparked.
|
|
316
|
+
*/
|
|
317
|
+
function mutateWithRetry(sessionId, mutator) {
|
|
318
|
+
let res;
|
|
319
|
+
for (let i = 0; i < REPARK_ATTEMPTS; i++) {
|
|
320
|
+
res = mutate(sessionId, mutator);
|
|
321
|
+
if (res.ok || !res.busy) return res;
|
|
322
|
+
sleepMs(REPARK_RETRY_MS);
|
|
323
|
+
}
|
|
324
|
+
return res;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Run a state mutation while holding the session's drain lease, so no hook
|
|
329
|
+
* drain can start or commit in between. Returns { ok, busy?, error?, state? }.
|
|
330
|
+
*/
|
|
331
|
+
function withLease(sessionId, fn) {
|
|
332
|
+
const lease = cs.acquireLease(sessionId);
|
|
333
|
+
if (!lease.ok) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
busy: lease.failure_class === "ownership",
|
|
337
|
+
error: lease.error,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
return fn();
|
|
342
|
+
} finally {
|
|
343
|
+
cs.releaseLease(sessionId, lease.lease.token);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function mutate(sessionId, mutator) {
|
|
348
|
+
return withLease(sessionId, () => {
|
|
349
|
+
const res = cs.mutateState(sessionId, (current) => {
|
|
350
|
+
if (!current.ok) {
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
failure_class: current.failure_class || "state_corrupt",
|
|
354
|
+
error: current.error || "watermark unreadable",
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
return mutator({ ...current.state });
|
|
358
|
+
});
|
|
359
|
+
return res.ok
|
|
360
|
+
? { ok: true, state: res.state }
|
|
361
|
+
: { ok: false, error: res.error };
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// State transitions
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
function parkedState(state, reason, nowIso) {
|
|
370
|
+
return {
|
|
371
|
+
...state,
|
|
372
|
+
parked: {
|
|
373
|
+
at: nowIso,
|
|
374
|
+
by: PARKED_BY,
|
|
375
|
+
reason,
|
|
376
|
+
previous: {
|
|
377
|
+
delivery: state.delivery,
|
|
378
|
+
failure_class: state.failure_class || null,
|
|
379
|
+
next_retry_at: state.next_retry_at || null,
|
|
380
|
+
attempt_count: Number(state.attempt_count) || 0,
|
|
381
|
+
last_error: state.last_error || null,
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
// Not `persistent`: the timer force-retries persistent failures once at
|
|
385
|
+
// startup regardless of next_retry_at.
|
|
386
|
+
delivery: "blocked",
|
|
387
|
+
failure_class: "transient",
|
|
388
|
+
next_retry_at: PARK_UNTIL,
|
|
389
|
+
blocked_reason: RESUME_HINT,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Retry fields for a session handed back to capture.js. A failure that was
|
|
395
|
+
* `persistent` with scope `exchange` is what lets capture.js skip one
|
|
396
|
+
* exchange the edge keeps rejecting (HTML 403), once its backoff is due.
|
|
397
|
+
* Clearing it would resend that exchange and hold the queue again, so keep
|
|
398
|
+
* the class and make the retry due now.
|
|
399
|
+
*/
|
|
400
|
+
function handBackRetryFields(previousFailureClass, state) {
|
|
401
|
+
if (previousFailureClass === "persistent" && state.last_failure_scope === "exchange") {
|
|
402
|
+
return {
|
|
403
|
+
failure_class: "persistent",
|
|
404
|
+
next_retry_at: new Date().toISOString(),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return { failure_class: null, next_retry_at: null };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function unparkedState(state) {
|
|
411
|
+
const queued = Number(state.queued_exchanges) || 0;
|
|
412
|
+
const previous = (state.parked && state.parked.previous) || {};
|
|
413
|
+
let delivery;
|
|
414
|
+
if (!state.conversation_id) delivery = "needs_conversation";
|
|
415
|
+
else if (state.lifecycle === "closed" && queued === 0) delivery = "complete";
|
|
416
|
+
else delivery = "pending";
|
|
417
|
+
return {
|
|
418
|
+
...state,
|
|
419
|
+
// writeState merges the file on disk under the candidate, so a removed
|
|
420
|
+
// key would survive; null clears it.
|
|
421
|
+
parked: null,
|
|
422
|
+
delivery,
|
|
423
|
+
...handBackRetryFields(previous.failure_class, state),
|
|
424
|
+
attempt_count: 0,
|
|
425
|
+
last_error: null,
|
|
426
|
+
blocked_reason: null,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function discardedState(state, lineCount) {
|
|
431
|
+
const queued = Number(state.queued_exchanges) || 0;
|
|
432
|
+
return {
|
|
433
|
+
...state,
|
|
434
|
+
parked: null,
|
|
435
|
+
last_line_number: Math.max(Number(state.last_line_number) || 0, lineCount),
|
|
436
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
437
|
+
lifecycle: "closed",
|
|
438
|
+
delivery: "complete",
|
|
439
|
+
queued_exchanges: 0,
|
|
440
|
+
failure_class: null,
|
|
441
|
+
next_retry_at: null,
|
|
442
|
+
attempt_count: 0,
|
|
443
|
+
last_error: null,
|
|
444
|
+
blocked_reason: null,
|
|
445
|
+
dropped_exchanges: (Number(state.dropped_exchanges) || 0) + queued,
|
|
446
|
+
last_drop_reason: `discarded by ${PARKED_BY}`,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function clearedFailureState(state) {
|
|
451
|
+
return {
|
|
452
|
+
...state,
|
|
453
|
+
failure_class: null,
|
|
454
|
+
next_retry_at: null,
|
|
455
|
+
attempt_count: 0,
|
|
456
|
+
last_error: null,
|
|
457
|
+
blocked_reason: null,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** clearedFailureState, keeping capture.js's one-exchange escape. */
|
|
462
|
+
function handBackState(state) {
|
|
463
|
+
return {
|
|
464
|
+
...clearedFailureState(state),
|
|
465
|
+
...handBackRetryFields(state.failure_class, state),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Sleep while holding the session's drain lease, so a hook timer's recovery
|
|
471
|
+
* cycle can't start a drain of it between two of our batches.
|
|
472
|
+
*/
|
|
473
|
+
function sleepHoldingLease(sessionId, ms) {
|
|
474
|
+
if (ms <= 0) return;
|
|
475
|
+
const lease = cs.acquireLease(sessionId);
|
|
476
|
+
try {
|
|
477
|
+
sleepMs(ms);
|
|
478
|
+
} finally {
|
|
479
|
+
if (lease.ok) cs.releaseLease(sessionId, lease.lease.token);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Wait until no live worker holds the session's drain lease. Returns false
|
|
485
|
+
* when it's still held after the wait.
|
|
486
|
+
*/
|
|
487
|
+
function waitForLease(sessionId, maxMs) {
|
|
488
|
+
const deadline = Date.now() + maxMs;
|
|
489
|
+
while (Date.now() < deadline) {
|
|
490
|
+
const lease = cs.acquireLease(sessionId);
|
|
491
|
+
if (lease.ok) {
|
|
492
|
+
cs.releaseLease(sessionId, lease.lease.token);
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
sleepMs(LEASE_POLL_MS);
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function heldByAnotherWorker(result) {
|
|
501
|
+
return (
|
|
502
|
+
result &&
|
|
503
|
+
result.status === "retry_wait" &&
|
|
504
|
+
result.failure_class === "ownership" &&
|
|
505
|
+
/lease held/.test(String(result.error || ""))
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Processes
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
const HOOK_SCRIPT_RE =
|
|
514
|
+
/hooks-handlers[\\/]lib[\\/](checkpoint-timer|terminal-handoff|capture)\.js(?:\s+(\S+))?(?:\s+(\S+))?/;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* List background capture processes from `ps`. Returns { ok, processes,
|
|
518
|
+
* error }. Unsupported platforms (no `ps`) return ok:false.
|
|
519
|
+
*/
|
|
520
|
+
function listCaptureProcesses() {
|
|
521
|
+
const res = spawnSync("ps", ["-axo", "pid=,ppid=,command="], {
|
|
522
|
+
encoding: "utf8",
|
|
523
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
524
|
+
});
|
|
525
|
+
if (res.error || res.status !== 0) {
|
|
526
|
+
return {
|
|
527
|
+
ok: false,
|
|
528
|
+
processes: [],
|
|
529
|
+
error: res.error ? res.error.message : `ps exited ${res.status}`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const processes = [];
|
|
533
|
+
const commands = new Map();
|
|
534
|
+
const rows = [];
|
|
535
|
+
for (const line of res.stdout.split("\n")) {
|
|
536
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
537
|
+
if (m) rows.push(m);
|
|
538
|
+
if (m) commands.set(Number(m[1]), m[3]);
|
|
539
|
+
}
|
|
540
|
+
for (const m of rows) {
|
|
541
|
+
const pid = Number(m[1]);
|
|
542
|
+
const ppid = Number(m[2]);
|
|
543
|
+
const command = m[3];
|
|
544
|
+
if (pid === process.pid) continue;
|
|
545
|
+
const hit = HOOK_SCRIPT_RE.exec(command);
|
|
546
|
+
if (!hit) continue;
|
|
547
|
+
const script = hit[1];
|
|
548
|
+
let kind = null;
|
|
549
|
+
let sessionId = null;
|
|
550
|
+
let jobFile = null;
|
|
551
|
+
if (script === "checkpoint-timer") {
|
|
552
|
+
kind = "timer";
|
|
553
|
+
if (hit[2]) sessionId = path.basename(hit[2], ".jsonl");
|
|
554
|
+
} else if (script === "terminal-handoff" && hit[2] === "worker") {
|
|
555
|
+
kind = "session_end_worker";
|
|
556
|
+
jobFile = hit[3] || null;
|
|
557
|
+
sessionId = sessionFromJobFile(jobFile);
|
|
558
|
+
} else if (script === "capture" && BACKGROUND_CAPTURE_MODES.includes(hit[2])) {
|
|
559
|
+
kind = `capture_${hit[2].replace("-", "_")}`;
|
|
560
|
+
}
|
|
561
|
+
if (!kind) continue;
|
|
562
|
+
processes.push({
|
|
563
|
+
pid,
|
|
564
|
+
ppid,
|
|
565
|
+
kind,
|
|
566
|
+
session_id: sessionId,
|
|
567
|
+
job_file: jobFile,
|
|
568
|
+
command,
|
|
569
|
+
orphan: isInitLike(ppid, commands.get(ppid)),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
return { ok: true, processes };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The parent a detached child is reparented to: PID 1, or a subreaper such
|
|
577
|
+
* as `systemd --user` or launchd.
|
|
578
|
+
*/
|
|
579
|
+
function isInitLike(ppid, parentCommand) {
|
|
580
|
+
if (ppid === 1) return true;
|
|
581
|
+
return /(^|\/)(systemd|launchd|init)(\s|$)/.test(String(parentCommand || ""));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The processes `stop` may signal: SessionEnd workers whose job file lives
|
|
586
|
+
* under this watermark directory, timers of in-scope sessions that are no
|
|
587
|
+
* longer active, and the capture.js children of any in-scope timer or
|
|
588
|
+
* worker. Timers of active sessions (`keepTimerSessionIds`) keep running;
|
|
589
|
+
* only their in-flight children are stopped. Detached capture.js runs with
|
|
590
|
+
* no parent (Kiro's Stop-time drain) can't be tied to a directory, so they
|
|
591
|
+
* are included only for an unfiltered stop of the default directory.
|
|
592
|
+
*/
|
|
593
|
+
function processesInScope(processes, sessionIds, opts, keepTimerSessionIds) {
|
|
594
|
+
const inScope = new Set(sessionIds);
|
|
595
|
+
const keep = new Set(keepTimerSessionIds || []);
|
|
596
|
+
const jobsDir = path.join(cs.watermarkDir(), "session-end-jobs") + path.sep;
|
|
597
|
+
const owners = processes.filter(
|
|
598
|
+
(p) =>
|
|
599
|
+
(p.kind === "timer" && inScope.has(p.session_id)) ||
|
|
600
|
+
(p.kind === "session_end_worker" &&
|
|
601
|
+
typeof p.job_file === "string" &&
|
|
602
|
+
p.job_file.startsWith(jobsDir) &&
|
|
603
|
+
(!p.session_id || inScope.has(p.session_id))),
|
|
604
|
+
);
|
|
605
|
+
const parents = new Set(owners.map((p) => p.pid));
|
|
606
|
+
const picked = owners.filter(
|
|
607
|
+
(p) => !(p.kind === "timer" && keep.has(p.session_id)),
|
|
608
|
+
);
|
|
609
|
+
const defaultDir =
|
|
610
|
+
path.resolve(cs.watermarkDir()) ===
|
|
611
|
+
path.resolve(os.homedir(), ".claude", "meko-capture");
|
|
612
|
+
const includeOrphans = defaultDir && opts.sessions.length === 0;
|
|
613
|
+
for (const p of processes) {
|
|
614
|
+
if (!p.kind.startsWith("capture_")) continue;
|
|
615
|
+
if (parents.has(p.ppid) || (includeOrphans && p.orphan)) picked.push(p);
|
|
616
|
+
}
|
|
617
|
+
return picked;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function sessionFromJobFile(jobFile) {
|
|
621
|
+
if (!jobFile) return null;
|
|
622
|
+
try {
|
|
623
|
+
const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
624
|
+
if (job.session_id) return String(job.session_id);
|
|
625
|
+
const t = job.transcript_path || (job.hookSpecificInput || {}).transcript_path;
|
|
626
|
+
return t ? path.basename(String(t), ".jsonl") : null;
|
|
627
|
+
} catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function sleepMs(ms) {
|
|
633
|
+
if (ms <= 0) return;
|
|
634
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
635
|
+
Atomics.wait(cell, 0, 0, ms);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* SIGTERM each process (timers and workers clean up their own PID and job
|
|
640
|
+
* files on SIGTERM), wait, then SIGKILL survivors.
|
|
641
|
+
*/
|
|
642
|
+
function killProcesses(processes) {
|
|
643
|
+
const results = [];
|
|
644
|
+
for (const p of processes) {
|
|
645
|
+
try {
|
|
646
|
+
process.kill(p.pid, "SIGTERM");
|
|
647
|
+
results.push({ ...p, signal: "SIGTERM", ok: true });
|
|
648
|
+
} catch (err) {
|
|
649
|
+
results.push({
|
|
650
|
+
...p,
|
|
651
|
+
signal: "SIGTERM",
|
|
652
|
+
ok: Boolean(err && err.code === "ESRCH"),
|
|
653
|
+
error: err && err.code !== "ESRCH" ? err.message : null,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const deadline = Date.now() + KILL_WAIT_MS;
|
|
658
|
+
while (Date.now() < deadline && results.some((r) => isPidAlive(r.pid))) {
|
|
659
|
+
sleepMs(100);
|
|
660
|
+
}
|
|
661
|
+
for (const r of results) {
|
|
662
|
+
if (!isPidAlive(r.pid)) {
|
|
663
|
+
r.exited = true;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
process.kill(r.pid, "SIGKILL");
|
|
668
|
+
r.signal = "SIGKILL";
|
|
669
|
+
} catch (err) {
|
|
670
|
+
r.error = err.message;
|
|
671
|
+
}
|
|
672
|
+
sleepMs(100);
|
|
673
|
+
r.exited = !isPidAlive(r.pid);
|
|
674
|
+
}
|
|
675
|
+
return results;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// Drain credentials
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* MEKO_MCP_URL / MEKO_API_KEY from the environment, else from the `env`
|
|
684
|
+
* block the installer writes into ~/.claude/settings.json for Claude Code.
|
|
685
|
+
*/
|
|
686
|
+
function resolveCredentials() {
|
|
687
|
+
const env = {
|
|
688
|
+
MEKO_MCP_URL: process.env.MEKO_MCP_URL || "",
|
|
689
|
+
MEKO_API_KEY: process.env.MEKO_API_KEY || "",
|
|
690
|
+
};
|
|
691
|
+
let source = env.MEKO_MCP_URL ? "environment" : null;
|
|
692
|
+
if (!env.MEKO_MCP_URL) {
|
|
693
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
694
|
+
try {
|
|
695
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
696
|
+
const block = (settings && settings.env) || {};
|
|
697
|
+
if (block.MEKO_MCP_URL) {
|
|
698
|
+
env.MEKO_MCP_URL = String(block.MEKO_MCP_URL);
|
|
699
|
+
if (!env.MEKO_API_KEY && block.MEKO_API_KEY) {
|
|
700
|
+
env.MEKO_API_KEY = String(block.MEKO_API_KEY);
|
|
701
|
+
}
|
|
702
|
+
source = settingsPath;
|
|
703
|
+
}
|
|
704
|
+
} catch {
|
|
705
|
+
/* no Claude Code settings; the caller reports the missing URL */
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return { env, source };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* A drain result that made progress or had nothing to do. `partial` without
|
|
713
|
+
* a failure class means the batch cap was reached with work still queued.
|
|
714
|
+
*/
|
|
715
|
+
function drainSucceeded(result) {
|
|
716
|
+
if (!result) return false;
|
|
717
|
+
if (result.status === "ok" || result.status === "noop") return true;
|
|
718
|
+
return result.status === "partial" && !result.failure_class && !result.error;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function runCaptureChild(mode, input, env) {
|
|
722
|
+
const captureJs = path.join(__dirname, "capture.js");
|
|
723
|
+
const res = spawnSync(process.execPath, [captureJs, mode], {
|
|
724
|
+
input: JSON.stringify(input),
|
|
725
|
+
encoding: "utf8",
|
|
726
|
+
env: { ...process.env, ...env },
|
|
727
|
+
timeout: cs.checkpointTimeout() * 1000,
|
|
728
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
729
|
+
});
|
|
730
|
+
const lines = String(res.stdout || "").trim().split("\n").filter(Boolean);
|
|
731
|
+
let result = null;
|
|
732
|
+
try {
|
|
733
|
+
result = JSON.parse(lines[lines.length - 1]);
|
|
734
|
+
} catch {
|
|
735
|
+
/* handled below */
|
|
736
|
+
}
|
|
737
|
+
if (!result) {
|
|
738
|
+
const why = res.error
|
|
739
|
+
? res.error.message
|
|
740
|
+
: `capture.js ${mode} exited ${res.status} without a result`;
|
|
741
|
+
result = { status: "error", error: why, captured: 0, queued_remaining: null };
|
|
742
|
+
}
|
|
743
|
+
const stderrTail = String(res.stderr || "")
|
|
744
|
+
.trim()
|
|
745
|
+
.split("\n")
|
|
746
|
+
.slice(-5)
|
|
747
|
+
.join("\n");
|
|
748
|
+
return { result, stderr: stderrTail };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
// Actions
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
function scan() {
|
|
756
|
+
const res = cs.scanStates({ includeBlocked: true });
|
|
757
|
+
if (!res.ok) throw new Error(res.error || "watermark scan failed");
|
|
758
|
+
return res.entries || [];
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function refreshHealthCache() {
|
|
762
|
+
const res = cs.scanStates({ includeBlocked: true });
|
|
763
|
+
const health = cs.computeAggregateHealth(res.entries || []);
|
|
764
|
+
const existing = cs.readHealthCache();
|
|
765
|
+
if (existing && existing.last_notified_status !== undefined) {
|
|
766
|
+
health.last_notified_status = existing.last_notified_status;
|
|
767
|
+
}
|
|
768
|
+
cs.writeHealthCache(health, { clampDropped: cs.scanCompleteForDropClamp(res) });
|
|
769
|
+
return health;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function actionStatus(opts) {
|
|
773
|
+
const nowMs = Date.now();
|
|
774
|
+
const entries = scan();
|
|
775
|
+
const summaries = entries.map((e) => summarize(e, nowMs));
|
|
776
|
+
const { entries: selected, errors } = selectEntries(
|
|
777
|
+
summaries,
|
|
778
|
+
opts,
|
|
779
|
+
needsAttention,
|
|
780
|
+
);
|
|
781
|
+
const procs = listCaptureProcesses();
|
|
782
|
+
const parkedIds = new Set(
|
|
783
|
+
summaries.filter((s) => s.parked).map((s) => s.session_id),
|
|
784
|
+
);
|
|
785
|
+
for (const p of procs.processes) {
|
|
786
|
+
// A SessionEnd worker for a parked session makes no requests; it sleeps
|
|
787
|
+
// until its age ceiling (older hooks) or exits (newer ones). A timer is
|
|
788
|
+
// never labelled: it still runs recovery for other sessions.
|
|
789
|
+
if (p.kind === "session_end_worker" && p.session_id && parkedIds.has(p.session_id)) {
|
|
790
|
+
p.idle_parked = true;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
action: "status",
|
|
795
|
+
watermark_dir: cs.watermarkDir(),
|
|
796
|
+
total_sessions: summaries.length,
|
|
797
|
+
sessions: selected,
|
|
798
|
+
processes: procs.processes,
|
|
799
|
+
process_scan_error: procs.ok ? null : procs.error,
|
|
800
|
+
errors,
|
|
801
|
+
exit: errors.length ? 1 : 0,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function parkEntries(entries, reason, dryRun) {
|
|
806
|
+
const nowIso = new Date().toISOString();
|
|
807
|
+
const results = [];
|
|
808
|
+
for (const e of entries) {
|
|
809
|
+
const sid = e.session_id;
|
|
810
|
+
if (!e.ok) {
|
|
811
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "watermark unreadable" });
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (isParked(e.state)) {
|
|
815
|
+
results.push({ session_id: sid, outcome: "already_parked" });
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (!hasPendingWork(e.state)) {
|
|
819
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "nothing to send" });
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (dryRun) {
|
|
823
|
+
results.push({ session_id: sid, outcome: "would_park", queued_exchanges: Number(e.state.queued_exchanges) || 0 });
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
// A hook may be draining this session right now (a kept timer restarts
|
|
827
|
+
// recovery when its child is stopped); wait for it rather than skip.
|
|
828
|
+
const res = mutateWithRetry(sid, (state) =>
|
|
829
|
+
isParked(state) ? state : parkedState(state, reason, nowIso),
|
|
830
|
+
);
|
|
831
|
+
results.push(
|
|
832
|
+
res.ok
|
|
833
|
+
? { session_id: sid, outcome: "parked", queued_exchanges: Number(res.state.queued_exchanges) || 0 }
|
|
834
|
+
: { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error },
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
return results;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function actionStop(opts) {
|
|
841
|
+
const entries = scan();
|
|
842
|
+
const { entries: selected, errors } = selectEntries(entries, opts, null);
|
|
843
|
+
const procs = listCaptureProcesses();
|
|
844
|
+
const activeIds = selected
|
|
845
|
+
.filter((e) => e.ok && e.state.lifecycle === "active")
|
|
846
|
+
.map((e) => e.session_id);
|
|
847
|
+
const targets = procs.ok
|
|
848
|
+
? processesInScope(
|
|
849
|
+
procs.processes,
|
|
850
|
+
selected.map((e) => e.session_id),
|
|
851
|
+
opts,
|
|
852
|
+
activeIds,
|
|
853
|
+
)
|
|
854
|
+
: [];
|
|
855
|
+
// Stop processes before parking: a drain that is mid-flight would
|
|
856
|
+
// otherwise commit its own state over the park.
|
|
857
|
+
let killed = [];
|
|
858
|
+
if (!opts.dryRun) {
|
|
859
|
+
killed = killProcesses(targets);
|
|
860
|
+
// A kept timer whose child was just killed starts a recovery cycle at
|
|
861
|
+
// once. Stop any capture.js child that appeared in the meantime.
|
|
862
|
+
const again = listCaptureProcesses();
|
|
863
|
+
if (again.ok) {
|
|
864
|
+
const seen = new Set(killed.map((k) => k.pid));
|
|
865
|
+
const late = processesInScope(
|
|
866
|
+
again.processes,
|
|
867
|
+
selected.map((e) => e.session_id),
|
|
868
|
+
opts,
|
|
869
|
+
activeIds,
|
|
870
|
+
).filter((p) => p.kind.startsWith("capture_") && !seen.has(p.pid));
|
|
871
|
+
killed = killed.concat(killProcesses(late));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const parked = parkEntries(selected, "stop", opts.dryRun);
|
|
875
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
876
|
+
const partial = parked.some((r) => r.outcome === "busy" || r.outcome === "failed") ||
|
|
877
|
+
killed.some((k) => !k.exited);
|
|
878
|
+
return {
|
|
879
|
+
action: "stop",
|
|
880
|
+
dry_run: opts.dryRun,
|
|
881
|
+
processes: opts.dryRun ? targets : killed,
|
|
882
|
+
process_scan_error: procs.ok ? null : procs.error,
|
|
883
|
+
sessions: parked,
|
|
884
|
+
errors,
|
|
885
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function actionResume(opts) {
|
|
890
|
+
const entries = scan();
|
|
891
|
+
const { entries: selected, errors } = selectEntries(
|
|
892
|
+
entries,
|
|
893
|
+
opts,
|
|
894
|
+
(e) => e.ok && isParked(e.state),
|
|
895
|
+
);
|
|
896
|
+
const results = [];
|
|
897
|
+
for (const e of selected) {
|
|
898
|
+
const sid = e.session_id;
|
|
899
|
+
if (!e.ok || !isParked(e.state)) {
|
|
900
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "not parked" });
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (opts.dryRun) {
|
|
904
|
+
results.push({ session_id: sid, outcome: "would_resume" });
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
const res = mutate(sid, (state) => (isParked(state) ? unparkedState(state) : state));
|
|
908
|
+
results.push(
|
|
909
|
+
res.ok
|
|
910
|
+
? { session_id: sid, outcome: "resumed", delivery: res.state.delivery }
|
|
911
|
+
: { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error },
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
915
|
+
const partial = results.some((r) => r.outcome === "busy" || r.outcome === "failed");
|
|
916
|
+
return {
|
|
917
|
+
action: "resume",
|
|
918
|
+
dry_run: opts.dryRun,
|
|
919
|
+
sessions: results,
|
|
920
|
+
errors,
|
|
921
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function actionDrain(opts, log) {
|
|
926
|
+
if (opts.sessions.length === 0 && !opts.all) {
|
|
927
|
+
return { action: "drain", errors: ["drain needs --session <id> or --all"], exit: 1 };
|
|
928
|
+
}
|
|
929
|
+
const { env, source } = resolveCredentials();
|
|
930
|
+
if (!env.MEKO_MCP_URL) {
|
|
931
|
+
return {
|
|
932
|
+
action: "drain",
|
|
933
|
+
errors: [
|
|
934
|
+
"MEKO_MCP_URL is not set and ~/.claude/settings.json has no env.MEKO_MCP_URL; " +
|
|
935
|
+
"export MEKO_MCP_URL and MEKO_API_KEY, then retry",
|
|
936
|
+
],
|
|
937
|
+
exit: 1,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const nowMs = Date.now();
|
|
941
|
+
const entries = scan();
|
|
942
|
+
const { entries: selected, errors } = selectEntries(entries, opts, null);
|
|
943
|
+
const targets = selected.filter((e) => e.ok && hasPendingWork(e.state));
|
|
944
|
+
const results = [];
|
|
945
|
+
// Say why a session named on the command line isn't drained, instead of
|
|
946
|
+
// "nothing to do".
|
|
947
|
+
if (opts.sessions.length > 0) {
|
|
948
|
+
for (const e of selected) {
|
|
949
|
+
if (targets.includes(e)) continue;
|
|
950
|
+
let reason = "nothing to send";
|
|
951
|
+
if (!e.ok) reason = "watermark unreadable";
|
|
952
|
+
else if (hasStaleFailure(e.state)) {
|
|
953
|
+
reason =
|
|
954
|
+
"nothing to send; only leftover failure fields remain. Run `--capture-cleanup repair` to clear them";
|
|
955
|
+
}
|
|
956
|
+
results.push({ session_id: e.session_id, outcome: "skipped", reason });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
let budget = opts.maxExchanges;
|
|
960
|
+
let stopped = null;
|
|
961
|
+
|
|
962
|
+
for (const e of targets) {
|
|
963
|
+
const sid = e.session_id;
|
|
964
|
+
if (stopped) {
|
|
965
|
+
results.push({ session_id: sid, outcome: "not_attempted" });
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
if (budget <= 0) {
|
|
969
|
+
results.push({ session_id: sid, outcome: "not_attempted", reason: "--max-exchanges reached" });
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
if (opts.dryRun) {
|
|
973
|
+
results.push({ session_id: sid, outcome: "would_drain", queued_exchanges: Number(e.state.queued_exchanges) || 0 });
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const wasParked = isParked(e.state);
|
|
977
|
+
let state = e.state;
|
|
978
|
+
if (wasParked || state.failure_class) {
|
|
979
|
+
const res = mutate(sid, (current) =>
|
|
980
|
+
isParked(current) ? unparkedState(current) : handBackState(current),
|
|
981
|
+
);
|
|
982
|
+
if (!res.ok) {
|
|
983
|
+
results.push({ session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error });
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
state = res.state;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const live = looksLive(state, transcriptInfo(state), nowMs);
|
|
990
|
+
// Never rebucket from here: this process's cwd says nothing about the
|
|
991
|
+
// session's owner. A session whose client is gone is finalized the way
|
|
992
|
+
// timer recovery does it.
|
|
993
|
+
const mode = live ? "drain" : "recover";
|
|
994
|
+
let captured = 0;
|
|
995
|
+
let last = null;
|
|
996
|
+
let stderr = "";
|
|
997
|
+
let leaseWaits = 0;
|
|
998
|
+
for (;;) {
|
|
999
|
+
const run = runCaptureChild(
|
|
1000
|
+
mode,
|
|
1001
|
+
{ session_id: sid, recorded_owner_only: true, max_exchanges: Math.min(budget, cs.maxCaptureBatchSize()) },
|
|
1002
|
+
env,
|
|
1003
|
+
);
|
|
1004
|
+
last = run.result;
|
|
1005
|
+
stderr = run.stderr;
|
|
1006
|
+
if (heldByAnotherWorker(last) && leaseWaits < MAX_LEASE_WAITS) {
|
|
1007
|
+
leaseWaits++;
|
|
1008
|
+
// A hook (usually a kept timer's recovery cycle) is draining this
|
|
1009
|
+
// session. That isn't a failure: wait for it, then continue.
|
|
1010
|
+
log(` ${sid.slice(0, 8)}: another worker holds the lease; waiting`);
|
|
1011
|
+
const waitMs = (cs.leaseTtlSeconds() + cs.checkpointTimeout()) * 1000;
|
|
1012
|
+
if (waitForLease(sid, waitMs)) continue;
|
|
1013
|
+
}
|
|
1014
|
+
captured += Number(last.captured) || 0;
|
|
1015
|
+
budget -= Number(last.captured) || 0;
|
|
1016
|
+
const remaining = Number(last.queued_remaining) || 0;
|
|
1017
|
+
if (!drainSucceeded(last)) break;
|
|
1018
|
+
if (remaining === 0 || !last.captured || budget <= 0) break;
|
|
1019
|
+
log(` ${sid.slice(0, 8)}: sent ${captured}, ${remaining} left`);
|
|
1020
|
+
sleepHoldingLease(sid, opts.paceMs);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const ok = drainSucceeded(last);
|
|
1024
|
+
const remaining = Number(last.queued_remaining) || 0;
|
|
1025
|
+
const repark = (reason) =>
|
|
1026
|
+
mutateWithRetry(sid, (current) =>
|
|
1027
|
+
isParked(current) ? current : parkedState(current, reason, new Date().toISOString()),
|
|
1028
|
+
);
|
|
1029
|
+
let outcome;
|
|
1030
|
+
let reparkError = null;
|
|
1031
|
+
if (!ok) {
|
|
1032
|
+
// Park it again so the hooks don't retry what just failed. A hook may
|
|
1033
|
+
// hold the lease right now (that is often why the drain failed), so
|
|
1034
|
+
// retry, and say so when the park doesn't land.
|
|
1035
|
+
const res = repark(`drain failed: ${last.error || last.status}`);
|
|
1036
|
+
stopped = sid;
|
|
1037
|
+
outcome = res.ok ? "failed_and_parked" : "failed_not_parked";
|
|
1038
|
+
if (!res.ok) reparkError = res.error;
|
|
1039
|
+
} else if (last.status === "noop" && last.error && captured === 0) {
|
|
1040
|
+
// For example "recorded owner missing": nothing was sent. A session
|
|
1041
|
+
// that was parked stays parked.
|
|
1042
|
+
if (wasParked) {
|
|
1043
|
+
const res = repark(`drain skipped: ${last.error}`);
|
|
1044
|
+
outcome = res.ok ? "skipped_and_parked" : "skipped_not_parked";
|
|
1045
|
+
if (!res.ok) reparkError = res.error;
|
|
1046
|
+
} else {
|
|
1047
|
+
outcome = "skipped";
|
|
1048
|
+
}
|
|
1049
|
+
} else if (remaining > 0) {
|
|
1050
|
+
// Out of --max-exchanges. A session that was parked stays parked.
|
|
1051
|
+
if (wasParked) {
|
|
1052
|
+
const res = repark("drain stopped at --max-exchanges");
|
|
1053
|
+
outcome = res.ok ? "partly_drained_and_parked" : "partly_drained_not_parked";
|
|
1054
|
+
if (!res.ok) reparkError = res.error;
|
|
1055
|
+
} else {
|
|
1056
|
+
outcome = "partly_drained";
|
|
1057
|
+
}
|
|
1058
|
+
} else {
|
|
1059
|
+
outcome = "drained";
|
|
1060
|
+
}
|
|
1061
|
+
results.push({
|
|
1062
|
+
session_id: sid,
|
|
1063
|
+
outcome,
|
|
1064
|
+
captured,
|
|
1065
|
+
queued_remaining: last.queued_remaining,
|
|
1066
|
+
status: last.status,
|
|
1067
|
+
error: last.error || null,
|
|
1068
|
+
repark_error: reparkError || undefined,
|
|
1069
|
+
stderr: ok ? undefined : stderr || undefined,
|
|
1070
|
+
});
|
|
1071
|
+
if (!["drained", "skipped", "skipped_and_parked"].includes(outcome)) continue;
|
|
1072
|
+
if (budget > 0) sleepMs(opts.paceMs);
|
|
1073
|
+
}
|
|
1074
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
1075
|
+
const partial =
|
|
1076
|
+
Boolean(stopped) ||
|
|
1077
|
+
results.some((r) =>
|
|
1078
|
+
[
|
|
1079
|
+
"busy",
|
|
1080
|
+
"failed",
|
|
1081
|
+
"not_attempted",
|
|
1082
|
+
"skipped",
|
|
1083
|
+
"partly_drained",
|
|
1084
|
+
"partly_drained_and_parked",
|
|
1085
|
+
"partly_drained_not_parked",
|
|
1086
|
+
"failed_not_parked",
|
|
1087
|
+
"skipped_and_parked",
|
|
1088
|
+
"skipped_not_parked",
|
|
1089
|
+
].includes(r.outcome),
|
|
1090
|
+
);
|
|
1091
|
+
return {
|
|
1092
|
+
action: "drain",
|
|
1093
|
+
dry_run: opts.dryRun,
|
|
1094
|
+
credentials_from: source,
|
|
1095
|
+
mcp_url: env.MEKO_MCP_URL,
|
|
1096
|
+
stopped_at: stopped,
|
|
1097
|
+
sessions: results,
|
|
1098
|
+
errors,
|
|
1099
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function actionDiscard(opts) {
|
|
1104
|
+
if (opts.sessions.length === 0 && !opts.all) {
|
|
1105
|
+
return { action: "discard", errors: ["discard needs --session <id> or --all"], exit: 1 };
|
|
1106
|
+
}
|
|
1107
|
+
if (!opts.yes && !opts.dryRun) {
|
|
1108
|
+
return {
|
|
1109
|
+
action: "discard",
|
|
1110
|
+
errors: ["discard never sends the skipped exchanges; rerun with --yes to confirm, or --dry-run to preview"],
|
|
1111
|
+
exit: 1,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
const nowMs = Date.now();
|
|
1115
|
+
const entries = scan();
|
|
1116
|
+
const { entries: selected, errors } = selectEntries(entries, opts, null);
|
|
1117
|
+
const results = [];
|
|
1118
|
+
for (const e of selected) {
|
|
1119
|
+
const sid = e.session_id;
|
|
1120
|
+
if (!e.ok) {
|
|
1121
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "watermark unreadable" });
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (!hasPendingWork(e.state) && !isParked(e.state)) {
|
|
1125
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "nothing to send" });
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
const info = transcriptInfo(e.state);
|
|
1129
|
+
if (recentlyWritten(info, nowMs) || timerAlive(sid)) {
|
|
1130
|
+
results.push({
|
|
1131
|
+
session_id: sid,
|
|
1132
|
+
outcome: "skipped",
|
|
1133
|
+
reason: "transcript changed recently or its timer is running; the session may still be open",
|
|
1134
|
+
});
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
const queued = Number(e.state.queued_exchanges) || 0;
|
|
1138
|
+
if (opts.dryRun) {
|
|
1139
|
+
results.push({ session_id: sid, outcome: "would_discard", queued_exchanges: queued });
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
let lineCount = 0;
|
|
1143
|
+
if (info.exists) {
|
|
1144
|
+
try {
|
|
1145
|
+
lineCount = countLines(e.state.transcript_path);
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
results.push({ session_id: sid, outcome: "failed", reason: `read transcript: ${err.message}` });
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
const res = mutate(sid, (state) => discardedState(state, lineCount));
|
|
1152
|
+
results.push(
|
|
1153
|
+
res.ok
|
|
1154
|
+
? { session_id: sid, outcome: "discarded", queued_exchanges: queued }
|
|
1155
|
+
: { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error },
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
1159
|
+
const partial = results.some((r) => r.outcome === "busy" || r.outcome === "failed");
|
|
1160
|
+
return {
|
|
1161
|
+
action: "discard",
|
|
1162
|
+
dry_run: opts.dryRun,
|
|
1163
|
+
sessions: results,
|
|
1164
|
+
errors,
|
|
1165
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function readPidFile(filePath) {
|
|
1170
|
+
try {
|
|
1171
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
1172
|
+
if (/^\d+$/.test(raw)) return Number(raw);
|
|
1173
|
+
const obj = JSON.parse(raw);
|
|
1174
|
+
return Number(obj && obj.pid);
|
|
1175
|
+
} catch {
|
|
1176
|
+
return NaN;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function actionRepair(opts) {
|
|
1181
|
+
const dir = cs.watermarkDir();
|
|
1182
|
+
const entries = scan();
|
|
1183
|
+
const { entries: selected, errors } = selectEntries(entries, opts, null);
|
|
1184
|
+
const sessions = [];
|
|
1185
|
+
for (const e of selected) {
|
|
1186
|
+
if (!e.ok || !hasStaleFailure(e.state)) continue;
|
|
1187
|
+
if (opts.dryRun) {
|
|
1188
|
+
sessions.push({ session_id: e.session_id, outcome: "would_clear_failure", failure_class: e.state.failure_class });
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const res = mutate(e.session_id, (state) =>
|
|
1192
|
+
hasStaleFailure(state) ? clearedFailureState(state) : state,
|
|
1193
|
+
);
|
|
1194
|
+
sessions.push(
|
|
1195
|
+
res.ok
|
|
1196
|
+
? { session_id: e.session_id, outcome: "cleared_failure" }
|
|
1197
|
+
: { session_id: e.session_id, outcome: res.busy ? "busy" : "failed", reason: res.error },
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Leftover files whose owning process is gone. Only when no --session
|
|
1202
|
+
// filter narrows the run.
|
|
1203
|
+
const files = [];
|
|
1204
|
+
if (opts.sessions.length === 0) {
|
|
1205
|
+
const procs = listCaptureProcesses();
|
|
1206
|
+
const liveTimerPids = new Set(
|
|
1207
|
+
procs.processes.filter((p) => p.kind === "timer").map((p) => p.pid),
|
|
1208
|
+
);
|
|
1209
|
+
let names = [];
|
|
1210
|
+
try {
|
|
1211
|
+
names = fs.readdirSync(dir);
|
|
1212
|
+
} catch {
|
|
1213
|
+
names = [];
|
|
1214
|
+
}
|
|
1215
|
+
const nowMs = Date.now();
|
|
1216
|
+
for (const name of names) {
|
|
1217
|
+
const full = path.join(dir, name);
|
|
1218
|
+
let stale = false;
|
|
1219
|
+
if (name.endsWith(".timer.pid")) {
|
|
1220
|
+
const pid = readPidFile(full);
|
|
1221
|
+
// With a process list, trust it (a reused PID is not a timer);
|
|
1222
|
+
// without one, fall back to "is any process alive".
|
|
1223
|
+
stale = procs.ok ? !liveTimerPids.has(pid) : !isPidAlive(pid);
|
|
1224
|
+
} else if (name.endsWith(".drain.lease")) {
|
|
1225
|
+
let lease = null;
|
|
1226
|
+
try {
|
|
1227
|
+
lease = JSON.parse(fs.readFileSync(full, "utf8"));
|
|
1228
|
+
} catch {
|
|
1229
|
+
lease = null;
|
|
1230
|
+
}
|
|
1231
|
+
const expires = lease ? Date.parse(lease.expires_at) : NaN;
|
|
1232
|
+
if (lease) {
|
|
1233
|
+
stale =
|
|
1234
|
+
!isPidAlive(Number(lease.pid)) &&
|
|
1235
|
+
(!Number.isFinite(expires) || expires <= nowMs);
|
|
1236
|
+
} else {
|
|
1237
|
+
let ageMs = 0;
|
|
1238
|
+
try {
|
|
1239
|
+
ageMs = nowMs - fs.statSync(full).mtimeMs;
|
|
1240
|
+
} catch {
|
|
1241
|
+
ageMs = 0;
|
|
1242
|
+
}
|
|
1243
|
+
stale = ageMs >= UNPARSABLE_LEASE_MIN_AGE_MS;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
if (stale) files.push(full);
|
|
1247
|
+
}
|
|
1248
|
+
const jobsDir = path.join(dir, "session-end-jobs");
|
|
1249
|
+
let jobs = [];
|
|
1250
|
+
try {
|
|
1251
|
+
jobs = fs.readdirSync(jobsDir);
|
|
1252
|
+
} catch {
|
|
1253
|
+
jobs = [];
|
|
1254
|
+
}
|
|
1255
|
+
if (procs.ok) {
|
|
1256
|
+
const liveJobFiles = procs.processes
|
|
1257
|
+
.filter((p) => p.kind === "session_end_worker")
|
|
1258
|
+
.map((p) => p.command);
|
|
1259
|
+
for (const name of jobs) {
|
|
1260
|
+
const full = path.join(jobsDir, name);
|
|
1261
|
+
if (!liveJobFiles.some((cmd) => cmd.includes(name))) files.push(full);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
const removed = [];
|
|
1266
|
+
for (const f of files) {
|
|
1267
|
+
if (opts.dryRun) {
|
|
1268
|
+
removed.push({ path: f, outcome: "would_remove" });
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
fs.unlinkSync(f);
|
|
1273
|
+
removed.push({ path: f, outcome: "removed" });
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
removed.push({ path: f, outcome: "failed", reason: err.message });
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
1279
|
+
const partial =
|
|
1280
|
+
sessions.some((r) => r.outcome === "busy" || r.outcome === "failed") ||
|
|
1281
|
+
removed.some((r) => r.outcome === "failed");
|
|
1282
|
+
return {
|
|
1283
|
+
action: "repair",
|
|
1284
|
+
dry_run: opts.dryRun,
|
|
1285
|
+
sessions,
|
|
1286
|
+
files: removed,
|
|
1287
|
+
errors,
|
|
1288
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// ---------------------------------------------------------------------------
|
|
1293
|
+
// Text output
|
|
1294
|
+
// ---------------------------------------------------------------------------
|
|
1295
|
+
|
|
1296
|
+
function short(id) {
|
|
1297
|
+
return String(id || "").slice(0, 8);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function formatBytes(n) {
|
|
1301
|
+
if (n == null) return "-";
|
|
1302
|
+
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
1303
|
+
if (n >= 1024) return `${Math.round(n / 1024)}KB`;
|
|
1304
|
+
return `${n}B`;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function formatStatus(report) {
|
|
1308
|
+
const lines = [];
|
|
1309
|
+
lines.push(`Capture state: ${report.watermark_dir} (${report.total_sessions} sessions)`);
|
|
1310
|
+
if (report.sessions.length === 0) {
|
|
1311
|
+
lines.push("No session has unsent work, a stale failure, or a park.");
|
|
1312
|
+
} else {
|
|
1313
|
+
lines.push("");
|
|
1314
|
+
lines.push("Sessions needing attention:");
|
|
1315
|
+
for (const s of report.sessions) {
|
|
1316
|
+
if (!s.readable) {
|
|
1317
|
+
lines.push(` ${short(s.session_id)} unreadable watermark: ${s.error}`);
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1320
|
+
const flags = [
|
|
1321
|
+
s.parked ? "PARKED" : null,
|
|
1322
|
+
s.live ? "live" : null,
|
|
1323
|
+
s.stale_failure ? "stale-failure" : null,
|
|
1324
|
+
s.transcript_exists ? null : "no-transcript",
|
|
1325
|
+
].filter(Boolean);
|
|
1326
|
+
lines.push(
|
|
1327
|
+
` ${short(s.session_id)} ${s.client || "?"} ${s.agent_id || "?"} ` +
|
|
1328
|
+
`${s.lifecycle}/${s.delivery} queued=${s.queued_exchanges} ` +
|
|
1329
|
+
`attempts=${s.attempt_count} transcript=${formatBytes(s.transcript_bytes)}` +
|
|
1330
|
+
(flags.length ? ` [${flags.join(", ")}]` : ""),
|
|
1331
|
+
);
|
|
1332
|
+
const err = s.parked ? s.parked.previous && s.parked.previous.last_error : s.last_error;
|
|
1333
|
+
if (s.failure_class && !s.parked) {
|
|
1334
|
+
lines.push(` failure=${s.failure_class} next_retry_at=${s.next_retry_at || "-"}`);
|
|
1335
|
+
}
|
|
1336
|
+
if (err) lines.push(` last_error: ${String(err).slice(0, 160)}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
lines.push("");
|
|
1340
|
+
if (report.process_scan_error) {
|
|
1341
|
+
lines.push(`Background processes: unknown (${report.process_scan_error})`);
|
|
1342
|
+
} else if (report.processes.length === 0) {
|
|
1343
|
+
lines.push("Background capture processes: none");
|
|
1344
|
+
} else {
|
|
1345
|
+
lines.push("Background capture processes:");
|
|
1346
|
+
for (const p of report.processes) {
|
|
1347
|
+
lines.push(
|
|
1348
|
+
` pid ${p.pid} ${p.kind} ${p.session_id ? short(p.session_id) : ""}` +
|
|
1349
|
+
(p.idle_parked ? " (session parked; idle, not retrying)" : ""),
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return lines.join("\n");
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function formatActionResult(report) {
|
|
1357
|
+
const lines = [];
|
|
1358
|
+
const verb = report.dry_run ? `${report.action} (dry run)` : report.action;
|
|
1359
|
+
lines.push(`capture-cleanup ${verb}`);
|
|
1360
|
+
if (report.processes) {
|
|
1361
|
+
lines.push(
|
|
1362
|
+
report.process_scan_error
|
|
1363
|
+
? ` processes: not scanned (${report.process_scan_error})`
|
|
1364
|
+
: ` processes: ${report.processes.length}` +
|
|
1365
|
+
(report.dry_run ? " would be stopped" : " stopped"),
|
|
1366
|
+
);
|
|
1367
|
+
for (const p of report.processes) {
|
|
1368
|
+
const tail = report.dry_run ? "" : p.exited ? ` ${p.signal}` : ` STILL RUNNING`;
|
|
1369
|
+
lines.push(` pid ${p.pid} ${p.kind} ${p.session_id ? short(p.session_id) : ""}${tail}`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (report.mcp_url) lines.push(` server: ${report.mcp_url} (credentials from ${report.credentials_from})`);
|
|
1373
|
+
for (const s of report.sessions || []) {
|
|
1374
|
+
const extra = [
|
|
1375
|
+
s.queued_exchanges != null ? `queued=${s.queued_exchanges}` : null,
|
|
1376
|
+
s.captured != null ? `sent=${s.captured}` : null,
|
|
1377
|
+
s.queued_remaining != null && s.outcome !== "would_drain" ? `left=${s.queued_remaining}` : null,
|
|
1378
|
+
s.delivery ? `delivery=${s.delivery}` : null,
|
|
1379
|
+
s.reason || s.error || null,
|
|
1380
|
+
].filter(Boolean);
|
|
1381
|
+
lines.push(` ${short(s.session_id)} ${s.outcome}${extra.length ? ` ${extra.join(" ")}` : ""}`);
|
|
1382
|
+
if (s.stderr) lines.push(` ${s.stderr.split("\n").join("\n ")}`);
|
|
1383
|
+
}
|
|
1384
|
+
for (const f of report.files || []) {
|
|
1385
|
+
lines.push(` ${f.outcome} ${f.path}${f.reason ? ` ${f.reason}` : ""}`);
|
|
1386
|
+
}
|
|
1387
|
+
if (report.stopped_at) {
|
|
1388
|
+
lines.push(` stopped at ${short(report.stopped_at)}; later sessions were not attempted`);
|
|
1389
|
+
}
|
|
1390
|
+
if ((report.sessions || []).length === 0 && !(report.files || []).length && !report.processes) {
|
|
1391
|
+
lines.push(" nothing to do");
|
|
1392
|
+
}
|
|
1393
|
+
return lines.join("\n");
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// ---------------------------------------------------------------------------
|
|
1397
|
+
// Main
|
|
1398
|
+
// ---------------------------------------------------------------------------
|
|
1399
|
+
|
|
1400
|
+
function run(argv, io) {
|
|
1401
|
+
const out = io || { stdout: process.stdout, stderr: process.stderr };
|
|
1402
|
+
let opts;
|
|
1403
|
+
try {
|
|
1404
|
+
opts = parseArgs(argv);
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
out.stderr.write(`[meko-capture-cleanup] ${err.message}\n${usage()}`);
|
|
1407
|
+
return 1;
|
|
1408
|
+
}
|
|
1409
|
+
if (opts.help) {
|
|
1410
|
+
out.stdout.write(usage());
|
|
1411
|
+
return 0;
|
|
1412
|
+
}
|
|
1413
|
+
const log = (msg) => {
|
|
1414
|
+
if (!opts.json) out.stderr.write(`${msg}\n`);
|
|
1415
|
+
};
|
|
1416
|
+
let report;
|
|
1417
|
+
try {
|
|
1418
|
+
switch (opts.action) {
|
|
1419
|
+
case "status":
|
|
1420
|
+
report = actionStatus(opts);
|
|
1421
|
+
break;
|
|
1422
|
+
case "stop":
|
|
1423
|
+
report = actionStop(opts);
|
|
1424
|
+
break;
|
|
1425
|
+
case "resume":
|
|
1426
|
+
report = actionResume(opts);
|
|
1427
|
+
break;
|
|
1428
|
+
case "drain":
|
|
1429
|
+
report = actionDrain(opts, log);
|
|
1430
|
+
break;
|
|
1431
|
+
case "discard":
|
|
1432
|
+
report = actionDiscard(opts);
|
|
1433
|
+
break;
|
|
1434
|
+
case "repair":
|
|
1435
|
+
report = actionRepair(opts);
|
|
1436
|
+
break;
|
|
1437
|
+
default:
|
|
1438
|
+
throw new Error(`unknown action ${opts.action}`);
|
|
1439
|
+
}
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
out.stderr.write(`[meko-capture-cleanup] ${err.message}\n`);
|
|
1442
|
+
return 1;
|
|
1443
|
+
}
|
|
1444
|
+
const exit = report.exit;
|
|
1445
|
+
delete report.exit;
|
|
1446
|
+
if (opts.json) {
|
|
1447
|
+
out.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
1448
|
+
} else {
|
|
1449
|
+
for (const e of report.errors || []) out.stderr.write(`[meko-capture-cleanup] ${e}\n`);
|
|
1450
|
+
out.stdout.write(
|
|
1451
|
+
`${report.action === "status" ? formatStatus(report) : formatActionResult(report)}\n`,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
return exit;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
module.exports = {
|
|
1458
|
+
parseArgs,
|
|
1459
|
+
run,
|
|
1460
|
+
hasPendingWork,
|
|
1461
|
+
hasStaleFailure,
|
|
1462
|
+
parkedState,
|
|
1463
|
+
unparkedState,
|
|
1464
|
+
discardedState,
|
|
1465
|
+
listCaptureProcesses,
|
|
1466
|
+
processesInScope,
|
|
1467
|
+
isInitLike,
|
|
1468
|
+
PARK_UNTIL,
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
if (require.main === module) {
|
|
1472
|
+
process.exit(run(process.argv.slice(2)));
|
|
1473
|
+
}
|