@yugabytedb/meko-mcp 2.2.0 → 2.5.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 +168 -61
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +2134 -0
- package/assets/hooks-handlers/lib/capture-notices.js +224 -14
- package/assets/hooks-handlers/lib/capture-state.js +206 -1
- package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
- package/assets/hooks-handlers/lib/capture.js +1364 -139
- package/assets/hooks-handlers/lib/checkpoint-timer.js +445 -18
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
- package/assets/hooks-handlers/lib/opencode-handler.js +643 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +102 -19
- package/assets/skills/meko-mcp-tools/SKILL.md +59 -16
- package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +275 -19
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +7 -4
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +39 -11
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +10 -6
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +44 -29
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +79 -14
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +47 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +57 -21
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
- 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 +8 -6
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +284 -38
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +32 -10
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +16 -12
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +46 -31
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +79 -14
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +69 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +93 -14
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +44 -12
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +14 -10
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +17 -5
- package/bin/create-meko-setup.mjs +186 -30
- package/lib/autofix.mjs +100 -0
- package/lib/browser-login.mjs +495 -0
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/codex.mjs +80 -35
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +5 -5
- package/lib/clients/opencode.mjs +537 -0
- package/lib/config.mjs +68 -1
- package/lib/installer.mjs +18 -2
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +9 -2
- 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/datapack.mjs +254 -16
- package/lib/migrate/id.mjs +195 -13
- package/lib/migrate/mcp-client.mjs +59 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +294 -78
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +97 -9
- package/lib/validate.mjs +111 -22
- package/package.json +5 -5
- package/scripts/bundle-assets.mjs +4 -0
|
@@ -0,0 +1,2134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Licensed to YugabyteDB, Inc. under one or more contributor license agreements.
|
|
4
|
+
* See the NOTICE file distributed with this work for additional information
|
|
5
|
+
* regarding copyright ownership. YugabyteDB licenses this file to you under
|
|
6
|
+
* the Apache License, Version 2.0 (the "License"); you may not use this file
|
|
7
|
+
* except in compliance with the License. You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software distributed
|
|
12
|
+
* under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
13
|
+
* CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
14
|
+
* specific language governing permissions and limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* capture-cleanup-cli.js: inspect and clean up stuck conversation capture.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* node capture-cleanup-cli.js <action> [--session <id>]... [--all]
|
|
21
|
+
* [--dry-run] [--json] [--yes]
|
|
22
|
+
* [--max-exchanges N] [--pace-ms N]
|
|
23
|
+
*
|
|
24
|
+
* Actions:
|
|
25
|
+
* status Read-only. Sessions with unsent work, parked sessions, and the
|
|
26
|
+
* background capture processes that are running.
|
|
27
|
+
* stop Stop the background capture work that can retry (SessionEnd
|
|
28
|
+
* workers, in-flight drains, timers of sessions that are no
|
|
29
|
+
* longer active), then park every session with unsent work so no
|
|
30
|
+
* hook retries it. Timers of open sessions keep running: they send
|
|
31
|
+
* nothing for a parked session, and drain uses them to tell an
|
|
32
|
+
* open session from a dead one. Nothing is deleted.
|
|
33
|
+
* resume Unpark sessions and hand them back to automatic capture.
|
|
34
|
+
* drain Send parked or pending sessions now, one session at a time, and
|
|
35
|
+
* stop at the first failure. A session that fails is parked again.
|
|
36
|
+
* discard Give up on the unsent exchanges of the named sessions: move the
|
|
37
|
+
* cursor to the end of the transcript and mark them complete.
|
|
38
|
+
* Requires --yes. The exchanges are never sent.
|
|
39
|
+
* repair Clear failure fields left on fully delivered sessions, and
|
|
40
|
+
* remove timer PID files, leases, and SessionEnd job files whose
|
|
41
|
+
* process is gone.
|
|
42
|
+
* fix Do all of the above for every session that needs it: diagnose
|
|
43
|
+
* why it is stuck, then repair it, drain it, or move its unsent
|
|
44
|
+
* turns to a new conversation when the old one can't be served.
|
|
45
|
+
* Sessions that need you (bad API key, plan limit, datapack) get
|
|
46
|
+
* one attempt per run and are reported if it fails the same way.
|
|
47
|
+
* Stops early when the server itself is failing.
|
|
48
|
+
*
|
|
49
|
+
* Parking works with the capture hooks already installed (2.2.0 and later):
|
|
50
|
+
* a drain skips the network while `failure_class` is set and
|
|
51
|
+
* `next_retry_at` is in the future, and recovery never selects such a
|
|
52
|
+
* session unless its failure class is `persistent`. A parked session keeps
|
|
53
|
+
* its cursor, so `resume` or `drain` loses nothing.
|
|
54
|
+
*
|
|
55
|
+
* Exit codes: 0 done, 1 usage or I/O error, 2 partly done (a session was
|
|
56
|
+
* busy, or drain stopped at a failure).
|
|
57
|
+
*
|
|
58
|
+
* Honors MEKO_WATERMARK_DIR. Invoked by installer --capture-cleanup.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
"use strict";
|
|
62
|
+
|
|
63
|
+
const { spawnSync } = require("child_process");
|
|
64
|
+
const fs = require("fs");
|
|
65
|
+
const os = require("os");
|
|
66
|
+
const path = require("path");
|
|
67
|
+
|
|
68
|
+
const cs = require("./capture-state");
|
|
69
|
+
const { PARKED_REASON_PREFIX } = require("./capture-notices");
|
|
70
|
+
|
|
71
|
+
const ACTIONS = ["status", "fix", "stop", "resume", "drain", "discard", "repair"];
|
|
72
|
+
const PARK_UNTIL = cs.CLEANUP_PARK_UNTIL;
|
|
73
|
+
const PARKED_BY = "meko-mcp --capture-cleanup";
|
|
74
|
+
const RESUME_HINT =
|
|
75
|
+
`${PARKED_REASON_PREFIX}; run \`npx @yugabytedb/meko-mcp@latest ` +
|
|
76
|
+
"--capture-cleanup fix` to send it, or `resume` to hand back to the hooks";
|
|
77
|
+
// A conversation reopened this many times was replayed into over and over
|
|
78
|
+
// (one session reached epoch 7,496); its trace is likely too large to serve.
|
|
79
|
+
const REPLAY_EPOCH_THRESHOLD = 20;
|
|
80
|
+
const PENDING_DELIVERIES = [
|
|
81
|
+
"needs_conversation",
|
|
82
|
+
"pending",
|
|
83
|
+
"draining",
|
|
84
|
+
"retry_wait",
|
|
85
|
+
"blocked",
|
|
86
|
+
];
|
|
87
|
+
// capture.js modes that run in the background. Foreground hook modes
|
|
88
|
+
// (session-start, pre-compact) belong to a live client and are left alone.
|
|
89
|
+
const BACKGROUND_CAPTURE_MODES = ["drain", "recover", "checkpoint", "session-end", "move"];
|
|
90
|
+
const DEFAULT_MAX_EXCHANGES = 200;
|
|
91
|
+
const DEFAULT_PACE_MS = 1000;
|
|
92
|
+
const KILL_WAIT_MS = 3000;
|
|
93
|
+
// A lease file that doesn't parse may be mid-write (acquireLease creates it
|
|
94
|
+
// with `wx`, then writes); only an old one is debris.
|
|
95
|
+
const UNPARSABLE_LEASE_MIN_AGE_MS = 60_000;
|
|
96
|
+
const REPARK_ATTEMPTS = 10;
|
|
97
|
+
const REPARK_RETRY_MS = 1000;
|
|
98
|
+
const LEASE_POLL_MS = 500;
|
|
99
|
+
const MAX_LEASE_WAITS = 5;
|
|
100
|
+
const MAX_MOVES_PER_DRAIN = 1;
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Args
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function usage() {
|
|
107
|
+
return (
|
|
108
|
+
"Usage: capture-cleanup-cli.js <status|fix|stop|resume|drain|discard|repair>\n" +
|
|
109
|
+
" [--session <id>]... [--all] [--dry-run] [--json] [--yes]\n" +
|
|
110
|
+
" [--max-exchanges N] [--pace-ms N] [--auto]\n"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseArgs(argv) {
|
|
115
|
+
const out = {
|
|
116
|
+
action: null,
|
|
117
|
+
sessions: [],
|
|
118
|
+
all: false,
|
|
119
|
+
dryRun: false,
|
|
120
|
+
json: false,
|
|
121
|
+
yes: false,
|
|
122
|
+
auto: false,
|
|
123
|
+
maxExchanges: DEFAULT_MAX_EXCHANGES,
|
|
124
|
+
paceMs: DEFAULT_PACE_MS,
|
|
125
|
+
};
|
|
126
|
+
const intArg = (name, raw) => {
|
|
127
|
+
const n = Number(raw);
|
|
128
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
129
|
+
throw new Error(`${name} must be a non-negative integer, got "${raw}"`);
|
|
130
|
+
}
|
|
131
|
+
return n;
|
|
132
|
+
};
|
|
133
|
+
for (let i = 0; i < argv.length; i++) {
|
|
134
|
+
const a = argv[i];
|
|
135
|
+
const next = () => {
|
|
136
|
+
if (i + 1 >= argv.length) throw new Error(`${a} requires a value`);
|
|
137
|
+
return argv[++i];
|
|
138
|
+
};
|
|
139
|
+
if (a === "--help" || a === "-h") out.help = true;
|
|
140
|
+
else if (a === "--session") out.sessions.push(next());
|
|
141
|
+
else if (a.startsWith("--session=")) out.sessions.push(a.slice(10));
|
|
142
|
+
else if (a === "--all") out.all = true;
|
|
143
|
+
else if (a === "--dry-run") out.dryRun = true;
|
|
144
|
+
else if (a === "--json") out.json = true;
|
|
145
|
+
else if (a === "--yes") out.yes = true;
|
|
146
|
+
else if (a === "--auto") out.auto = true;
|
|
147
|
+
else if (a === "--max-exchanges") out.maxExchanges = intArg(a, next());
|
|
148
|
+
else if (a === "--pace-ms") out.paceMs = intArg(a, next());
|
|
149
|
+
else if (!a.startsWith("-") && !out.action) out.action = a;
|
|
150
|
+
else throw new Error(`unknown argument: ${a}`);
|
|
151
|
+
}
|
|
152
|
+
if (!out.help) {
|
|
153
|
+
if (!out.action) out.action = "status";
|
|
154
|
+
if (!ACTIONS.includes(out.action)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`unknown action "${out.action}"; expected one of ${ACTIONS.join(", ")}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Session helpers
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function isPidAlive(pid) {
|
|
168
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
169
|
+
try {
|
|
170
|
+
process.kill(pid, 0);
|
|
171
|
+
return true;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// EPERM means the process exists but belongs to someone else.
|
|
174
|
+
return Boolean(err && err.code === "EPERM");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function hasPendingWork(state) {
|
|
179
|
+
if (!state || state.delivery === "complete") return false;
|
|
180
|
+
return (
|
|
181
|
+
(Number(state.queued_exchanges) || 0) > 0 ||
|
|
182
|
+
PENDING_DELIVERIES.includes(state.delivery)
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* True only while the park is still in effect. Hook writes merge the file on
|
|
188
|
+
* disk under their candidate, so a `parked` object survives a rebucket or a
|
|
189
|
+
* successful drain that has already reset the retry fields.
|
|
190
|
+
*/
|
|
191
|
+
function isParked(state) {
|
|
192
|
+
return Boolean(
|
|
193
|
+
state &&
|
|
194
|
+
state.parked &&
|
|
195
|
+
typeof state.parked === "object" &&
|
|
196
|
+
state.failure_class &&
|
|
197
|
+
state.next_retry_at === PARK_UNTIL,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Failure fields left behind on a session that has nothing left to send. */
|
|
202
|
+
function hasStaleFailure(state) {
|
|
203
|
+
if (!state || isParked(state)) return false;
|
|
204
|
+
if (!["complete", "idle"].includes(state.delivery)) return false;
|
|
205
|
+
if ((Number(state.queued_exchanges) || 0) > 0) return false;
|
|
206
|
+
return Boolean(
|
|
207
|
+
state.failure_class || state.next_retry_at || state.last_error,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function transcriptInfo(state) {
|
|
212
|
+
const p = state && state.transcript_path;
|
|
213
|
+
if (!p) return { exists: false, bytes: null, mtime_ms: null };
|
|
214
|
+
try {
|
|
215
|
+
const st = fs.statSync(p);
|
|
216
|
+
return { exists: true, bytes: st.size, mtime_ms: st.mtimeMs };
|
|
217
|
+
} catch {
|
|
218
|
+
return { exists: false, bytes: null, mtime_ms: null };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function timerAlive(sessionId) {
|
|
223
|
+
try {
|
|
224
|
+
const pid = Number.parseInt(
|
|
225
|
+
fs.readFileSync(cs.timerPidPath(sessionId), "utf8"),
|
|
226
|
+
10,
|
|
227
|
+
);
|
|
228
|
+
return isPidAlive(pid);
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The transcript changed within the recovery idle window. */
|
|
235
|
+
function recentlyWritten(info, nowMs) {
|
|
236
|
+
if (!info.exists || info.mtime_ms == null) return false;
|
|
237
|
+
return nowMs - info.mtime_ms < cs.recoveryMinIdle() * 1000;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* An active session whose client may still be writing turns. The timer only
|
|
242
|
+
* treats an active session as abandoned when it is idle AND no checkpoint
|
|
243
|
+
* timer is alive (buildEligibility in capture-state.js); a session counts as
|
|
244
|
+
* live here if either says so.
|
|
245
|
+
*/
|
|
246
|
+
function looksLive(state, info, nowMs) {
|
|
247
|
+
if (!state || state.lifecycle !== "active") return false;
|
|
248
|
+
return recentlyWritten(info, nowMs) || timerAlive(state.session_id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function countLines(filePath) {
|
|
252
|
+
const buf = fs.readFileSync(filePath);
|
|
253
|
+
if (buf.length === 0) return 0;
|
|
254
|
+
let n = 0;
|
|
255
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n++;
|
|
256
|
+
if (buf[buf.length - 1] !== 10) n++;
|
|
257
|
+
return n;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function summarize(entry, nowMs) {
|
|
261
|
+
if (!entry.ok) {
|
|
262
|
+
return {
|
|
263
|
+
session_id: entry.session_id,
|
|
264
|
+
readable: false,
|
|
265
|
+
health: entry.health,
|
|
266
|
+
error: entry.error || null,
|
|
267
|
+
path: entry.path || null,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const s = entry.state;
|
|
271
|
+
const info = transcriptInfo(s);
|
|
272
|
+
return {
|
|
273
|
+
session_id: s.session_id,
|
|
274
|
+
readable: true,
|
|
275
|
+
health: entry.health,
|
|
276
|
+
client: s.client || null,
|
|
277
|
+
agent_id: s.agent_id || null,
|
|
278
|
+
conversation_id: s.conversation_id || null,
|
|
279
|
+
lifecycle: s.lifecycle,
|
|
280
|
+
delivery: s.delivery,
|
|
281
|
+
queued_exchanges: Number(s.queued_exchanges) || 0,
|
|
282
|
+
attempt_count: Number(s.attempt_count) || 0,
|
|
283
|
+
failure_class: s.failure_class || null,
|
|
284
|
+
next_retry_at: s.next_retry_at || null,
|
|
285
|
+
last_error: s.last_error || null,
|
|
286
|
+
last_success_at: s.last_success_at || null,
|
|
287
|
+
parked: isParked(s) ? s.parked : null,
|
|
288
|
+
pending_work: hasPendingWork(s),
|
|
289
|
+
stale_failure: hasStaleFailure(s),
|
|
290
|
+
transcript_path: s.transcript_path || null,
|
|
291
|
+
transcript_exists: info.exists,
|
|
292
|
+
transcript_bytes: info.bytes,
|
|
293
|
+
live: looksLive(s, info, nowMs),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function needsAttention(summary) {
|
|
298
|
+
if (!summary.readable) return true;
|
|
299
|
+
return (
|
|
300
|
+
summary.pending_work ||
|
|
301
|
+
Boolean(summary.parked) ||
|
|
302
|
+
summary.stale_failure ||
|
|
303
|
+
summary.health !== "healthy"
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Resolve --session values (full IDs or unique prefixes) against the scan.
|
|
309
|
+
* Returns { entries, errors }.
|
|
310
|
+
*/
|
|
311
|
+
function selectEntries(scanEntries, opts, defaultFilter) {
|
|
312
|
+
if (opts.sessions.length === 0) {
|
|
313
|
+
const filtered = opts.all || !defaultFilter
|
|
314
|
+
? scanEntries
|
|
315
|
+
: scanEntries.filter(defaultFilter);
|
|
316
|
+
return { entries: filtered, errors: [] };
|
|
317
|
+
}
|
|
318
|
+
const entries = [];
|
|
319
|
+
const errors = [];
|
|
320
|
+
for (const wanted of opts.sessions) {
|
|
321
|
+
const exact = scanEntries.find((e) => e.session_id === wanted);
|
|
322
|
+
const matches = exact
|
|
323
|
+
? [exact]
|
|
324
|
+
: scanEntries.filter((e) => String(e.session_id).startsWith(wanted));
|
|
325
|
+
if (matches.length === 1) {
|
|
326
|
+
if (!entries.includes(matches[0])) entries.push(matches[0]);
|
|
327
|
+
} else if (matches.length === 0) {
|
|
328
|
+
errors.push(`no watermark matches session "${wanted}"`);
|
|
329
|
+
} else {
|
|
330
|
+
errors.push(
|
|
331
|
+
`session "${wanted}" is ambiguous (${matches.length} matches); use more characters`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { entries, errors };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* mutate(), retried while a hook drain holds the lease. Used to park a
|
|
340
|
+
* session again after a failed drain, when a hook may have picked it up in
|
|
341
|
+
* the moment it was unparked.
|
|
342
|
+
*/
|
|
343
|
+
function mutateWithRetry(sessionId, mutator) {
|
|
344
|
+
let res;
|
|
345
|
+
for (let i = 0; i < REPARK_ATTEMPTS; i++) {
|
|
346
|
+
res = mutate(sessionId, mutator);
|
|
347
|
+
if (res.ok || !res.busy) return res;
|
|
348
|
+
sleepMs(REPARK_RETRY_MS);
|
|
349
|
+
}
|
|
350
|
+
return res;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Run a state mutation while holding the session's drain lease, so no hook
|
|
355
|
+
* drain can start or commit in between. Returns { ok, busy?, error?, state? }.
|
|
356
|
+
*/
|
|
357
|
+
function withLease(sessionId, fn) {
|
|
358
|
+
const lease = cs.acquireLease(sessionId);
|
|
359
|
+
if (!lease.ok) {
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
busy: lease.failure_class === "ownership",
|
|
363
|
+
error: lease.error,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
return fn();
|
|
368
|
+
} finally {
|
|
369
|
+
cs.releaseLease(sessionId, lease.lease.token);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function mutate(sessionId, mutator) {
|
|
374
|
+
return withLease(sessionId, () => {
|
|
375
|
+
const res = cs.mutateState(sessionId, (current) => {
|
|
376
|
+
if (!current.ok) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
failure_class: current.failure_class || "state_corrupt",
|
|
380
|
+
error: current.error || "watermark unreadable",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return mutator({ ...current.state });
|
|
384
|
+
});
|
|
385
|
+
return res.ok
|
|
386
|
+
? { ok: true, state: res.state }
|
|
387
|
+
: { ok: false, error: res.error };
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// State transitions
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
function parkedState(state, reason, nowIso) {
|
|
396
|
+
return {
|
|
397
|
+
...state,
|
|
398
|
+
parked: {
|
|
399
|
+
at: nowIso,
|
|
400
|
+
by: PARKED_BY,
|
|
401
|
+
reason,
|
|
402
|
+
previous: {
|
|
403
|
+
delivery: state.delivery,
|
|
404
|
+
failure_class: state.failure_class || null,
|
|
405
|
+
next_retry_at: state.next_retry_at || null,
|
|
406
|
+
attempt_count: Number(state.attempt_count) || 0,
|
|
407
|
+
last_error: state.last_error || null,
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
// Not `persistent`: the timer force-retries persistent failures once at
|
|
411
|
+
// startup regardless of next_retry_at.
|
|
412
|
+
delivery: "blocked",
|
|
413
|
+
failure_class: "transient",
|
|
414
|
+
next_retry_at: PARK_UNTIL,
|
|
415
|
+
blocked_reason: RESUME_HINT,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const PAUSED_BY_USER = "paused by the user with stop";
|
|
420
|
+
|
|
421
|
+
/** A session the user parked with `stop`, while that park is in effect. */
|
|
422
|
+
function stoppedByUser(state) {
|
|
423
|
+
return isParked(state) && state.parked.reason === "stop";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Retry fields for a session handed back to capture.js. A failure that was
|
|
428
|
+
* `persistent` with scope `exchange` is what lets capture.js skip one
|
|
429
|
+
* exchange the edge keeps rejecting (HTML 403), once its backoff is due.
|
|
430
|
+
* Clearing it would resend that exchange and hold the queue again, so keep
|
|
431
|
+
* the class and make the retry due now.
|
|
432
|
+
*/
|
|
433
|
+
function handBackRetryFields(previousFailureClass, state) {
|
|
434
|
+
if (previousFailureClass === "persistent" && state.last_failure_scope === "exchange") {
|
|
435
|
+
return {
|
|
436
|
+
failure_class: "persistent",
|
|
437
|
+
next_retry_at: new Date().toISOString(),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
return { failure_class: null, next_retry_at: null };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function unparkedState(state) {
|
|
444
|
+
const queued = Number(state.queued_exchanges) || 0;
|
|
445
|
+
const previous = (state.parked && state.parked.previous) || {};
|
|
446
|
+
let delivery;
|
|
447
|
+
if (!state.conversation_id) delivery = "needs_conversation";
|
|
448
|
+
else if (state.lifecycle === "closed" && queued === 0) delivery = "complete";
|
|
449
|
+
else delivery = "pending";
|
|
450
|
+
return {
|
|
451
|
+
...state,
|
|
452
|
+
// writeState merges the file on disk under the candidate, so a removed
|
|
453
|
+
// key would survive; null clears it.
|
|
454
|
+
parked: null,
|
|
455
|
+
delivery,
|
|
456
|
+
...handBackRetryFields(previous.failure_class, state),
|
|
457
|
+
attempt_count: 0,
|
|
458
|
+
last_error: null,
|
|
459
|
+
blocked_reason: null,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function discardedState(state, lineCount) {
|
|
464
|
+
const queued = Number(state.queued_exchanges) || 0;
|
|
465
|
+
return {
|
|
466
|
+
...state,
|
|
467
|
+
parked: null,
|
|
468
|
+
last_line_number: Math.max(Number(state.last_line_number) || 0, lineCount),
|
|
469
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
470
|
+
lifecycle: "closed",
|
|
471
|
+
delivery: "complete",
|
|
472
|
+
queued_exchanges: 0,
|
|
473
|
+
failure_class: null,
|
|
474
|
+
next_retry_at: null,
|
|
475
|
+
attempt_count: 0,
|
|
476
|
+
last_error: null,
|
|
477
|
+
blocked_reason: null,
|
|
478
|
+
dropped_exchanges: (Number(state.dropped_exchanges) || 0) + queued,
|
|
479
|
+
last_drop_reason: `discarded by ${PARKED_BY}`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function clearedFailureState(state) {
|
|
484
|
+
return {
|
|
485
|
+
...state,
|
|
486
|
+
failure_class: null,
|
|
487
|
+
next_retry_at: null,
|
|
488
|
+
attempt_count: 0,
|
|
489
|
+
last_error: null,
|
|
490
|
+
blocked_reason: null,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** clearedFailureState, keeping capture.js's one-exchange escape. */
|
|
495
|
+
function handBackState(state) {
|
|
496
|
+
return {
|
|
497
|
+
...clearedFailureState(state),
|
|
498
|
+
...handBackRetryFields(state.failure_class, state),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Sleep while holding the session's drain lease, so a hook timer's recovery
|
|
504
|
+
* cycle can't start a drain of it between two of our batches.
|
|
505
|
+
*/
|
|
506
|
+
function sleepHoldingLease(sessionId, ms) {
|
|
507
|
+
if (ms <= 0) return;
|
|
508
|
+
const lease = cs.acquireLease(sessionId);
|
|
509
|
+
try {
|
|
510
|
+
sleepMs(ms);
|
|
511
|
+
} finally {
|
|
512
|
+
if (lease.ok) cs.releaseLease(sessionId, lease.lease.token);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Wait until no live worker holds the session's drain lease. Returns false
|
|
518
|
+
* when it's still held after the wait.
|
|
519
|
+
*/
|
|
520
|
+
function waitForLease(sessionId, maxMs) {
|
|
521
|
+
const deadline = Date.now() + maxMs;
|
|
522
|
+
while (Date.now() < deadline) {
|
|
523
|
+
const lease = cs.acquireLease(sessionId);
|
|
524
|
+
if (lease.ok) {
|
|
525
|
+
cs.releaseLease(sessionId, lease.lease.token);
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
sleepMs(LEASE_POLL_MS);
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* capture.js answers retry_wait with no failure class and no error when it
|
|
535
|
+
* has just moved the session's backlog to a new conversation (MEKO-723).
|
|
536
|
+
*/
|
|
537
|
+
function movedToNewConversation(result) {
|
|
538
|
+
return Boolean(
|
|
539
|
+
result &&
|
|
540
|
+
result.status === "retry_wait" &&
|
|
541
|
+
!result.failure_class &&
|
|
542
|
+
!result.error,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function heldByAnotherWorker(result) {
|
|
547
|
+
return (
|
|
548
|
+
result &&
|
|
549
|
+
result.status === "retry_wait" &&
|
|
550
|
+
result.failure_class === "ownership" &&
|
|
551
|
+
/lease held/.test(String(result.error || ""))
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// Processes
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
const HOOK_SCRIPT_RE =
|
|
560
|
+
/hooks-handlers[\\/]lib[\\/](checkpoint-timer|terminal-handoff|capture|capture-cleanup-cli)\.js(?:\s+(\S+))?(?:\s+(\S+))?/;
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* List background capture processes from `ps`. Returns { ok, processes,
|
|
564
|
+
* error }. Unsupported platforms (no `ps`) return ok:false.
|
|
565
|
+
*/
|
|
566
|
+
function listCaptureProcesses() {
|
|
567
|
+
const res = spawnSync("ps", ["-axo", "pid=,ppid=,command="], {
|
|
568
|
+
encoding: "utf8",
|
|
569
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
570
|
+
});
|
|
571
|
+
if (res.error || res.status !== 0) {
|
|
572
|
+
return {
|
|
573
|
+
ok: false,
|
|
574
|
+
processes: [],
|
|
575
|
+
error: res.error ? res.error.message : `ps exited ${res.status}`,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const processes = [];
|
|
579
|
+
const commands = new Map();
|
|
580
|
+
const rows = [];
|
|
581
|
+
for (const line of res.stdout.split("\n")) {
|
|
582
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
583
|
+
if (m) rows.push(m);
|
|
584
|
+
if (m) commands.set(Number(m[1]), m[3]);
|
|
585
|
+
}
|
|
586
|
+
for (const m of rows) {
|
|
587
|
+
const pid = Number(m[1]);
|
|
588
|
+
const ppid = Number(m[2]);
|
|
589
|
+
const command = m[3];
|
|
590
|
+
if (pid === process.pid) continue;
|
|
591
|
+
const hit = HOOK_SCRIPT_RE.exec(command);
|
|
592
|
+
if (!hit) continue;
|
|
593
|
+
const script = hit[1];
|
|
594
|
+
let kind = null;
|
|
595
|
+
let sessionId = null;
|
|
596
|
+
let jobFile = null;
|
|
597
|
+
if (script === "checkpoint-timer") {
|
|
598
|
+
kind = "timer";
|
|
599
|
+
if (hit[2]) sessionId = path.basename(hit[2], ".jsonl");
|
|
600
|
+
} else if (script === "terminal-handoff" && hit[2] === "worker") {
|
|
601
|
+
kind = "session_end_worker";
|
|
602
|
+
jobFile = hit[3] || null;
|
|
603
|
+
sessionId = sessionFromJobFile(jobFile);
|
|
604
|
+
} else if (script === "capture-cleanup-cli" && hit[2] === "fix" && /\s--auto(\s|$)/.test(command)) {
|
|
605
|
+
// An automatic repair run started by a checkpoint timer.
|
|
606
|
+
kind = "autofix_run";
|
|
607
|
+
} else if (script === "capture" && BACKGROUND_CAPTURE_MODES.includes(hit[2])) {
|
|
608
|
+
kind = `capture_${hit[2].replace("-", "_")}`;
|
|
609
|
+
}
|
|
610
|
+
if (!kind) continue;
|
|
611
|
+
processes.push({
|
|
612
|
+
pid,
|
|
613
|
+
ppid,
|
|
614
|
+
kind,
|
|
615
|
+
session_id: sessionId,
|
|
616
|
+
job_file: jobFile,
|
|
617
|
+
command,
|
|
618
|
+
orphan: isInitLike(ppid, commands.get(ppid)),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return { ok: true, processes };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The parent a detached child is reparented to: PID 1, or a subreaper such
|
|
626
|
+
* as `systemd --user` or launchd.
|
|
627
|
+
*/
|
|
628
|
+
function isInitLike(ppid, parentCommand) {
|
|
629
|
+
if (ppid === 1) return true;
|
|
630
|
+
return /(^|\/)(systemd|launchd|init)(\s|$)/.test(String(parentCommand || ""));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The processes `stop` may signal: SessionEnd workers whose job file lives
|
|
635
|
+
* under this watermark directory, timers of in-scope sessions that are no
|
|
636
|
+
* longer active, and the capture.js children of any in-scope timer or
|
|
637
|
+
* worker. Timers of active sessions (`keepTimerSessionIds`) keep running;
|
|
638
|
+
* only their in-flight children are stopped. Detached capture.js runs with
|
|
639
|
+
* no parent (Kiro's Stop-time drain) can't be tied to a directory, so they
|
|
640
|
+
* are included only for an unfiltered stop of the default directory.
|
|
641
|
+
*/
|
|
642
|
+
function processesInScope(processes, sessionIds, opts, keepTimerSessionIds) {
|
|
643
|
+
const inScope = new Set(sessionIds);
|
|
644
|
+
const keep = new Set(keepTimerSessionIds || []);
|
|
645
|
+
const jobsDir = path.join(cs.watermarkDir(), "session-end-jobs") + path.sep;
|
|
646
|
+
const owners = processes.filter(
|
|
647
|
+
(p) =>
|
|
648
|
+
(p.kind === "timer" && inScope.has(p.session_id)) ||
|
|
649
|
+
(p.kind === "session_end_worker" &&
|
|
650
|
+
typeof p.job_file === "string" &&
|
|
651
|
+
p.job_file.startsWith(jobsDir) &&
|
|
652
|
+
(!p.session_id || inScope.has(p.session_id))),
|
|
653
|
+
);
|
|
654
|
+
const parents = new Set(owners.map((p) => p.pid));
|
|
655
|
+
const picked = owners.filter(
|
|
656
|
+
(p) => !(p.kind === "timer" && keep.has(p.session_id)),
|
|
657
|
+
);
|
|
658
|
+
const defaultDir =
|
|
659
|
+
path.resolve(cs.watermarkDir()) ===
|
|
660
|
+
path.resolve(os.homedir(), ".claude", "meko-capture");
|
|
661
|
+
const includeOrphans = defaultDir && opts.sessions.length === 0;
|
|
662
|
+
for (const p of processes) {
|
|
663
|
+
if (!p.kind.startsWith("capture_")) continue;
|
|
664
|
+
if (parents.has(p.ppid) || (includeOrphans && p.orphan)) picked.push(p);
|
|
665
|
+
}
|
|
666
|
+
return picked;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function sessionFromJobFile(jobFile) {
|
|
670
|
+
if (!jobFile) return null;
|
|
671
|
+
try {
|
|
672
|
+
const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
673
|
+
if (job.session_id) return String(job.session_id);
|
|
674
|
+
const t = job.transcript_path || (job.hookSpecificInput || {}).transcript_path;
|
|
675
|
+
return t ? path.basename(String(t), ".jsonl") : null;
|
|
676
|
+
} catch {
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function sleepMs(ms) {
|
|
682
|
+
if (ms <= 0) return;
|
|
683
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
684
|
+
Atomics.wait(cell, 0, 0, ms);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* SIGTERM each process (timers and workers clean up their own PID and job
|
|
689
|
+
* files on SIGTERM), wait, then SIGKILL survivors.
|
|
690
|
+
*/
|
|
691
|
+
function killProcesses(processes) {
|
|
692
|
+
const results = [];
|
|
693
|
+
for (const p of processes) {
|
|
694
|
+
try {
|
|
695
|
+
process.kill(p.pid, "SIGTERM");
|
|
696
|
+
results.push({ ...p, signal: "SIGTERM", ok: true });
|
|
697
|
+
} catch (err) {
|
|
698
|
+
results.push({
|
|
699
|
+
...p,
|
|
700
|
+
signal: "SIGTERM",
|
|
701
|
+
ok: Boolean(err && err.code === "ESRCH"),
|
|
702
|
+
error: err && err.code !== "ESRCH" ? err.message : null,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const deadline = Date.now() + KILL_WAIT_MS;
|
|
707
|
+
while (Date.now() < deadline && results.some((r) => isPidAlive(r.pid))) {
|
|
708
|
+
sleepMs(100);
|
|
709
|
+
}
|
|
710
|
+
for (const r of results) {
|
|
711
|
+
if (!isPidAlive(r.pid)) {
|
|
712
|
+
r.exited = true;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
process.kill(r.pid, "SIGKILL");
|
|
717
|
+
r.signal = "SIGKILL";
|
|
718
|
+
} catch (err) {
|
|
719
|
+
r.error = err.message;
|
|
720
|
+
}
|
|
721
|
+
sleepMs(100);
|
|
722
|
+
r.exited = !isPidAlive(r.pid);
|
|
723
|
+
}
|
|
724
|
+
return results;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
// Drain credentials
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* MEKO_MCP_URL / MEKO_API_KEY from the environment, else from the `env`
|
|
733
|
+
* block the installer writes into ~/.claude/settings.json for Claude Code.
|
|
734
|
+
*/
|
|
735
|
+
function resolveCredentials() {
|
|
736
|
+
const env = {
|
|
737
|
+
MEKO_MCP_URL: process.env.MEKO_MCP_URL || "",
|
|
738
|
+
MEKO_API_KEY: process.env.MEKO_API_KEY || "",
|
|
739
|
+
};
|
|
740
|
+
let source = env.MEKO_MCP_URL ? "environment" : null;
|
|
741
|
+
// The checkpoint timer sets MEKO_CLEANUP_ENV_ONLY for automatic runs: a
|
|
742
|
+
// client whose hook credentials were removed must not borrow Claude
|
|
743
|
+
// Code's from settings.json.
|
|
744
|
+
if (!env.MEKO_MCP_URL && !process.env.MEKO_CLEANUP_ENV_ONLY) {
|
|
745
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
746
|
+
try {
|
|
747
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
748
|
+
const block = (settings && settings.env) || {};
|
|
749
|
+
if (block.MEKO_MCP_URL) {
|
|
750
|
+
env.MEKO_MCP_URL = String(block.MEKO_MCP_URL);
|
|
751
|
+
if (!env.MEKO_API_KEY && block.MEKO_API_KEY) {
|
|
752
|
+
env.MEKO_API_KEY = String(block.MEKO_API_KEY);
|
|
753
|
+
}
|
|
754
|
+
source = settingsPath;
|
|
755
|
+
}
|
|
756
|
+
} catch {
|
|
757
|
+
/* no Claude Code settings; the caller reports the missing URL */
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return { env, source };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* A drain result that made progress or had nothing to do. `partial` without
|
|
765
|
+
* a failure class means the batch cap was reached with work still queued.
|
|
766
|
+
*/
|
|
767
|
+
function drainSucceeded(result) {
|
|
768
|
+
if (!result) return false;
|
|
769
|
+
if (result.status === "ok" || result.status === "noop") return true;
|
|
770
|
+
if (movedToNewConversation(result)) return true;
|
|
771
|
+
return result.status === "partial" && !result.failure_class && !result.error;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function runCaptureChild(mode, input, env) {
|
|
775
|
+
const captureJs = path.join(__dirname, "capture.js");
|
|
776
|
+
const res = spawnSync(process.execPath, [captureJs, mode], {
|
|
777
|
+
input: JSON.stringify(input),
|
|
778
|
+
encoding: "utf8",
|
|
779
|
+
env: { ...process.env, ...env },
|
|
780
|
+
timeout: cs.checkpointTimeout() * 1000,
|
|
781
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
782
|
+
});
|
|
783
|
+
const lines = String(res.stdout || "").trim().split("\n").filter(Boolean);
|
|
784
|
+
let result = null;
|
|
785
|
+
try {
|
|
786
|
+
result = JSON.parse(lines[lines.length - 1]);
|
|
787
|
+
} catch {
|
|
788
|
+
/* handled below */
|
|
789
|
+
}
|
|
790
|
+
if (!result) {
|
|
791
|
+
const why = res.error
|
|
792
|
+
? res.error.message
|
|
793
|
+
: `capture.js ${mode} exited ${res.status} without a result`;
|
|
794
|
+
result = { status: "error", error: why, captured: 0, queued_remaining: null };
|
|
795
|
+
}
|
|
796
|
+
const stderrTail = String(res.stderr || "")
|
|
797
|
+
.trim()
|
|
798
|
+
.split("\n")
|
|
799
|
+
.slice(-5)
|
|
800
|
+
.join("\n");
|
|
801
|
+
return { result, stderr: stderrTail };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// Actions
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
function scan() {
|
|
809
|
+
const res = cs.scanStates({ includeBlocked: true });
|
|
810
|
+
if (!res.ok) throw new Error(res.error || "watermark scan failed");
|
|
811
|
+
return res.entries || [];
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function refreshHealthCache() {
|
|
815
|
+
const res = cs.scanStates({ includeBlocked: true });
|
|
816
|
+
const health = cs.computeAggregateHealth(res.entries || []);
|
|
817
|
+
const existing = cs.readHealthCache();
|
|
818
|
+
if (existing && existing.last_notified_status !== undefined) {
|
|
819
|
+
health.last_notified_status = existing.last_notified_status;
|
|
820
|
+
}
|
|
821
|
+
cs.writeHealthCache(health, { clampDropped: cs.scanCompleteForDropClamp(res) });
|
|
822
|
+
return health;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function describeLastFix(last) {
|
|
826
|
+
if (!last || !last.ran_at) return null;
|
|
827
|
+
const c = last.counts || {};
|
|
828
|
+
const parts = [`fixed ${c.fixed || 0}`];
|
|
829
|
+
if (c.moved) parts.push(`moved ${c.moved}`);
|
|
830
|
+
if (c.paused) parts.push(`left paused ${c.paused}`);
|
|
831
|
+
if (c.failed) parts.push(`failed ${c.failed}`);
|
|
832
|
+
if (c.not_attempted) parts.push(`not attempted ${c.not_attempted}`);
|
|
833
|
+
for (const n of last.needs_you || []) parts.push(`needs you (${n.cause}): ${n.sessions}`);
|
|
834
|
+
if (last.error) parts.push(`error: ${String(last.error).slice(0, 120)}`);
|
|
835
|
+
if (last.running) parts.push("did not finish");
|
|
836
|
+
return `${last.mode === "auto" ? "automatic" : "manual"} run at ${last.ran_at}: ${parts.join(", ")}`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function actionStatus(opts) {
|
|
840
|
+
const nowMs = Date.now();
|
|
841
|
+
const entries = scan();
|
|
842
|
+
const summaries = entries.map((e) => summarize(e, nowMs));
|
|
843
|
+
const { entries: selected, errors } = selectEntries(
|
|
844
|
+
summaries,
|
|
845
|
+
opts,
|
|
846
|
+
needsAttention,
|
|
847
|
+
);
|
|
848
|
+
const procs = listCaptureProcesses();
|
|
849
|
+
const parkedIds = new Set(
|
|
850
|
+
summaries.filter((s) => s.parked).map((s) => s.session_id),
|
|
851
|
+
);
|
|
852
|
+
for (const p of procs.processes) {
|
|
853
|
+
// A SessionEnd worker for a parked session makes no requests; it sleeps
|
|
854
|
+
// until its age ceiling (older hooks) or exits (newer ones). A timer is
|
|
855
|
+
// never labelled: it still runs recovery for other sessions.
|
|
856
|
+
if (p.kind === "session_end_worker" && p.session_id && parkedIds.has(p.session_id)) {
|
|
857
|
+
p.idle_parked = true;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
action: "status",
|
|
862
|
+
watermark_dir: cs.watermarkDir(),
|
|
863
|
+
total_sessions: summaries.length,
|
|
864
|
+
last_fix: cs.readAutofixLast(),
|
|
865
|
+
autofix: { ...cs.autofixSetting(), interval_seconds: cs.autofixIntervalSeconds() },
|
|
866
|
+
sessions: selected,
|
|
867
|
+
processes: procs.processes,
|
|
868
|
+
process_scan_error: procs.ok ? null : procs.error,
|
|
869
|
+
errors,
|
|
870
|
+
exit: errors.length ? 1 : 0,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function parkEntries(entries, reason, dryRun) {
|
|
875
|
+
const nowIso = new Date().toISOString();
|
|
876
|
+
const results = [];
|
|
877
|
+
for (const e of entries) {
|
|
878
|
+
const sid = e.session_id;
|
|
879
|
+
if (!e.ok) {
|
|
880
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "watermark unreadable" });
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (isParked(e.state)) {
|
|
884
|
+
results.push({ session_id: sid, outcome: "already_parked" });
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (!hasPendingWork(e.state)) {
|
|
888
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "nothing to send" });
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (dryRun) {
|
|
892
|
+
results.push({ session_id: sid, outcome: "would_park", queued_exchanges: Number(e.state.queued_exchanges) || 0 });
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
// A hook may be draining this session right now (a kept timer restarts
|
|
896
|
+
// recovery when its child is stopped); wait for it rather than skip.
|
|
897
|
+
const res = mutateWithRetry(sid, (state) =>
|
|
898
|
+
isParked(state) ? state : parkedState(state, reason, nowIso),
|
|
899
|
+
);
|
|
900
|
+
results.push(
|
|
901
|
+
res.ok
|
|
902
|
+
? { session_id: sid, outcome: "parked", queued_exchanges: Number(res.state.queued_exchanges) || 0 }
|
|
903
|
+
: { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error },
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
return results;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function actionStop(opts) {
|
|
910
|
+
const entries = scan();
|
|
911
|
+
const { entries: selected, errors } = selectEntries(entries, opts, null);
|
|
912
|
+
const procs = listCaptureProcesses();
|
|
913
|
+
const activeIds = selected
|
|
914
|
+
.filter((e) => e.ok && e.state.lifecycle === "active")
|
|
915
|
+
.map((e) => e.session_id);
|
|
916
|
+
const targets = procs.ok
|
|
917
|
+
? processesInScope(
|
|
918
|
+
procs.processes,
|
|
919
|
+
selected.map((e) => e.session_id),
|
|
920
|
+
opts,
|
|
921
|
+
activeIds,
|
|
922
|
+
)
|
|
923
|
+
: [];
|
|
924
|
+
// Stop processes before parking: a drain that is mid-flight would
|
|
925
|
+
// otherwise commit its own state over the park.
|
|
926
|
+
// An automatic repair run for this directory: the child of the timer that
|
|
927
|
+
// holds this directory's autofix.lock. Matching by name alone could stop
|
|
928
|
+
// another directory's run.
|
|
929
|
+
if (procs.ok && opts.sessions.length === 0) {
|
|
930
|
+
let lockPid = NaN;
|
|
931
|
+
let runPid = NaN;
|
|
932
|
+
try {
|
|
933
|
+
const lock = JSON.parse(fs.readFileSync(path.join(cs.watermarkDir(), "autofix.lock"), "utf8"));
|
|
934
|
+
lockPid = Number(lock.pid);
|
|
935
|
+
runPid = Number(lock.run_pid);
|
|
936
|
+
} catch {
|
|
937
|
+
lockPid = NaN;
|
|
938
|
+
runPid = NaN;
|
|
939
|
+
}
|
|
940
|
+
// A run outlives a timer that was killed, and is then reparented, so
|
|
941
|
+
// also match the run's own PID from the lock.
|
|
942
|
+
for (const p of procs.processes) {
|
|
943
|
+
if (p.kind === "autofix_run" && (p.ppid === lockPid || p.pid === runPid) && !targets.includes(p)) targets.push(p);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
// Stopping the automatic run's wrapper doesn't stop the capture.js child it
|
|
947
|
+
// is blocked on, so stop those children too and wait for them before
|
|
948
|
+
// parking.
|
|
949
|
+
const autofixPids = new Set(targets.filter((p) => p.kind === "autofix_run").map((p) => p.pid));
|
|
950
|
+
if (procs.ok) {
|
|
951
|
+
for (const p of procs.processes) {
|
|
952
|
+
if (p.kind.startsWith("capture_") && autofixPids.has(p.ppid) && !targets.includes(p)) targets.push(p);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
let killed = [];
|
|
956
|
+
if (!opts.dryRun) {
|
|
957
|
+
killed = killProcesses(targets);
|
|
958
|
+
// A kept timer whose child was just killed starts a recovery cycle at
|
|
959
|
+
// once. Stop any capture.js child that appeared in the meantime.
|
|
960
|
+
const again = listCaptureProcesses();
|
|
961
|
+
if (again.ok) {
|
|
962
|
+
const seen = new Set(killed.map((k) => k.pid));
|
|
963
|
+
const late = processesInScope(
|
|
964
|
+
again.processes,
|
|
965
|
+
selected.map((e) => e.session_id),
|
|
966
|
+
opts,
|
|
967
|
+
activeIds,
|
|
968
|
+
)
|
|
969
|
+
.concat(again.processes.filter((p) => p.kind.startsWith("capture_") && autofixPids.has(p.ppid)))
|
|
970
|
+
.filter((p, i, all) => p.kind.startsWith("capture_") && !seen.has(p.pid) && all.indexOf(p) === i);
|
|
971
|
+
killed = killed.concat(killProcesses(late));
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const parked = parkEntries(selected, "stop", opts.dryRun);
|
|
975
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
976
|
+
const partial = parked.some((r) => r.outcome === "busy" || r.outcome === "failed") ||
|
|
977
|
+
killed.some((k) => !k.exited);
|
|
978
|
+
return {
|
|
979
|
+
action: "stop",
|
|
980
|
+
dry_run: opts.dryRun,
|
|
981
|
+
processes: opts.dryRun ? targets : killed,
|
|
982
|
+
process_scan_error: procs.ok ? null : procs.error,
|
|
983
|
+
sessions: parked,
|
|
984
|
+
errors,
|
|
985
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function actionResume(opts) {
|
|
990
|
+
const entries = scan();
|
|
991
|
+
const { entries: selected, errors } = selectEntries(
|
|
992
|
+
entries,
|
|
993
|
+
opts,
|
|
994
|
+
(e) => e.ok && isParked(e.state),
|
|
995
|
+
);
|
|
996
|
+
const results = [];
|
|
997
|
+
for (const e of selected) {
|
|
998
|
+
const sid = e.session_id;
|
|
999
|
+
if (!e.ok || !isParked(e.state)) {
|
|
1000
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "not parked" });
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
if (opts.dryRun) {
|
|
1004
|
+
results.push({ session_id: sid, outcome: "would_resume" });
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
const res = mutate(sid, (state) => (isParked(state) ? unparkedState(state) : state));
|
|
1008
|
+
results.push(
|
|
1009
|
+
res.ok
|
|
1010
|
+
? { session_id: sid, outcome: "resumed", delivery: res.state.delivery }
|
|
1011
|
+
: { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error },
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
1015
|
+
const partial = results.some((r) => r.outcome === "busy" || r.outcome === "failed");
|
|
1016
|
+
return {
|
|
1017
|
+
action: "resume",
|
|
1018
|
+
dry_run: opts.dryRun,
|
|
1019
|
+
sessions: results,
|
|
1020
|
+
errors,
|
|
1021
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Drain one session in the foreground until it is empty, fails, or the
|
|
1027
|
+
* shared `ctx.budget` runs out. Unparks first and parks again on failure.
|
|
1028
|
+
* Returns { result, stop }: `stop` asks the caller not to attempt later
|
|
1029
|
+
* sessions, because the failure may be the server's.
|
|
1030
|
+
*/
|
|
1031
|
+
function drainOneSession(e, opts, ctx, options) {
|
|
1032
|
+
const sid = e.session_id;
|
|
1033
|
+
const log = ctx.log;
|
|
1034
|
+
// freshEntry() returns no state when the watermark can't be read; report
|
|
1035
|
+
// that session as failed instead of throwing and ending the whole run.
|
|
1036
|
+
if (!e.ok || !e.state) {
|
|
1037
|
+
return { result: { session_id: sid, outcome: "failed", reason: e.error || "could not read the session state" }, stop: false };
|
|
1038
|
+
}
|
|
1039
|
+
{
|
|
1040
|
+
// A caller that already changed the state (a move ends the park) passes
|
|
1041
|
+
// whether the session was parked before, so "a parked session stays
|
|
1042
|
+
// parked" still holds for partial and skipped drains.
|
|
1043
|
+
const wasParked =
|
|
1044
|
+
Boolean(options && options.wasParked) || isParked(e.state);
|
|
1045
|
+
let state = e.state;
|
|
1046
|
+
if (wasParked || state.failure_class) {
|
|
1047
|
+
const res = mutate(sid, (current) => {
|
|
1048
|
+
// An automatic run scanned before the user may have run `stop`;
|
|
1049
|
+
// check the current state under the lease, not the scan.
|
|
1050
|
+
if (opts.auto && stoppedByUser(current)) {
|
|
1051
|
+
return { ok: false, error: PAUSED_BY_USER };
|
|
1052
|
+
}
|
|
1053
|
+
return isParked(current) ? unparkedState(current) : handBackState(current);
|
|
1054
|
+
});
|
|
1055
|
+
if (!res.ok && res.error === PAUSED_BY_USER) {
|
|
1056
|
+
return { result: { session_id: sid, outcome: "paused_by_user" }, stop: false };
|
|
1057
|
+
}
|
|
1058
|
+
if (!res.ok) {
|
|
1059
|
+
return { result: { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error }, stop: false };
|
|
1060
|
+
}
|
|
1061
|
+
state = res.state;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const live = looksLive(state, transcriptInfo(state), ctx.nowMs);
|
|
1065
|
+
// Never rebucket from here: this process's cwd says nothing about the
|
|
1066
|
+
// session's owner. A session whose client is gone is finalized the way
|
|
1067
|
+
// timer recovery does it.
|
|
1068
|
+
const mode = live ? "drain" : "recover";
|
|
1069
|
+
let captured = 0;
|
|
1070
|
+
let last = null;
|
|
1071
|
+
let stderr = "";
|
|
1072
|
+
let leaseWaits = 0;
|
|
1073
|
+
let moves = 0;
|
|
1074
|
+
let stop = false;
|
|
1075
|
+
for (;;) {
|
|
1076
|
+
const run = runCaptureChild(
|
|
1077
|
+
mode,
|
|
1078
|
+
{ session_id: sid, recorded_owner_only: true, max_exchanges: Math.min(ctx.budget, cs.maxCaptureBatchSize()) },
|
|
1079
|
+
ctx.env,
|
|
1080
|
+
);
|
|
1081
|
+
last = run.result;
|
|
1082
|
+
stderr = run.stderr;
|
|
1083
|
+
if (heldByAnotherWorker(last) && leaseWaits < MAX_LEASE_WAITS) {
|
|
1084
|
+
leaseWaits++;
|
|
1085
|
+
// A hook (usually a kept timer's recovery cycle) is draining this
|
|
1086
|
+
// session. That isn't a failure: wait for it, then continue.
|
|
1087
|
+
log(` ${sid.slice(0, 8)}: another worker holds the lease; waiting`);
|
|
1088
|
+
const waitMs = (cs.leaseTtlSeconds() + cs.checkpointTimeout()) * 1000;
|
|
1089
|
+
if (waitForLease(sid, waitMs)) continue;
|
|
1090
|
+
}
|
|
1091
|
+
captured += Number(last.captured) || 0;
|
|
1092
|
+
ctx.budget -= Number(last.captured) || 0;
|
|
1093
|
+
const remaining = Number(last.queued_remaining) || 0;
|
|
1094
|
+
if (!drainSucceeded(last)) break;
|
|
1095
|
+
if (movedToNewConversation(last) && moves < MAX_MOVES_PER_DRAIN) {
|
|
1096
|
+
// capture.js moved the backlog to a new conversation (MEKO-723);
|
|
1097
|
+
// nothing was sent yet, so drain again into it.
|
|
1098
|
+
moves++;
|
|
1099
|
+
log(` ${sid.slice(0, 8)}: moved to a new conversation; continuing`);
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
if (remaining === 0 || !last.captured || ctx.budget <= 0) break;
|
|
1103
|
+
log(` ${sid.slice(0, 8)}: sent ${captured}, ${remaining} left`);
|
|
1104
|
+
sleepHoldingLease(sid, opts.paceMs);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const ok = drainSucceeded(last);
|
|
1108
|
+
const remaining = Number(last.queued_remaining) || 0;
|
|
1109
|
+
const repark = (reason) =>
|
|
1110
|
+
mutateWithRetry(sid, (current) =>
|
|
1111
|
+
isParked(current) ? current : parkedState(current, reason, new Date().toISOString()),
|
|
1112
|
+
);
|
|
1113
|
+
let outcome;
|
|
1114
|
+
let reparkError = null;
|
|
1115
|
+
if (!ok) {
|
|
1116
|
+
// Park it again so the hooks don't retry what just failed. A hook may
|
|
1117
|
+
// hold the lease right now (that is often why the drain failed), so
|
|
1118
|
+
// retry, and say so when the park doesn't land.
|
|
1119
|
+
const res = repark(`drain failed: ${last.error || last.status}`);
|
|
1120
|
+
stop = true;
|
|
1121
|
+
outcome = res.ok ? "failed_and_parked" : "failed_not_parked";
|
|
1122
|
+
if (!res.ok) reparkError = res.error;
|
|
1123
|
+
} else if (last.status === "noop" && last.error && captured === 0) {
|
|
1124
|
+
// For example "recorded owner missing": nothing was sent. A session
|
|
1125
|
+
// that was parked stays parked.
|
|
1126
|
+
if (wasParked) {
|
|
1127
|
+
const res = repark(`drain skipped: ${last.error}`);
|
|
1128
|
+
outcome = res.ok ? "skipped_and_parked" : "skipped_not_parked";
|
|
1129
|
+
if (!res.ok) reparkError = res.error;
|
|
1130
|
+
} else {
|
|
1131
|
+
outcome = "skipped";
|
|
1132
|
+
}
|
|
1133
|
+
} else if (remaining > 0) {
|
|
1134
|
+
// Out of --max-exchanges. A session that was parked stays parked.
|
|
1135
|
+
if (wasParked) {
|
|
1136
|
+
const res = repark("drain stopped at --max-exchanges");
|
|
1137
|
+
outcome = res.ok ? "partly_drained_and_parked" : "partly_drained_not_parked";
|
|
1138
|
+
if (!res.ok) reparkError = res.error;
|
|
1139
|
+
} else {
|
|
1140
|
+
outcome = "partly_drained";
|
|
1141
|
+
}
|
|
1142
|
+
} else {
|
|
1143
|
+
outcome = "drained";
|
|
1144
|
+
}
|
|
1145
|
+
return {
|
|
1146
|
+
stop,
|
|
1147
|
+
result: {
|
|
1148
|
+
session_id: sid,
|
|
1149
|
+
outcome,
|
|
1150
|
+
captured,
|
|
1151
|
+
queued_remaining: last.queued_remaining,
|
|
1152
|
+
status: last.status,
|
|
1153
|
+
error: last.error || null,
|
|
1154
|
+
repark_error: reparkError || undefined,
|
|
1155
|
+
stderr: ok ? undefined : stderr || undefined,
|
|
1156
|
+
},
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function actionDrain(opts, log) {
|
|
1162
|
+
if (opts.sessions.length === 0 && !opts.all) {
|
|
1163
|
+
return { action: "drain", errors: ["drain needs --session <id> or --all"], exit: 1 };
|
|
1164
|
+
}
|
|
1165
|
+
const { env, source } = resolveCredentials();
|
|
1166
|
+
if (!env.MEKO_MCP_URL) {
|
|
1167
|
+
return {
|
|
1168
|
+
action: "drain",
|
|
1169
|
+
errors: [
|
|
1170
|
+
"MEKO_MCP_URL is not set and ~/.claude/settings.json has no env.MEKO_MCP_URL; " +
|
|
1171
|
+
"export MEKO_MCP_URL and MEKO_API_KEY, then retry",
|
|
1172
|
+
],
|
|
1173
|
+
exit: 1,
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
const nowMs = Date.now();
|
|
1177
|
+
const entries = scan();
|
|
1178
|
+
const { entries: selected, errors } = selectEntries(entries, opts, null);
|
|
1179
|
+
const targets = selected.filter((e) => e.ok && hasPendingWork(e.state));
|
|
1180
|
+
const results = [];
|
|
1181
|
+
// Say why a session named on the command line isn't drained, instead of
|
|
1182
|
+
// "nothing to do".
|
|
1183
|
+
if (opts.sessions.length > 0) {
|
|
1184
|
+
for (const e of selected) {
|
|
1185
|
+
if (targets.includes(e)) continue;
|
|
1186
|
+
let reason = "nothing to send";
|
|
1187
|
+
if (!e.ok) reason = "watermark unreadable";
|
|
1188
|
+
else if (hasStaleFailure(e.state)) {
|
|
1189
|
+
reason =
|
|
1190
|
+
"nothing to send; only leftover failure fields remain. Run `--capture-cleanup repair` to clear them";
|
|
1191
|
+
}
|
|
1192
|
+
results.push({ session_id: e.session_id, outcome: "skipped", reason });
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const ctx = { env, log, nowMs, budget: opts.maxExchanges };
|
|
1196
|
+
let stopped = null;
|
|
1197
|
+
|
|
1198
|
+
for (const e of targets) {
|
|
1199
|
+
const sid = e.session_id;
|
|
1200
|
+
if (stopped) {
|
|
1201
|
+
results.push({ session_id: sid, outcome: "not_attempted" });
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
if (ctx.budget <= 0) {
|
|
1205
|
+
results.push({ session_id: sid, outcome: "not_attempted", reason: "--max-exchanges reached" });
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
if (opts.dryRun) {
|
|
1209
|
+
results.push({ session_id: sid, outcome: "would_drain", queued_exchanges: Number(e.state.queued_exchanges) || 0 });
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
const { result, stop } = drainOneSession(e, opts, ctx);
|
|
1213
|
+
results.push(result);
|
|
1214
|
+
if (stop) stopped = sid;
|
|
1215
|
+
const outcome = result.outcome;
|
|
1216
|
+
if (!["drained", "skipped", "skipped_and_parked"].includes(outcome)) continue;
|
|
1217
|
+
if (ctx.budget > 0) sleepMs(opts.paceMs);
|
|
1218
|
+
}
|
|
1219
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
1220
|
+
const partial =
|
|
1221
|
+
Boolean(stopped) ||
|
|
1222
|
+
results.some((r) =>
|
|
1223
|
+
[
|
|
1224
|
+
"busy",
|
|
1225
|
+
"failed",
|
|
1226
|
+
"not_attempted",
|
|
1227
|
+
"skipped",
|
|
1228
|
+
"partly_drained",
|
|
1229
|
+
"partly_drained_and_parked",
|
|
1230
|
+
"partly_drained_not_parked",
|
|
1231
|
+
"failed_not_parked",
|
|
1232
|
+
"skipped_and_parked",
|
|
1233
|
+
"skipped_not_parked",
|
|
1234
|
+
].includes(r.outcome),
|
|
1235
|
+
);
|
|
1236
|
+
return {
|
|
1237
|
+
action: "drain",
|
|
1238
|
+
dry_run: opts.dryRun,
|
|
1239
|
+
credentials_from: source,
|
|
1240
|
+
mcp_url: env.MEKO_MCP_URL,
|
|
1241
|
+
stopped_at: stopped,
|
|
1242
|
+
sessions: results,
|
|
1243
|
+
errors,
|
|
1244
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function actionDiscard(opts) {
|
|
1249
|
+
if (opts.sessions.length === 0 && !opts.all) {
|
|
1250
|
+
return { action: "discard", errors: ["discard needs --session <id> or --all"], exit: 1 };
|
|
1251
|
+
}
|
|
1252
|
+
if (!opts.yes && !opts.dryRun) {
|
|
1253
|
+
return {
|
|
1254
|
+
action: "discard",
|
|
1255
|
+
errors: ["discard never sends the skipped exchanges; rerun with --yes to confirm, or --dry-run to preview"],
|
|
1256
|
+
exit: 1,
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
const nowMs = Date.now();
|
|
1260
|
+
const entries = scan();
|
|
1261
|
+
const { entries: selected, errors } = selectEntries(entries, opts, null);
|
|
1262
|
+
const results = [];
|
|
1263
|
+
for (const e of selected) {
|
|
1264
|
+
const sid = e.session_id;
|
|
1265
|
+
if (!e.ok) {
|
|
1266
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "watermark unreadable" });
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
if (!hasPendingWork(e.state) && !isParked(e.state)) {
|
|
1270
|
+
results.push({ session_id: sid, outcome: "skipped", reason: "nothing to send" });
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
const info = transcriptInfo(e.state);
|
|
1274
|
+
if (recentlyWritten(info, nowMs) || timerAlive(sid)) {
|
|
1275
|
+
results.push({
|
|
1276
|
+
session_id: sid,
|
|
1277
|
+
outcome: "skipped",
|
|
1278
|
+
reason: "transcript changed recently or its timer is running; the session may still be open",
|
|
1279
|
+
});
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
const queued = Number(e.state.queued_exchanges) || 0;
|
|
1283
|
+
if (opts.dryRun) {
|
|
1284
|
+
results.push({ session_id: sid, outcome: "would_discard", queued_exchanges: queued });
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
let lineCount = 0;
|
|
1288
|
+
if (info.exists) {
|
|
1289
|
+
try {
|
|
1290
|
+
lineCount = countLines(e.state.transcript_path);
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
results.push({ session_id: sid, outcome: "failed", reason: `read transcript: ${err.message}` });
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
const res = mutate(sid, (state) => discardedState(state, lineCount));
|
|
1297
|
+
results.push(
|
|
1298
|
+
res.ok
|
|
1299
|
+
? { session_id: sid, outcome: "discarded", queued_exchanges: queued }
|
|
1300
|
+
: { session_id: sid, outcome: res.busy ? "busy" : "failed", reason: res.error },
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
1304
|
+
const partial = results.some((r) => r.outcome === "busy" || r.outcome === "failed");
|
|
1305
|
+
return {
|
|
1306
|
+
action: "discard",
|
|
1307
|
+
dry_run: opts.dryRun,
|
|
1308
|
+
sessions: results,
|
|
1309
|
+
errors,
|
|
1310
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// ---------------------------------------------------------------------------
|
|
1315
|
+
// Diagnose and fix
|
|
1316
|
+
// ---------------------------------------------------------------------------
|
|
1317
|
+
|
|
1318
|
+
function lastErrorOf(state) {
|
|
1319
|
+
const previous = isParked(state) && state.parked.previous;
|
|
1320
|
+
return String((previous && previous.last_error) || state.last_error || "");
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* A failure that says the server, not this session, is in trouble. An error
|
|
1325
|
+
* about this session's conversation ("Could not look up conversation <id>:
|
|
1326
|
+
* timed out") is about the conversation, even when it mentions a timeout.
|
|
1327
|
+
*/
|
|
1328
|
+
function serverLevelFailure(error) {
|
|
1329
|
+
if (conversationUnusable(error)) return false;
|
|
1330
|
+
return /timed out|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|socket hang up|connection refused|MCP HTTP 5\d\d/i.test(
|
|
1331
|
+
String(error || ""),
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Errors that mean the recorded conversation itself can't be used. */
|
|
1336
|
+
function conversationUnusable(error) {
|
|
1337
|
+
return /agent_id_mismatch|lookup_failed|conversation\s+\S+\s+not\s+found|"not_found"/i.test(
|
|
1338
|
+
String(error || ""),
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Causes only the user can fix. Matches the server's error codes and the
|
|
1344
|
+
* reasons capture.js stores in last_error (classifyPersistentCaptureFailure),
|
|
1345
|
+
* for example "the account does not have access to the target datapack".
|
|
1346
|
+
* Returns { cause, hint } or null.
|
|
1347
|
+
*/
|
|
1348
|
+
function userActionableCause(error) {
|
|
1349
|
+
const err = String(error || "");
|
|
1350
|
+
if (/\b401\b|unauthori[sz]ed|invalid[_\s-]?api[_\s-]?key/i.test(err)) {
|
|
1351
|
+
return {
|
|
1352
|
+
cause: "auth",
|
|
1353
|
+
hint: "the server rejected the API key; fix MEKO_API_KEY, then run fix again",
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
if (
|
|
1357
|
+
/datapack_id_required|datapack_access_denied|read_only_access|access to the target datapack|datapack[^"]*(denied|missing|invalid|mismatch)/i.test(
|
|
1358
|
+
err,
|
|
1359
|
+
)
|
|
1360
|
+
) {
|
|
1361
|
+
return {
|
|
1362
|
+
cause: "datapack",
|
|
1363
|
+
hint: "this account can't write to the session's datapack; pin another datapack with meko-select-datapack or get write access, then run fix again",
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
if (
|
|
1367
|
+
/\b402\b|payment|quota|free[_\s-]?tier|subscription|entitle|plan[_\s-]?limit|plan's capture limit|usage[_\s-]?limit|capture usage limit/i.test(
|
|
1368
|
+
err,
|
|
1369
|
+
)
|
|
1370
|
+
) {
|
|
1371
|
+
return {
|
|
1372
|
+
cause: "account_limit",
|
|
1373
|
+
hint: "the account hit a plan or capture limit; upgrade or wait, then run fix again",
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
if (/Meko rejected capture with HTTP 403/i.test(err)) {
|
|
1377
|
+
return {
|
|
1378
|
+
cause: "forbidden",
|
|
1379
|
+
hint: "the server refused capture (HTTP 403); check the account's access, then run fix again",
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Why a session is stuck and what fixes it. Returns null for a session with
|
|
1387
|
+
* nothing to fix. Actions: repair, drain, move (to a new conversation, then
|
|
1388
|
+
* drain), drain_then_move, retry_once (a cause only the user can fix: one
|
|
1389
|
+
* attempt per run, then report), or none (an unreadable watermark).
|
|
1390
|
+
*/
|
|
1391
|
+
function diagnose(entry) {
|
|
1392
|
+
if (!entry.ok) {
|
|
1393
|
+
return {
|
|
1394
|
+
cause: "unreadable",
|
|
1395
|
+
action: "none",
|
|
1396
|
+
hint: "the watermark can't be read; it is left on disk for inspection",
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
const s = entry.state;
|
|
1400
|
+
if (hasStaleFailure(s)) {
|
|
1401
|
+
return {
|
|
1402
|
+
cause: "stale_failure",
|
|
1403
|
+
action: "repair",
|
|
1404
|
+
hint: "everything was delivered; only leftover failure fields remain",
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
if (!hasPendingWork(s) && !isParked(s)) return null;
|
|
1408
|
+
const err = lastErrorOf(s);
|
|
1409
|
+
const epoch = Number(s.conversation_epoch) || 0;
|
|
1410
|
+
const userCause = userActionableCause(err);
|
|
1411
|
+
if (userCause) {
|
|
1412
|
+
// Try once per run: the user usually runs fix right after changing the
|
|
1413
|
+
// key, plan or access. If it fails the same way, fix reports needs_you
|
|
1414
|
+
// and the session stays parked, so the hooks don't retry it.
|
|
1415
|
+
return { ...userCause, action: "retry_once" };
|
|
1416
|
+
}
|
|
1417
|
+
// A session that was just moved to a new conversation waits for that
|
|
1418
|
+
// conversation to accept a turn before it can be moved again (the guard
|
|
1419
|
+
// from MEKO-723). Moving again would recreate the replay loop.
|
|
1420
|
+
if (s.recovered_from_conversation_id) {
|
|
1421
|
+
return {
|
|
1422
|
+
cause: "awaiting_new_conversation",
|
|
1423
|
+
action: "drain",
|
|
1424
|
+
hint: "already moved to a new conversation that hasn't accepted a turn yet",
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
if (/agent_id_mismatch/i.test(err)) {
|
|
1428
|
+
return {
|
|
1429
|
+
cause: "owner_lost",
|
|
1430
|
+
action: "move",
|
|
1431
|
+
hint: "the server no longer recognizes this conversation's owner",
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
// conversation_epoch only goes up over a session's life, so count the
|
|
1435
|
+
// epochs since this tool last moved the session, and only when a failure
|
|
1436
|
+
// is recorded; a healthy session moved off a high epoch stays put.
|
|
1437
|
+
const epochsSinceMove = epoch - (Number(s.cleanup_moved_epoch) || 0);
|
|
1438
|
+
if (err && epochsSinceMove >= REPLAY_EPOCH_THRESHOLD) {
|
|
1439
|
+
return {
|
|
1440
|
+
cause: "replay_loop",
|
|
1441
|
+
action: "move",
|
|
1442
|
+
hint: `the conversation was reopened ${epochsSinceMove} times; its trace is likely too large to serve`,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
if (/lookup_failed/i.test(err)) {
|
|
1446
|
+
return {
|
|
1447
|
+
cause: "lookup_failing",
|
|
1448
|
+
action: "drain_then_move",
|
|
1449
|
+
hint: "the server couldn't look the conversation up",
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
if (/conversation\s+\S+\s+not\s+found|"not_found"/i.test(err)) {
|
|
1453
|
+
return {
|
|
1454
|
+
cause: "conversation_missing",
|
|
1455
|
+
action: "drain_then_move",
|
|
1456
|
+
hint: "the server answered not_found for the conversation",
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
return { cause: "retry", action: "drain", hint: err ? `last error: ${err.slice(0, 120)}` : "unsent turns are waiting" };
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Run `capture.js move` for one session. `expectedConversation` is the
|
|
1464
|
+
* conversation fix diagnosed; the move is refused (`changed`) if the session
|
|
1465
|
+
* no longer points at it when the lease is taken.
|
|
1466
|
+
*/
|
|
1467
|
+
function moveSession(sid, ctx, expectedConversation) {
|
|
1468
|
+
const run = runCaptureChild(
|
|
1469
|
+
"move",
|
|
1470
|
+
{
|
|
1471
|
+
session_id: sid,
|
|
1472
|
+
expected_conversation_id: expectedConversation || undefined,
|
|
1473
|
+
respect_stop: ctx.auto ? true : undefined,
|
|
1474
|
+
defer_if_live: true,
|
|
1475
|
+
},
|
|
1476
|
+
ctx.env,
|
|
1477
|
+
);
|
|
1478
|
+
const r = run.result || {};
|
|
1479
|
+
if (r.ok) return { ok: true, from: r.from, to: r.to };
|
|
1480
|
+
return {
|
|
1481
|
+
ok: false,
|
|
1482
|
+
busy: Boolean(r.busy),
|
|
1483
|
+
changed: Boolean(r.changed),
|
|
1484
|
+
paused: Boolean(r.paused),
|
|
1485
|
+
deferred: Boolean(r.deferred),
|
|
1486
|
+
error: r.error || run.stderr || "move failed",
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function freshEntry(sid) {
|
|
1491
|
+
const read = cs.readState(sid);
|
|
1492
|
+
return read.ok
|
|
1493
|
+
? { ok: true, session_id: sid, state: read.state }
|
|
1494
|
+
: { ok: false, session_id: sid, error: read.error };
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Move to a new conversation, then drain. The move itself ends a park (it
|
|
1499
|
+
* resets the retry fields), so nothing is unparked first. A busy move, or
|
|
1500
|
+
* one refused because the session changed while waiting, leaves the
|
|
1501
|
+
* watermark untouched; a move that fails is parked with its diagnosed error.
|
|
1502
|
+
* `wasParked` is the session's parked status before fix touched it.
|
|
1503
|
+
*/
|
|
1504
|
+
function moveThenDrain(e, opts, ctx, wasParkedBefore) {
|
|
1505
|
+
const sid = e.session_id;
|
|
1506
|
+
const wasParked = wasParkedBefore != null ? Boolean(wasParkedBefore) : isParked(e.state);
|
|
1507
|
+
const diagnosedError = lastErrorOf(e.state);
|
|
1508
|
+
const expected = e.state.conversation_id;
|
|
1509
|
+
let moved = moveSession(sid, ctx, expected);
|
|
1510
|
+
for (let waits = 0; !moved.ok && moved.busy && waits < MAX_LEASE_WAITS; waits++) {
|
|
1511
|
+
// A hook is draining this session; wait for it rather than give up.
|
|
1512
|
+
ctx.log(` ${sid.slice(0, 8)}: another worker holds the lease; waiting`);
|
|
1513
|
+
const waitMs = (cs.leaseTtlSeconds() + cs.checkpointTimeout()) * 1000;
|
|
1514
|
+
if (!waitForLease(sid, waitMs)) break;
|
|
1515
|
+
moved = moveSession(sid, ctx, expected);
|
|
1516
|
+
}
|
|
1517
|
+
if (!moved.ok && moved.busy) {
|
|
1518
|
+
return { result: { session_id: sid, outcome: "busy", reason: moved.error }, stop: false };
|
|
1519
|
+
}
|
|
1520
|
+
if (!moved.ok && moved.paused) {
|
|
1521
|
+
return { result: { session_id: sid, outcome: "paused_by_user" }, stop: false };
|
|
1522
|
+
}
|
|
1523
|
+
if (!moved.ok && moved.deferred) {
|
|
1524
|
+
// The session became live after fix planned the move; capture.js
|
|
1525
|
+
// refused it under the lease. A later fix moves it after it ends.
|
|
1526
|
+
return {
|
|
1527
|
+
result: { session_id: sid, outcome: "move_deferred", reason: "the session is still open; fix moves it to a new conversation after the session ends" },
|
|
1528
|
+
stop: false,
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
if (!moved.ok && moved.changed) {
|
|
1532
|
+
// A hook recovered or delivered the session while fix waited for the
|
|
1533
|
+
// lease. Don't create another conversation; drain what is there now.
|
|
1534
|
+
ctx.log(` ${sid.slice(0, 8)}: ${moved.error}; draining instead of moving`);
|
|
1535
|
+
const drained = drainOneSession(freshEntry(sid), opts, ctx, { wasParked });
|
|
1536
|
+
drained.result.move_skipped = moved.error;
|
|
1537
|
+
return drained;
|
|
1538
|
+
}
|
|
1539
|
+
if (!moved.ok) {
|
|
1540
|
+
// Keep the diagnosed error, so the next fix still sees why the session
|
|
1541
|
+
// needs a move.
|
|
1542
|
+
const parked = mutateWithRetry(sid, (current) =>
|
|
1543
|
+
isParked(current)
|
|
1544
|
+
? current
|
|
1545
|
+
: parkedState(
|
|
1546
|
+
{ ...current, last_error: current.last_error || diagnosedError || null },
|
|
1547
|
+
`move failed: ${moved.error}`,
|
|
1548
|
+
new Date().toISOString(),
|
|
1549
|
+
),
|
|
1550
|
+
);
|
|
1551
|
+
return {
|
|
1552
|
+
result: {
|
|
1553
|
+
session_id: sid,
|
|
1554
|
+
outcome: parked.ok ? "move_failed_and_parked" : "move_failed_not_parked",
|
|
1555
|
+
error: moved.error,
|
|
1556
|
+
},
|
|
1557
|
+
stop: serverLevelFailure(moved.error),
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
ctx.log(` ${sid.slice(0, 8)}: moved to new conversation ${moved.to}`);
|
|
1561
|
+
const drained = drainOneSession(freshEntry(sid), opts, ctx, { wasParked });
|
|
1562
|
+
drained.result.moved_from = moved.from;
|
|
1563
|
+
drained.result.moved_to = moved.to;
|
|
1564
|
+
return drained;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* The datapack an agent's pin file names, read the way capture.js
|
|
1569
|
+
* readDatapackPin() does (pin-<slug>.json in the capture directory), or null.
|
|
1570
|
+
*/
|
|
1571
|
+
function pinnedDatapackId(agentId) {
|
|
1572
|
+
if (!agentId) return null;
|
|
1573
|
+
try {
|
|
1574
|
+
const pin = JSON.parse(
|
|
1575
|
+
fs.readFileSync(path.join(cs.watermarkDir(), `pin-${cs.agentSlug(agentId)}.json`), "utf8"),
|
|
1576
|
+
);
|
|
1577
|
+
const id = typeof pin.datapack_id === "string" ? pin.datapack_id.trim() : "";
|
|
1578
|
+
const name = typeof pin.datapack_name === "string" ? pin.datapack_name.trim() : "";
|
|
1579
|
+
return id && name ? id : null;
|
|
1580
|
+
} catch {
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Record what a fix run did in autofix-last.json. The status bar and
|
|
1587
|
+
* `status` read it, and the checkpoint timer uses ran_at to schedule the
|
|
1588
|
+
* next automatic run.
|
|
1589
|
+
*/
|
|
1590
|
+
function writeFixSummary(opts, results, stopped, error) {
|
|
1591
|
+
const counts = { fixed: 0, moved: 0, needs_you: 0, paused: 0, failed: 0, not_attempted: 0, partial: 0 };
|
|
1592
|
+
const needs = new Map();
|
|
1593
|
+
for (const r of results) {
|
|
1594
|
+
const o = String(r.outcome || "");
|
|
1595
|
+
if (o === "drained" || o === "repaired") counts.fixed++;
|
|
1596
|
+
if (r.moved_to) counts.moved++;
|
|
1597
|
+
if (o === "needs_you") {
|
|
1598
|
+
counts.needs_you++;
|
|
1599
|
+
const n = needs.get(r.cause) || { cause: r.cause, hint: r.hint, sessions: 0 };
|
|
1600
|
+
n.sessions++;
|
|
1601
|
+
needs.set(r.cause, n);
|
|
1602
|
+
}
|
|
1603
|
+
if (o === "paused_by_user") counts.paused++;
|
|
1604
|
+
if (o.includes("failed")) counts.failed++;
|
|
1605
|
+
if (o === "not_attempted" || o === "busy" || o === "move_deferred") counts.not_attempted++;
|
|
1606
|
+
if (o.startsWith("partly_drained")) counts.partial++;
|
|
1607
|
+
}
|
|
1608
|
+
try {
|
|
1609
|
+
cs.atomicWriteJson(cs.autofixLastPath(), {
|
|
1610
|
+
schema_version: 1,
|
|
1611
|
+
ran_at: new Date().toISOString(),
|
|
1612
|
+
mode: opts.auto ? "auto" : "manual",
|
|
1613
|
+
error: error || null,
|
|
1614
|
+
stopped_at: stopped || null,
|
|
1615
|
+
counts,
|
|
1616
|
+
needs_you: [...needs.values()],
|
|
1617
|
+
});
|
|
1618
|
+
} catch {
|
|
1619
|
+
/* the summary is informational; the run itself already happened */
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function actionFix(opts, log) {
|
|
1624
|
+
const entries = scan();
|
|
1625
|
+
const { entries: selected, errors } = selectEntries(entries, opts, null);
|
|
1626
|
+
const plan = [];
|
|
1627
|
+
for (const e of selected) {
|
|
1628
|
+
const d = diagnose(e);
|
|
1629
|
+
if (d) plan.push({ entry: e, diagnosis: d });
|
|
1630
|
+
else if (opts.sessions.length > 0) {
|
|
1631
|
+
plan.push({ entry: e, diagnosis: null });
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
const needsServer = plan.some(
|
|
1635
|
+
(p) => p.diagnosis && ["drain", "move", "drain_then_move", "retry_once"].includes(p.diagnosis.action),
|
|
1636
|
+
);
|
|
1637
|
+
const { env, source } = resolveCredentials();
|
|
1638
|
+
if (needsServer && !env.MEKO_MCP_URL && !opts.dryRun) {
|
|
1639
|
+
const message =
|
|
1640
|
+
"MEKO_MCP_URL is not set and ~/.claude/settings.json has no env.MEKO_MCP_URL; " +
|
|
1641
|
+
"export MEKO_MCP_URL and MEKO_API_KEY, then retry";
|
|
1642
|
+
writeFixSummary(opts, [], null, message);
|
|
1643
|
+
refreshHealthCache();
|
|
1644
|
+
return { action: "fix", errors: [message], exit: 1 };
|
|
1645
|
+
}
|
|
1646
|
+
const ctx = { env, log, nowMs: Date.now(), budget: opts.maxExchanges, auto: opts.auto };
|
|
1647
|
+
const results = [];
|
|
1648
|
+
let stopped = null;
|
|
1649
|
+
// Causes only the user can fix, found earlier in this run. A rejected key,
|
|
1650
|
+
// a plan limit or an account-level 403 affects every session; a datapack
|
|
1651
|
+
// problem affects the sessions in that datapack. Trying the rest would
|
|
1652
|
+
// only repeat the same failure.
|
|
1653
|
+
const blockedBy = new Map();
|
|
1654
|
+
// A session with no datapack in its watermark drains to its agent's pin
|
|
1655
|
+
// (capture.js re-reads it), so key it by that pinned datapack. Only an
|
|
1656
|
+
// agent with no pin falls back to a per-agent key.
|
|
1657
|
+
const datapackKey = (state) => {
|
|
1658
|
+
const id = (state && state.datapack_id) || pinnedDatapackId(state && state.agent_id);
|
|
1659
|
+
return id ? `datapack:${id}` : `datapack-of-agent:${(state && state.agent_id) || ""}`;
|
|
1660
|
+
};
|
|
1661
|
+
const blockKeys = (state, cause) => (cause === "datapack" ? [datapackKey(state)] : ["account"]);
|
|
1662
|
+
const blockedFor = (state) => blockedBy.get("account") || blockedBy.get(datapackKey(state)) || null;
|
|
1663
|
+
|
|
1664
|
+
for (const { entry: e, diagnosis: d } of plan) {
|
|
1665
|
+
const sid = e.session_id;
|
|
1666
|
+
if (!d) {
|
|
1667
|
+
results.push({ session_id: sid, outcome: "nothing_to_fix" });
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
const base = { session_id: sid, cause: d.cause, action: d.action, hint: d.hint };
|
|
1671
|
+
if (
|
|
1672
|
+
opts.auto &&
|
|
1673
|
+
e.ok &&
|
|
1674
|
+
isParked(e.state) &&
|
|
1675
|
+
e.state.parked.reason === "stop"
|
|
1676
|
+
) {
|
|
1677
|
+
// The user paused this session with `stop`; an automatic run leaves
|
|
1678
|
+
// it alone until they run `fix` or `resume`.
|
|
1679
|
+
results.push({ ...base, outcome: "paused_by_user" });
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
if (opts.dryRun) {
|
|
1683
|
+
results.push({ ...base, outcome: "would_fix" });
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
if (d.action === "none") {
|
|
1687
|
+
results.push({ ...base, outcome: "needs_you" });
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
if (d.action === "repair") {
|
|
1691
|
+
const res = mutate(sid, (state) => (hasStaleFailure(state) ? clearedFailureState(state) : state));
|
|
1692
|
+
results.push({ ...base, outcome: res.ok ? "repaired" : res.busy ? "busy" : "failed", reason: res.ok ? undefined : res.error });
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
if (stopped) {
|
|
1696
|
+
results.push({ ...base, outcome: "not_attempted", reason: `stopped at ${stopped.slice(0, 8)}: the server is failing` });
|
|
1697
|
+
continue;
|
|
1698
|
+
}
|
|
1699
|
+
if (ctx.budget <= 0) {
|
|
1700
|
+
results.push({ ...base, outcome: "not_attempted", reason: "--max-exchanges reached" });
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
const blocked = e.ok && blockedFor(e.state);
|
|
1704
|
+
if (blocked) {
|
|
1705
|
+
// Park it with the shared cause, without another request, so its
|
|
1706
|
+
// hooks stop sending the same rejected key or limit too. The next fix
|
|
1707
|
+
// retries one session per cause.
|
|
1708
|
+
const parked = mutateWithRetry(sid, (current) => {
|
|
1709
|
+
if (opts.auto && stoppedByUser(current)) return current;
|
|
1710
|
+
return isParked(current)
|
|
1711
|
+
? current
|
|
1712
|
+
: parkedState(
|
|
1713
|
+
{ ...current, last_error: blocked.error || current.last_error || null },
|
|
1714
|
+
`${blocked.cause}: same cause as another session`,
|
|
1715
|
+
new Date().toISOString(),
|
|
1716
|
+
);
|
|
1717
|
+
});
|
|
1718
|
+
results.push({
|
|
1719
|
+
...base,
|
|
1720
|
+
cause: blocked.cause,
|
|
1721
|
+
hint: blocked.hint,
|
|
1722
|
+
outcome: parked.ok ? "needs_you" : "needs_you_not_parked",
|
|
1723
|
+
reason: "not tried: another session hit the same cause in this run",
|
|
1724
|
+
});
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
let run;
|
|
1729
|
+
const wasParkedBefore = isParked(e.state);
|
|
1730
|
+
// SessionStart gave a live session's agent its conversation_id, and a
|
|
1731
|
+
// move can't tell the agent the new one, so its tool calls would stay on
|
|
1732
|
+
// the old conversation while captured turns go to the new one. Wait until
|
|
1733
|
+
// the session ends; a later fix moves it.
|
|
1734
|
+
const deferMove = looksLive(e.state, transcriptInfo(e.state), ctx.nowMs);
|
|
1735
|
+
if (d.action === "move" && deferMove) {
|
|
1736
|
+
results.push({ ...base, outcome: "move_deferred", reason: "the session is still open; fix moves it to a new conversation after the session ends" });
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
if (d.action === "move") {
|
|
1740
|
+
run = moveThenDrain(e, opts, ctx, wasParkedBefore);
|
|
1741
|
+
} else {
|
|
1742
|
+
const diagnosedConversation = e.state.conversation_id;
|
|
1743
|
+
run = drainOneSession(e, opts, ctx);
|
|
1744
|
+
const err = run.result.error;
|
|
1745
|
+
if (
|
|
1746
|
+
d.action === "drain_then_move" &&
|
|
1747
|
+
!["drained", "skipped"].includes(run.result.outcome) &&
|
|
1748
|
+
!serverLevelFailure(err) &&
|
|
1749
|
+
conversationUnusable(err)
|
|
1750
|
+
) {
|
|
1751
|
+
// The same conversation error came back. Move only if it is still
|
|
1752
|
+
// about the conversation diagnosed above and no move is waiting for
|
|
1753
|
+
// its new conversation to accept a turn; capture.js may already have
|
|
1754
|
+
// moved the session during this drain.
|
|
1755
|
+
const now = freshEntry(sid);
|
|
1756
|
+
const sameConversation = now.ok && now.state.conversation_id === diagnosedConversation;
|
|
1757
|
+
const guarded = now.ok && Boolean(now.state.recovered_from_conversation_id);
|
|
1758
|
+
if (sameConversation && !guarded && deferMove) {
|
|
1759
|
+
log(` ${sid.slice(0, 8)}: ${String(err).slice(0, 80)}; the session is still open, so the move waits until it ends`);
|
|
1760
|
+
run = { ...run, result: { ...run.result, move_deferred: true } };
|
|
1761
|
+
} else if (sameConversation && !guarded) {
|
|
1762
|
+
log(` ${sid.slice(0, 8)}: ${String(err).slice(0, 80)}; moving to a new conversation`);
|
|
1763
|
+
// The failed drain just re-parked the session; keep its status
|
|
1764
|
+
// from before fix ran.
|
|
1765
|
+
run = moveThenDrain(now, opts, ctx, wasParkedBefore);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const userCause = run.result.outcome !== "drained" && userActionableCause(run.result.error);
|
|
1770
|
+
if (userCause) {
|
|
1771
|
+
// The drain found a cause only the user can fix (it may differ from the
|
|
1772
|
+
// stored error). When the session is parked the hooks won't retry it,
|
|
1773
|
+
// and the next fix tries it once more. When the park didn't land, keep
|
|
1774
|
+
// the outcome that says so.
|
|
1775
|
+
const parkedNow = /_and_parked$/.test(String(run.result.outcome));
|
|
1776
|
+
for (const key of blockKeys(e.state, userCause.cause)) {
|
|
1777
|
+
if (!blockedBy.has(key)) blockedBy.set(key, { ...userCause, error: run.result.error });
|
|
1778
|
+
}
|
|
1779
|
+
results.push({
|
|
1780
|
+
...base,
|
|
1781
|
+
...run.result,
|
|
1782
|
+
...userCause,
|
|
1783
|
+
outcome: parkedNow ? "needs_you" : run.result.outcome,
|
|
1784
|
+
});
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1787
|
+
results.push({ ...base, ...run.result });
|
|
1788
|
+
if (run.stop && serverLevelFailure(run.result.error)) stopped = sid;
|
|
1789
|
+
if (ctx.budget > 0) sleepMs(opts.paceMs);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
if (!opts.dryRun) {
|
|
1793
|
+
writeFixSummary(opts, results, stopped);
|
|
1794
|
+
refreshHealthCache();
|
|
1795
|
+
}
|
|
1796
|
+
const done = ["repaired", "drained", "nothing_to_fix", "would_fix", "paused_by_user"];
|
|
1797
|
+
const partial = results.some((r) => !done.includes(r.outcome));
|
|
1798
|
+
return {
|
|
1799
|
+
action: "fix",
|
|
1800
|
+
dry_run: opts.dryRun,
|
|
1801
|
+
credentials_from: needsServer ? source : undefined,
|
|
1802
|
+
mcp_url: needsServer ? env.MEKO_MCP_URL : undefined,
|
|
1803
|
+
stopped_at: stopped,
|
|
1804
|
+
sessions: results,
|
|
1805
|
+
errors,
|
|
1806
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function readPidFile(filePath) {
|
|
1811
|
+
try {
|
|
1812
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
1813
|
+
if (/^\d+$/.test(raw)) return Number(raw);
|
|
1814
|
+
const obj = JSON.parse(raw);
|
|
1815
|
+
return Number(obj && obj.pid);
|
|
1816
|
+
} catch {
|
|
1817
|
+
return NaN;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function actionRepair(opts) {
|
|
1822
|
+
const dir = cs.watermarkDir();
|
|
1823
|
+
const entries = scan();
|
|
1824
|
+
const { entries: selected, errors } = selectEntries(entries, opts, null);
|
|
1825
|
+
const sessions = [];
|
|
1826
|
+
for (const e of selected) {
|
|
1827
|
+
if (!e.ok || !hasStaleFailure(e.state)) continue;
|
|
1828
|
+
if (opts.dryRun) {
|
|
1829
|
+
sessions.push({ session_id: e.session_id, outcome: "would_clear_failure", failure_class: e.state.failure_class });
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
const res = mutate(e.session_id, (state) =>
|
|
1833
|
+
hasStaleFailure(state) ? clearedFailureState(state) : state,
|
|
1834
|
+
);
|
|
1835
|
+
sessions.push(
|
|
1836
|
+
res.ok
|
|
1837
|
+
? { session_id: e.session_id, outcome: "cleared_failure" }
|
|
1838
|
+
: { session_id: e.session_id, outcome: res.busy ? "busy" : "failed", reason: res.error },
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// Leftover files whose owning process is gone. Only when no --session
|
|
1843
|
+
// filter narrows the run.
|
|
1844
|
+
const files = [];
|
|
1845
|
+
if (opts.sessions.length === 0) {
|
|
1846
|
+
const procs = listCaptureProcesses();
|
|
1847
|
+
const liveTimerPids = new Set(
|
|
1848
|
+
procs.processes.filter((p) => p.kind === "timer").map((p) => p.pid),
|
|
1849
|
+
);
|
|
1850
|
+
let names = [];
|
|
1851
|
+
try {
|
|
1852
|
+
names = fs.readdirSync(dir);
|
|
1853
|
+
} catch {
|
|
1854
|
+
names = [];
|
|
1855
|
+
}
|
|
1856
|
+
const nowMs = Date.now();
|
|
1857
|
+
for (const name of names) {
|
|
1858
|
+
const full = path.join(dir, name);
|
|
1859
|
+
let stale = false;
|
|
1860
|
+
if (name.endsWith(".timer.pid")) {
|
|
1861
|
+
const pid = readPidFile(full);
|
|
1862
|
+
// With a process list, trust it (a reused PID is not a timer);
|
|
1863
|
+
// without one, fall back to "is any process alive".
|
|
1864
|
+
stale = procs.ok ? !liveTimerPids.has(pid) : !isPidAlive(pid);
|
|
1865
|
+
} else if (name.endsWith(".drain.lease")) {
|
|
1866
|
+
let lease = null;
|
|
1867
|
+
try {
|
|
1868
|
+
lease = JSON.parse(fs.readFileSync(full, "utf8"));
|
|
1869
|
+
} catch {
|
|
1870
|
+
lease = null;
|
|
1871
|
+
}
|
|
1872
|
+
const expires = lease ? Date.parse(lease.expires_at) : NaN;
|
|
1873
|
+
if (lease) {
|
|
1874
|
+
stale =
|
|
1875
|
+
!isPidAlive(Number(lease.pid)) &&
|
|
1876
|
+
(!Number.isFinite(expires) || expires <= nowMs);
|
|
1877
|
+
} else {
|
|
1878
|
+
let ageMs = 0;
|
|
1879
|
+
try {
|
|
1880
|
+
ageMs = nowMs - fs.statSync(full).mtimeMs;
|
|
1881
|
+
} catch {
|
|
1882
|
+
ageMs = 0;
|
|
1883
|
+
}
|
|
1884
|
+
stale = ageMs >= UNPARSABLE_LEASE_MIN_AGE_MS;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
if (stale) files.push(full);
|
|
1888
|
+
}
|
|
1889
|
+
const jobsDir = path.join(dir, "session-end-jobs");
|
|
1890
|
+
let jobs = [];
|
|
1891
|
+
try {
|
|
1892
|
+
jobs = fs.readdirSync(jobsDir);
|
|
1893
|
+
} catch {
|
|
1894
|
+
jobs = [];
|
|
1895
|
+
}
|
|
1896
|
+
if (procs.ok) {
|
|
1897
|
+
const liveJobFiles = procs.processes
|
|
1898
|
+
.filter((p) => p.kind === "session_end_worker")
|
|
1899
|
+
.map((p) => p.command);
|
|
1900
|
+
for (const name of jobs) {
|
|
1901
|
+
const full = path.join(jobsDir, name);
|
|
1902
|
+
if (!liveJobFiles.some((cmd) => cmd.includes(name))) files.push(full);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
const removed = [];
|
|
1907
|
+
for (const f of files) {
|
|
1908
|
+
if (opts.dryRun) {
|
|
1909
|
+
removed.push({ path: f, outcome: "would_remove" });
|
|
1910
|
+
continue;
|
|
1911
|
+
}
|
|
1912
|
+
try {
|
|
1913
|
+
fs.unlinkSync(f);
|
|
1914
|
+
removed.push({ path: f, outcome: "removed" });
|
|
1915
|
+
} catch (err) {
|
|
1916
|
+
removed.push({ path: f, outcome: "failed", reason: err.message });
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
if (!opts.dryRun) refreshHealthCache();
|
|
1920
|
+
const partial =
|
|
1921
|
+
sessions.some((r) => r.outcome === "busy" || r.outcome === "failed") ||
|
|
1922
|
+
removed.some((r) => r.outcome === "failed");
|
|
1923
|
+
return {
|
|
1924
|
+
action: "repair",
|
|
1925
|
+
dry_run: opts.dryRun,
|
|
1926
|
+
sessions,
|
|
1927
|
+
files: removed,
|
|
1928
|
+
errors,
|
|
1929
|
+
exit: errors.length ? 1 : partial ? 2 : 0,
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// ---------------------------------------------------------------------------
|
|
1934
|
+
// Text output
|
|
1935
|
+
// ---------------------------------------------------------------------------
|
|
1936
|
+
|
|
1937
|
+
function short(id) {
|
|
1938
|
+
return String(id || "").slice(0, 8);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function formatBytes(n) {
|
|
1942
|
+
if (n == null) return "-";
|
|
1943
|
+
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
1944
|
+
if (n >= 1024) return `${Math.round(n / 1024)}KB`;
|
|
1945
|
+
return `${n}B`;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function formatStatus(report) {
|
|
1949
|
+
const lines = [];
|
|
1950
|
+
lines.push(`Capture state: ${report.watermark_dir} (${report.total_sessions} sessions)`);
|
|
1951
|
+
const lastFix = describeLastFix(report.last_fix);
|
|
1952
|
+
const auto = report.autofix || {};
|
|
1953
|
+
lines.push(
|
|
1954
|
+
`Automatic repair: ${
|
|
1955
|
+
auto.enabled
|
|
1956
|
+
? `on, every ${Math.max(1, Math.round((auto.interval_seconds || 0) / 60))} minutes`
|
|
1957
|
+
: auto.source === "env"
|
|
1958
|
+
? "off (MEKO_AUTOFIX in the environment)"
|
|
1959
|
+
: `off (${auto.path || "autofix-config.json"}; reinstall with --autofix to turn it on)`
|
|
1960
|
+
}` +
|
|
1961
|
+
(lastFix ? `; last ${lastFix}` : ""),
|
|
1962
|
+
);
|
|
1963
|
+
if (report.sessions.length === 0) {
|
|
1964
|
+
lines.push("No session has unsent work, a stale failure, or a park.");
|
|
1965
|
+
} else {
|
|
1966
|
+
lines.push("");
|
|
1967
|
+
lines.push("Sessions needing attention:");
|
|
1968
|
+
for (const s of report.sessions) {
|
|
1969
|
+
if (!s.readable) {
|
|
1970
|
+
lines.push(` ${short(s.session_id)} unreadable watermark: ${s.error}`);
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
const flags = [
|
|
1974
|
+
s.parked ? "PARKED" : null,
|
|
1975
|
+
s.live ? "live" : null,
|
|
1976
|
+
s.stale_failure ? "stale-failure" : null,
|
|
1977
|
+
s.transcript_exists ? null : "no-transcript",
|
|
1978
|
+
].filter(Boolean);
|
|
1979
|
+
lines.push(
|
|
1980
|
+
` ${short(s.session_id)} ${s.client || "?"} ${s.agent_id || "?"} ` +
|
|
1981
|
+
`${s.lifecycle}/${s.delivery} queued=${s.queued_exchanges} ` +
|
|
1982
|
+
`attempts=${s.attempt_count} transcript=${formatBytes(s.transcript_bytes)}` +
|
|
1983
|
+
(flags.length ? ` [${flags.join(", ")}]` : ""),
|
|
1984
|
+
);
|
|
1985
|
+
const err = s.parked ? s.parked.previous && s.parked.previous.last_error : s.last_error;
|
|
1986
|
+
if (s.failure_class && !s.parked) {
|
|
1987
|
+
lines.push(` failure=${s.failure_class} next_retry_at=${s.next_retry_at || "-"}`);
|
|
1988
|
+
}
|
|
1989
|
+
if (err) lines.push(` last_error: ${String(err).slice(0, 160)}`);
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
lines.push("");
|
|
1993
|
+
if (report.process_scan_error) {
|
|
1994
|
+
lines.push(`Background processes: unknown (${report.process_scan_error})`);
|
|
1995
|
+
} else if (report.processes.length === 0) {
|
|
1996
|
+
lines.push("Background capture processes: none");
|
|
1997
|
+
} else {
|
|
1998
|
+
lines.push("Background capture processes:");
|
|
1999
|
+
for (const p of report.processes) {
|
|
2000
|
+
lines.push(
|
|
2001
|
+
` pid ${p.pid} ${p.kind} ${p.session_id ? short(p.session_id) : ""}` +
|
|
2002
|
+
(p.idle_parked ? " (session parked; idle, not retrying)" : ""),
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
return lines.join("\n");
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function formatActionResult(report) {
|
|
2010
|
+
const lines = [];
|
|
2011
|
+
const verb = report.dry_run ? `${report.action} (dry run)` : report.action;
|
|
2012
|
+
lines.push(`capture-cleanup ${verb}`);
|
|
2013
|
+
if (report.processes) {
|
|
2014
|
+
lines.push(
|
|
2015
|
+
report.process_scan_error
|
|
2016
|
+
? ` processes: not scanned (${report.process_scan_error})`
|
|
2017
|
+
: ` processes: ${report.processes.length}` +
|
|
2018
|
+
(report.dry_run ? " would be stopped" : " stopped"),
|
|
2019
|
+
);
|
|
2020
|
+
for (const p of report.processes) {
|
|
2021
|
+
const tail = report.dry_run ? "" : p.exited ? ` ${p.signal}` : ` STILL RUNNING`;
|
|
2022
|
+
lines.push(` pid ${p.pid} ${p.kind} ${p.session_id ? short(p.session_id) : ""}${tail}`);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
if (report.mcp_url) lines.push(` server: ${report.mcp_url} (credentials from ${report.credentials_from})`);
|
|
2026
|
+
for (const s of report.sessions || []) {
|
|
2027
|
+
const extra = [
|
|
2028
|
+
s.cause ? `cause=${s.cause}` : null,
|
|
2029
|
+
s.moved_to ? `new conversation ${short(s.moved_to)}` : null,
|
|
2030
|
+
s.queued_exchanges != null ? `queued=${s.queued_exchanges}` : null,
|
|
2031
|
+
s.captured != null ? `sent=${s.captured}` : null,
|
|
2032
|
+
s.queued_remaining != null && s.outcome !== "would_drain" ? `left=${s.queued_remaining}` : null,
|
|
2033
|
+
s.delivery ? `delivery=${s.delivery}` : null,
|
|
2034
|
+
s.reason || s.error || null,
|
|
2035
|
+
].filter(Boolean);
|
|
2036
|
+
lines.push(` ${short(s.session_id)} ${s.outcome}${extra.length ? ` ${extra.join(" ")}` : ""}`);
|
|
2037
|
+
if (s.hint && ["needs_you", "would_fix"].includes(s.outcome)) lines.push(` ${s.hint}`);
|
|
2038
|
+
if (s.stderr) lines.push(` ${s.stderr.split("\n").join("\n ")}`);
|
|
2039
|
+
}
|
|
2040
|
+
for (const f of report.files || []) {
|
|
2041
|
+
lines.push(` ${f.outcome} ${f.path}${f.reason ? ` ${f.reason}` : ""}`);
|
|
2042
|
+
}
|
|
2043
|
+
if (report.stopped_at) {
|
|
2044
|
+
lines.push(` stopped at ${short(report.stopped_at)}; later sessions were not attempted`);
|
|
2045
|
+
}
|
|
2046
|
+
if ((report.sessions || []).length === 0 && !(report.files || []).length && !report.processes) {
|
|
2047
|
+
lines.push(" nothing to do");
|
|
2048
|
+
}
|
|
2049
|
+
return lines.join("\n");
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// ---------------------------------------------------------------------------
|
|
2053
|
+
// Main
|
|
2054
|
+
// ---------------------------------------------------------------------------
|
|
2055
|
+
|
|
2056
|
+
function run(argv, io) {
|
|
2057
|
+
const out = io || { stdout: process.stdout, stderr: process.stderr };
|
|
2058
|
+
let opts;
|
|
2059
|
+
try {
|
|
2060
|
+
opts = parseArgs(argv);
|
|
2061
|
+
} catch (err) {
|
|
2062
|
+
out.stderr.write(`[meko-capture-cleanup] ${err.message}\n${usage()}`);
|
|
2063
|
+
return 1;
|
|
2064
|
+
}
|
|
2065
|
+
if (opts.help) {
|
|
2066
|
+
out.stdout.write(usage());
|
|
2067
|
+
return 0;
|
|
2068
|
+
}
|
|
2069
|
+
const log = (msg) => {
|
|
2070
|
+
if (!opts.json) out.stderr.write(`${msg}\n`);
|
|
2071
|
+
};
|
|
2072
|
+
let report;
|
|
2073
|
+
try {
|
|
2074
|
+
switch (opts.action) {
|
|
2075
|
+
case "status":
|
|
2076
|
+
report = actionStatus(opts);
|
|
2077
|
+
break;
|
|
2078
|
+
case "stop":
|
|
2079
|
+
report = actionStop(opts);
|
|
2080
|
+
break;
|
|
2081
|
+
case "resume":
|
|
2082
|
+
report = actionResume(opts);
|
|
2083
|
+
break;
|
|
2084
|
+
case "drain":
|
|
2085
|
+
report = actionDrain(opts, log);
|
|
2086
|
+
break;
|
|
2087
|
+
case "discard":
|
|
2088
|
+
report = actionDiscard(opts);
|
|
2089
|
+
break;
|
|
2090
|
+
case "repair":
|
|
2091
|
+
report = actionRepair(opts);
|
|
2092
|
+
break;
|
|
2093
|
+
case "fix":
|
|
2094
|
+
report = actionFix(opts, log);
|
|
2095
|
+
break;
|
|
2096
|
+
default:
|
|
2097
|
+
throw new Error(`unknown action ${opts.action}`);
|
|
2098
|
+
}
|
|
2099
|
+
} catch (err) {
|
|
2100
|
+
out.stderr.write(`[meko-capture-cleanup] ${err.message}\n`);
|
|
2101
|
+
return 1;
|
|
2102
|
+
}
|
|
2103
|
+
const exit = report.exit;
|
|
2104
|
+
delete report.exit;
|
|
2105
|
+
if (opts.json) {
|
|
2106
|
+
out.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
2107
|
+
} else {
|
|
2108
|
+
for (const e of report.errors || []) out.stderr.write(`[meko-capture-cleanup] ${e}\n`);
|
|
2109
|
+
out.stdout.write(
|
|
2110
|
+
`${report.action === "status" ? formatStatus(report) : formatActionResult(report)}\n`,
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
return exit;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
module.exports = {
|
|
2117
|
+
parseArgs,
|
|
2118
|
+
run,
|
|
2119
|
+
drainOneSession,
|
|
2120
|
+
diagnose,
|
|
2121
|
+
hasPendingWork,
|
|
2122
|
+
hasStaleFailure,
|
|
2123
|
+
parkedState,
|
|
2124
|
+
unparkedState,
|
|
2125
|
+
discardedState,
|
|
2126
|
+
listCaptureProcesses,
|
|
2127
|
+
processesInScope,
|
|
2128
|
+
isInitLike,
|
|
2129
|
+
PARK_UNTIL,
|
|
2130
|
+
};
|
|
2131
|
+
|
|
2132
|
+
if (require.main === module) {
|
|
2133
|
+
process.exit(run(process.argv.slice(2)));
|
|
2134
|
+
}
|