@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
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
"use strict";
|
|
19
19
|
|
|
20
|
-
const { execFile } = require("child_process");
|
|
20
|
+
const { execFile, spawn } = require("child_process");
|
|
21
21
|
const fs = require("fs");
|
|
22
22
|
const path = require("path");
|
|
23
23
|
const crypto = require("crypto");
|
|
@@ -31,11 +31,20 @@ const {
|
|
|
31
31
|
timerPidPath,
|
|
32
32
|
watermarkDir,
|
|
33
33
|
scanStates,
|
|
34
|
+
scanCompleteForDropClamp,
|
|
34
35
|
sortOldestFirst,
|
|
35
36
|
computeAggregateHealth,
|
|
36
37
|
writeHealthCache,
|
|
37
38
|
importLegacyCaptureErrors,
|
|
38
39
|
legacyErrorsSafeToRemove,
|
|
40
|
+
isResumableState,
|
|
41
|
+
readState,
|
|
42
|
+
atomicWriteJson,
|
|
43
|
+
autofixEnabled,
|
|
44
|
+
autofixIntervalSeconds,
|
|
45
|
+
autofixLastPath,
|
|
46
|
+
readAutofixLast,
|
|
47
|
+
envInt,
|
|
39
48
|
} = require("./capture-state");
|
|
40
49
|
|
|
41
50
|
const INTERVAL_SECS = Math.max(1, checkpointInterval());
|
|
@@ -44,6 +53,23 @@ const MAX_AGE_SECS = Math.max(1, timerMaxAge());
|
|
|
44
53
|
const IDLE_EXIT_SECS = parseInt(process.env.MEKO_TIMER_IDLE_EXIT, 10) || 7200;
|
|
45
54
|
const STARTUP_GRACE_SECS =
|
|
46
55
|
parseInt(process.env.MEKO_TIMER_STARTUP_GRACE, 10) || 1800;
|
|
56
|
+
const FOLLOWUP_SECS =
|
|
57
|
+
parseInt(process.env.MEKO_TIMER_FOLLOWUP_INTERVAL, 10) || 30;
|
|
58
|
+
|
|
59
|
+
// Automatic repair: one `--capture-cleanup fix --auto` run per machine every
|
|
60
|
+
// MEKO_AUTOFIX_INTERVAL seconds (default 1800). Any timer may run it; a lock
|
|
61
|
+
// file keeps it to one at a time.
|
|
62
|
+
const AUTOFIX_CHECK_SECS = Math.max(1, envInt("MEKO_AUTOFIX_CHECK_INTERVAL", 60));
|
|
63
|
+
const AUTOFIX_FIRST_CHECK_SECS = Math.max(1, envInt("MEKO_AUTOFIX_FIRST_CHECK", 30));
|
|
64
|
+
const AUTOFIX_TIMEOUT_MS = Math.max(60, envInt("MEKO_AUTOFIX_TIMEOUT", 900)) * 1000;
|
|
65
|
+
const AUTOFIX_MAX_EXCHANGES = Math.max(1, envInt("MEKO_AUTOFIX_MAX_EXCHANGES", 100));
|
|
66
|
+
const cleanupScript = path.join(__dirname, "capture-cleanup-cli.js");
|
|
67
|
+
let autofixInFlight = false;
|
|
68
|
+
// The running automatic-repair child and its lock, so cleanup() can stop it:
|
|
69
|
+
// the timer exits through process.exit, which skips runAutofix's finally.
|
|
70
|
+
let autofixChild = null;
|
|
71
|
+
let autofixLockPath = null;
|
|
72
|
+
let exiting = false;
|
|
47
73
|
|
|
48
74
|
const startedAt = Date.now();
|
|
49
75
|
const STATE_DIR = watermarkDir();
|
|
@@ -150,7 +176,107 @@ function cleanup() {
|
|
|
150
176
|
} catch {
|
|
151
177
|
/* already gone */
|
|
152
178
|
}
|
|
153
|
-
|
|
179
|
+
if (exiting) return;
|
|
180
|
+
exiting = true;
|
|
181
|
+
// Stopping an automatic run waits for it to exit, which needs the event
|
|
182
|
+
// loop (a synchronous wait would leave it a zombie that still looks alive).
|
|
183
|
+
stopAutofixChild()
|
|
184
|
+
.catch(() => {})
|
|
185
|
+
.finally(() => process.exit(0));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function groupAlive(pgid) {
|
|
189
|
+
if (!Number.isInteger(pgid) || pgid <= 0) return false;
|
|
190
|
+
try {
|
|
191
|
+
process.kill(-pgid, 0);
|
|
192
|
+
return true;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return Boolean(err && err.code === "EPERM");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function signalGroup(pgid, signal) {
|
|
199
|
+
try {
|
|
200
|
+
process.kill(-pgid, signal);
|
|
201
|
+
} catch {
|
|
202
|
+
/* already gone */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Stop an in-flight automatic repair: its whole process group, so the
|
|
210
|
+
* capture.js child the cleanup CLI is blocked on stops too. The lock is
|
|
211
|
+
* released only once the group is gone; until then it names the run's pid,
|
|
212
|
+
* so no other timer starts a second run while this one is still sending.
|
|
213
|
+
*/
|
|
214
|
+
async function stopAutofixChild() {
|
|
215
|
+
const child = autofixChild;
|
|
216
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
217
|
+
const closed = new Promise((r) => child.once("close", r));
|
|
218
|
+
signalGroup(child.pid, "SIGTERM");
|
|
219
|
+
const exited = await Promise.race([closed.then(() => true), sleep(3000).then(() => false)]);
|
|
220
|
+
if (!exited) {
|
|
221
|
+
signalGroup(child.pid, "SIGKILL");
|
|
222
|
+
await Promise.race([closed, sleep(1000)]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (child) {
|
|
226
|
+
// The rest of the group (capture.js) was re-parented when the CLI died.
|
|
227
|
+
for (let waited = 0; waited < 3000 && groupAlive(child.pid); waited += 100) await sleep(100);
|
|
228
|
+
if (groupAlive(child.pid)) signalGroup(child.pid, "SIGKILL");
|
|
229
|
+
}
|
|
230
|
+
releaseAutofixLock(child ? child.pid : null);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function releaseAutofixLock(runPid) {
|
|
234
|
+
if (!autofixLockPath) return;
|
|
235
|
+
if (runPid && groupAlive(runPid)) return;
|
|
236
|
+
try {
|
|
237
|
+
const held = JSON.parse(fs.readFileSync(autofixLockPath, "utf8"));
|
|
238
|
+
if (Number(held.pid) === process.pid) fs.unlinkSync(autofixLockPath);
|
|
239
|
+
} catch {
|
|
240
|
+
/* already gone */
|
|
241
|
+
}
|
|
242
|
+
autofixLockPath = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// MEKO-918: an OpenCode timer outlives edits to its hook env file. Without a
|
|
246
|
+
// re-read, a timer started during an outage sends every attempt to the dead
|
|
247
|
+
// URL until it exits: MEKO_TIMER_IDLE_EXIT after the session's last turn, or
|
|
248
|
+
// MEKO_TIMER_MAX_AGE (24 h) after it started.
|
|
249
|
+
//
|
|
250
|
+
// The installer always writes MEKO_MCP_URL and MEKO_API_KEY, so a file that
|
|
251
|
+
// lacks them was emptied by uninstall or by hand. Drop the inherited values
|
|
252
|
+
// then, so the timer stops sending with removed credentials. A file that
|
|
253
|
+
// can't be parsed keeps the inherited environment.
|
|
254
|
+
const RELOADED_CREDENTIAL_KEYS = ["MEKO_MCP_URL", "MEKO_API_KEY"];
|
|
255
|
+
|
|
256
|
+
function captureChildEnv() {
|
|
257
|
+
if (
|
|
258
|
+
process.env.MEKO_HOOK_CLIENT !== "opencode" ||
|
|
259
|
+
!process.env.MEKO_OPENCODE_ENV_FILE
|
|
260
|
+
) {
|
|
261
|
+
return process.env;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const { status, set, unset } = require("./opencode-handler").readHookEnvironment(
|
|
265
|
+
process.env.MEKO_OPENCODE_ENV_FILE,
|
|
266
|
+
);
|
|
267
|
+
if (status === "unreadable") return process.env;
|
|
268
|
+
const env = { ...process.env, ...set };
|
|
269
|
+
for (const key of unset) delete env[key];
|
|
270
|
+
for (const key of RELOADED_CREDENTIAL_KEYS) {
|
|
271
|
+
if (!(key in set)) delete env[key];
|
|
272
|
+
}
|
|
273
|
+
return env;
|
|
274
|
+
} catch (err) {
|
|
275
|
+
process.stderr.write(
|
|
276
|
+
`[meko-timer] Could not reload OpenCode hook environment: ${err.message}\n`,
|
|
277
|
+
);
|
|
278
|
+
return process.env;
|
|
279
|
+
}
|
|
154
280
|
}
|
|
155
281
|
|
|
156
282
|
function runCaptureChild(mode, payload, timeoutMs) {
|
|
@@ -158,7 +284,7 @@ function runCaptureChild(mode, payload, timeoutMs) {
|
|
|
158
284
|
const child = execFile(
|
|
159
285
|
process.execPath,
|
|
160
286
|
[captureScript, mode],
|
|
161
|
-
{ timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
|
|
287
|
+
{ timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, env: captureChildEnv() },
|
|
162
288
|
(err, stdout, stderr) => {
|
|
163
289
|
let result = null;
|
|
164
290
|
const text = (stdout || "").trim();
|
|
@@ -185,11 +311,27 @@ function runCaptureChild(mode, payload, timeoutMs) {
|
|
|
185
311
|
});
|
|
186
312
|
}
|
|
187
313
|
|
|
314
|
+
// MEKO-635: settle OpenCode pending turns past the 30-minute default.
|
|
315
|
+
function sweepOpenCodeSettles() {
|
|
316
|
+
try {
|
|
317
|
+
const opencode = require("./opencode-handler");
|
|
318
|
+
if (typeof opencode.sweepExpiredSettles === "function") {
|
|
319
|
+
opencode.sweepExpiredSettles();
|
|
320
|
+
}
|
|
321
|
+
} catch (err) {
|
|
322
|
+
process.stderr.write(
|
|
323
|
+
`[meko-timer] OpenCode settle sweep failed: ${err.message}\n`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
188
328
|
function rebuildHealth() {
|
|
189
329
|
try {
|
|
190
330
|
const scan = scanStates({ includeBlocked: true });
|
|
191
331
|
const health = computeAggregateHealth(scan);
|
|
192
|
-
writeHealthCache(health
|
|
332
|
+
writeHealthCache(health, {
|
|
333
|
+
clampDropped: scanCompleteForDropClamp(scan),
|
|
334
|
+
});
|
|
193
335
|
} catch (err) {
|
|
194
336
|
process.stderr.write(
|
|
195
337
|
`[meko-timer] Health rebuild failed: ${err.message}\n`,
|
|
@@ -245,11 +387,17 @@ function refreshStartupPersistentQueue(scan, forcePersistentRetry) {
|
|
|
245
387
|
* Returns true if more recoverable work may remain for a later cycle.
|
|
246
388
|
*/
|
|
247
389
|
async function runRecoveryCycle() {
|
|
248
|
-
|
|
390
|
+
// Once cleanup() starts, start no new capture.js child: it would outlive
|
|
391
|
+
// this timer's exit.
|
|
392
|
+
if (recoveryInFlight || exiting) return workRemains;
|
|
249
393
|
recoveryInFlight = true;
|
|
250
394
|
const forcePersistentRetry = startupRecoveryPending;
|
|
251
395
|
let moreWork = false;
|
|
252
396
|
try {
|
|
397
|
+
// Finalize any resumable session whose settling window has expired before
|
|
398
|
+
// the scan snapshots eligibility. Otherwise recovery keeps draining a
|
|
399
|
+
// pending user (queued_exchanges === 1) whose assistant will never arrive.
|
|
400
|
+
sweepOpenCodeSettles();
|
|
253
401
|
const scan = scanStates({
|
|
254
402
|
includeBlocked: true,
|
|
255
403
|
forcePersistentRetry,
|
|
@@ -292,11 +440,12 @@ async function runRecoveryCycle() {
|
|
|
292
440
|
}
|
|
293
441
|
|
|
294
442
|
const state = entry.state || {};
|
|
443
|
+
const resumable = isResumableState(state);
|
|
295
444
|
const payload = {
|
|
296
445
|
session_id: entry.session_id,
|
|
297
446
|
transcript_path: state.transcript_path || undefined,
|
|
298
|
-
lifecycle:
|
|
299
|
-
confirmed_dead:
|
|
447
|
+
lifecycle: resumable ? "active" : "closing",
|
|
448
|
+
confirmed_dead: !resumable,
|
|
300
449
|
recorded_owner_only: true,
|
|
301
450
|
max_exchanges: Math.min(budget, maxCaptureBatchSize()),
|
|
302
451
|
force_retry: Boolean(
|
|
@@ -321,7 +470,10 @@ async function runRecoveryCycle() {
|
|
|
321
470
|
const captured = Number((result && result.captured) || 0);
|
|
322
471
|
budget -= captured;
|
|
323
472
|
const queued = Number((result && result.queued_remaining) || 0);
|
|
324
|
-
if (
|
|
473
|
+
if (
|
|
474
|
+
(queued > 0 || (result && result.status === "retry_wait")) &&
|
|
475
|
+
!(resumable && captured === 0 && queued <= 1 && result.status !== "retry_wait")
|
|
476
|
+
) {
|
|
325
477
|
moreWork = true;
|
|
326
478
|
}
|
|
327
479
|
if (result && (result.status === "partial" || result.status === "retry_wait" || result.status === "blocked")) {
|
|
@@ -346,7 +498,10 @@ async function runRecoveryCycle() {
|
|
|
346
498
|
e.eligibility &&
|
|
347
499
|
e.session_id !== sessionId &&
|
|
348
500
|
Number(e.eligibility.queued_exchanges || 0) > 0 &&
|
|
349
|
-
e.eligibility.retry_due === false
|
|
501
|
+
e.eligibility.retry_due === false &&
|
|
502
|
+
!(isResumableState(e.state) &&
|
|
503
|
+
Number(e.eligibility.queued_exchanges || 0) <= 1 &&
|
|
504
|
+
e.state && e.state.delivery === "pending"),
|
|
350
505
|
);
|
|
351
506
|
}
|
|
352
507
|
|
|
@@ -361,10 +516,72 @@ async function runRecoveryCycle() {
|
|
|
361
516
|
return moreWork;
|
|
362
517
|
}
|
|
363
518
|
|
|
519
|
+
// MEKO-635: for the daemon's own session, look up whether its captured intent
|
|
520
|
+
// declared a resumable pending-turn policy. If so, timer-idle and
|
|
521
|
+
// transcript-gone paths must drain without finalizing the session as dead.
|
|
522
|
+
function selfIsResumable() {
|
|
523
|
+
const read = readState(sessionId);
|
|
524
|
+
return read.ok ? isResumableState(read.state) : false;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// MEKO-918: recovery skips the daemon's own session, so without this check a
|
|
528
|
+
// failed live drain waits for the next full tick (600 s by default) instead
|
|
529
|
+
// of retrying at next_retry_at. Each scheduled retry runs once: a checkpoint
|
|
530
|
+
// that exits before the network leaves next_retry_at unchanged.
|
|
531
|
+
//
|
|
532
|
+
// The retry has its own in-flight promise rather than tickInFlight. A tick
|
|
533
|
+
// that fires during a retry waits for it and then runs, so the exit checks
|
|
534
|
+
// and recovery cycle are not skipped for a whole interval.
|
|
535
|
+
let ownRetryAttemptedFor = null;
|
|
536
|
+
let ownRetryInFlight = null;
|
|
537
|
+
|
|
538
|
+
function ownRetryDue(nowMs) {
|
|
539
|
+
const read = readState(sessionId);
|
|
540
|
+
if (!read.ok) return false;
|
|
541
|
+
const state = read.state;
|
|
542
|
+
// needs_conversation counts: on a watermark without a conversation_id,
|
|
543
|
+
// writeSessionIntent resets a failed attempt's retry_wait to
|
|
544
|
+
// needs_conversation and keeps next_retry_at. OpenCode does this on every
|
|
545
|
+
// snapshot (ensureCaptureIntent).
|
|
546
|
+
if (["idle", "complete", "draining"].includes(state.delivery)) return false;
|
|
547
|
+
if (!(Number(state.queued_exchanges || 0) > 0)) return false;
|
|
548
|
+
const retryMs = Date.parse(state.next_retry_at || "");
|
|
549
|
+
if (!Number.isFinite(retryMs) || retryMs > nowMs) return false;
|
|
550
|
+
return state.next_retry_at !== ownRetryAttemptedFor;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function retryOwnSession() {
|
|
554
|
+
if (exiting || tickInFlight || recoveryInFlight || ownRetryInFlight) {
|
|
555
|
+
return Promise.resolve();
|
|
556
|
+
}
|
|
557
|
+
ownRetryInFlight = runOwnRetry().finally(() => {
|
|
558
|
+
ownRetryInFlight = null;
|
|
559
|
+
});
|
|
560
|
+
return ownRetryInFlight;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function runOwnRetry() {
|
|
564
|
+
const read = readState(sessionId);
|
|
565
|
+
ownRetryAttemptedFor = read.ok ? read.state.next_retry_at : null;
|
|
566
|
+
const live = await runCaptureChild(
|
|
567
|
+
"checkpoint",
|
|
568
|
+
{ transcript_path: transcriptPath, force_retry: false },
|
|
569
|
+
CAPTURE_TIMEOUT_MS,
|
|
570
|
+
);
|
|
571
|
+
if (!live.ok) {
|
|
572
|
+
process.stderr.write(
|
|
573
|
+
`[meko-timer] Own-session retry failed: ${live.error}\n`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
rebuildHealth();
|
|
577
|
+
}
|
|
578
|
+
|
|
364
579
|
async function tick() {
|
|
365
|
-
if (tickInFlight) return;
|
|
580
|
+
if (tickInFlight || exiting) return;
|
|
366
581
|
tickInFlight = true;
|
|
367
582
|
try {
|
|
583
|
+
// Never run two checkpoints of this session at once.
|
|
584
|
+
if (ownRetryInFlight) await ownRetryInFlight.catch(() => {});
|
|
368
585
|
if (Date.now() - startedAt >= MAX_AGE_SECS * 1000) {
|
|
369
586
|
process.stderr.write(
|
|
370
587
|
`[meko-timer] Max age (${MAX_AGE_SECS}s) reached; self-exiting (outbox left pending).\n`,
|
|
@@ -382,14 +599,16 @@ async function tick() {
|
|
|
382
599
|
await runRecoveryCycle();
|
|
383
600
|
return;
|
|
384
601
|
}
|
|
385
|
-
// Transcript gone after grace
|
|
602
|
+
// Transcript gone after grace. For a resumable session drain without
|
|
603
|
+
// finalizing so a later snapshot can complete its pending turn.
|
|
604
|
+
const resumable = selfIsResumable();
|
|
386
605
|
await runCaptureChild(
|
|
387
606
|
"drain",
|
|
388
607
|
{
|
|
389
608
|
session_id: sessionId,
|
|
390
609
|
transcript_path: transcriptPath,
|
|
391
|
-
lifecycle: "closing",
|
|
392
|
-
confirmed_dead:
|
|
610
|
+
lifecycle: resumable ? "active" : "closing",
|
|
611
|
+
confirmed_dead: !resumable,
|
|
393
612
|
recorded_owner_only: true,
|
|
394
613
|
},
|
|
395
614
|
CAPTURE_TIMEOUT_MS,
|
|
@@ -403,13 +622,14 @@ async function tick() {
|
|
|
403
622
|
process.stderr.write(
|
|
404
623
|
`[meko-timer] Transcript idle > ${IDLE_EXIT_SECS}s; final drain then exit.\n`,
|
|
405
624
|
);
|
|
625
|
+
const resumable = selfIsResumable();
|
|
406
626
|
await runCaptureChild(
|
|
407
627
|
"drain",
|
|
408
628
|
{
|
|
409
629
|
session_id: sessionId,
|
|
410
630
|
transcript_path: transcriptPath,
|
|
411
|
-
lifecycle: "closing",
|
|
412
|
-
confirmed_dead:
|
|
631
|
+
lifecycle: resumable ? "active" : "closing",
|
|
632
|
+
confirmed_dead: !resumable,
|
|
413
633
|
recorded_owner_only: true,
|
|
414
634
|
},
|
|
415
635
|
CAPTURE_TIMEOUT_MS,
|
|
@@ -459,6 +679,192 @@ async function tick() {
|
|
|
459
679
|
}
|
|
460
680
|
}
|
|
461
681
|
|
|
682
|
+
function autofixDue(nowMs) {
|
|
683
|
+
if (!autofixEnabled() || !fs.existsSync(cleanupScript)) return false;
|
|
684
|
+
const last = readAutofixLast();
|
|
685
|
+
const ranAt = last ? Date.parse(last.ran_at) : NaN;
|
|
686
|
+
return !Number.isFinite(ranAt) || nowMs - ranAt >= autofixIntervalSeconds() * 1000;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function acquireAutofixLock(nowMs) {
|
|
690
|
+
const lockPath = path.join(STATE_DIR, "autofix.lock");
|
|
691
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
692
|
+
try {
|
|
693
|
+
fs.writeFileSync(
|
|
694
|
+
lockPath,
|
|
695
|
+
JSON.stringify({ pid: process.pid, at: new Date(nowMs).toISOString() }),
|
|
696
|
+
{ flag: "wx", mode: 0o600 },
|
|
697
|
+
);
|
|
698
|
+
return lockPath;
|
|
699
|
+
} catch (err) {
|
|
700
|
+
if (!err || err.code !== "EEXIST") return null;
|
|
701
|
+
let held = null;
|
|
702
|
+
try {
|
|
703
|
+
held = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
704
|
+
} catch {
|
|
705
|
+
held = null;
|
|
706
|
+
}
|
|
707
|
+
const heldAt = held ? Date.parse(held.at) : NaN;
|
|
708
|
+
// Stale only when neither the timer that took it nor the run it
|
|
709
|
+
// started is alive: a run outlives its timer if the timer is killed.
|
|
710
|
+
const holderAlive = held && (isProcessAlive(Number(held.pid)) || groupAlive(Number(held.run_pid)));
|
|
711
|
+
const stale =
|
|
712
|
+
!held ||
|
|
713
|
+
!holderAlive ||
|
|
714
|
+
!Number.isFinite(heldAt) ||
|
|
715
|
+
nowMs - heldAt > AUTOFIX_TIMEOUT_MS * 2;
|
|
716
|
+
if (!stale) return null;
|
|
717
|
+
try {
|
|
718
|
+
fs.unlinkSync(lockPath);
|
|
719
|
+
} catch {
|
|
720
|
+
/* another timer removed it first */
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function summarizeAutofix(stdout, err) {
|
|
728
|
+
let report = null;
|
|
729
|
+
try {
|
|
730
|
+
report = JSON.parse(stdout);
|
|
731
|
+
} catch {
|
|
732
|
+
report = null;
|
|
733
|
+
}
|
|
734
|
+
const errors = report && Array.isArray(report.errors) ? report.errors : [];
|
|
735
|
+
const sessions = report && Array.isArray(report.sessions) ? report.sessions : [];
|
|
736
|
+
const parts = [];
|
|
737
|
+
if (sessions.length) {
|
|
738
|
+
const byOutcome = {};
|
|
739
|
+
for (const x of sessions) byOutcome[x.outcome] = (byOutcome[x.outcome] || 0) + 1;
|
|
740
|
+
parts.push(Object.entries(byOutcome).map(([k, v]) => `${k}=${v}`).join(" "));
|
|
741
|
+
}
|
|
742
|
+
if (errors.length) parts.push(`errors: ${errors.join("; ")}`);
|
|
743
|
+
if (!report) parts.push(err ? `failed: ${err.message}` : "failed: no report");
|
|
744
|
+
else if (err && !errors.length) parts.push(`exit ${err.code != null ? err.code : "?"}`);
|
|
745
|
+
return parts.length ? parts.join("; ") : "nothing to fix";
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* A run that crashed or timed out never wrote its own summary. Replace the
|
|
750
|
+
* placeholder so status and the status bar don't show a run that never
|
|
751
|
+
* finished, or the previous run's needs_you list as current.
|
|
752
|
+
*/
|
|
753
|
+
function finishAutofixPlaceholder(error) {
|
|
754
|
+
const last = readAutofixLast();
|
|
755
|
+
if (!last || !last.running) return;
|
|
756
|
+
try {
|
|
757
|
+
atomicWriteJson(autofixLastPath(), {
|
|
758
|
+
...last,
|
|
759
|
+
running: false,
|
|
760
|
+
error,
|
|
761
|
+
counts: {},
|
|
762
|
+
needs_you: [],
|
|
763
|
+
});
|
|
764
|
+
} catch {
|
|
765
|
+
/* informational */
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function runAutofix() {
|
|
770
|
+
if (autofixInFlight || exiting) return;
|
|
771
|
+
const now = Date.now();
|
|
772
|
+
if (!autofixDue(now)) return;
|
|
773
|
+
// A client whose hook credentials were removed has nothing to repair with.
|
|
774
|
+
const childEnv = { ...captureChildEnv(), MEKO_CLEANUP_ENV_ONLY: "1" };
|
|
775
|
+
if (!childEnv.MEKO_MCP_URL) return;
|
|
776
|
+
const lockPath = acquireAutofixLock(now);
|
|
777
|
+
if (!lockPath) return;
|
|
778
|
+
autofixLockPath = lockPath;
|
|
779
|
+
autofixInFlight = true;
|
|
780
|
+
try {
|
|
781
|
+
// Another timer may have finished a run between the check and the lock.
|
|
782
|
+
if (!autofixDue(Date.now())) return;
|
|
783
|
+
// Record the attempt first: if the run crashes, the next one still
|
|
784
|
+
// waits a full interval instead of every timer retrying every minute.
|
|
785
|
+
const previous = readAutofixLast() || {};
|
|
786
|
+
try {
|
|
787
|
+
atomicWriteJson(autofixLastPath(), {
|
|
788
|
+
schema_version: 1,
|
|
789
|
+
ran_at: new Date().toISOString(),
|
|
790
|
+
mode: "auto",
|
|
791
|
+
running: true,
|
|
792
|
+
counts: {},
|
|
793
|
+
needs_you: Array.isArray(previous.needs_you) ? previous.needs_you : [],
|
|
794
|
+
});
|
|
795
|
+
} catch {
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
await new Promise((resolve) => {
|
|
799
|
+
// Its own process group (execFile ignores `detached`), so stopping it
|
|
800
|
+
// also stops the capture.js child it is blocked on.
|
|
801
|
+
const child = spawn(
|
|
802
|
+
process.execPath,
|
|
803
|
+
[
|
|
804
|
+
cleanupScript,
|
|
805
|
+
"fix",
|
|
806
|
+
"--auto",
|
|
807
|
+
"--json",
|
|
808
|
+
"--max-exchanges",
|
|
809
|
+
String(AUTOFIX_MAX_EXCHANGES),
|
|
810
|
+
"--pace-ms",
|
|
811
|
+
"500",
|
|
812
|
+
],
|
|
813
|
+
{ env: childEnv, detached: true, stdio: ["ignore", "pipe", "ignore"] },
|
|
814
|
+
);
|
|
815
|
+
autofixChild = child;
|
|
816
|
+
let stdout = "";
|
|
817
|
+
child.stdout.on("data", (chunk) => {
|
|
818
|
+
if (stdout.length < 16 * 1024 * 1024) stdout += chunk;
|
|
819
|
+
});
|
|
820
|
+
let timedOut = false;
|
|
821
|
+
const timer = setTimeout(() => {
|
|
822
|
+
timedOut = true;
|
|
823
|
+
signalGroup(child.pid, "SIGTERM");
|
|
824
|
+
setTimeout(() => signalGroup(child.pid, "SIGKILL"), 3000).unref();
|
|
825
|
+
}, AUTOFIX_TIMEOUT_MS);
|
|
826
|
+
// Record the run in the lock, so the lock isn't taken as stale while
|
|
827
|
+
// the run is alive.
|
|
828
|
+
try {
|
|
829
|
+
atomicWriteJson(lockPath, { pid: process.pid, run_pid: child.pid, at: new Date(now).toISOString() });
|
|
830
|
+
} catch {
|
|
831
|
+
/* the lock still names this timer */
|
|
832
|
+
}
|
|
833
|
+
let finished = false;
|
|
834
|
+
const finish = (err) => {
|
|
835
|
+
if (finished) return;
|
|
836
|
+
finished = true;
|
|
837
|
+
clearTimeout(timer);
|
|
838
|
+
const failure = timedOut
|
|
839
|
+
? new Error(`timed out after ${AUTOFIX_TIMEOUT_MS / 1000}s`)
|
|
840
|
+
: err;
|
|
841
|
+
// Anything left in the group (capture.js) after the CLI failed.
|
|
842
|
+
if (failure) signalGroup(child.pid, "SIGTERM");
|
|
843
|
+
const summary = summarizeAutofix(stdout, failure);
|
|
844
|
+
if (failure) finishAutofixPlaceholder(summary);
|
|
845
|
+
process.stderr.write(`[meko-timer] Automatic repair: ${summary}\n`);
|
|
846
|
+
resolve();
|
|
847
|
+
};
|
|
848
|
+
child.on("error", (err) => finish(err));
|
|
849
|
+
child.on("close", (code, signal) => {
|
|
850
|
+
if (code === 0 || code === 2) return finish(null);
|
|
851
|
+
const err = new Error(signal ? `stopped by ${signal}` : `exit ${code}`);
|
|
852
|
+
err.code = code;
|
|
853
|
+
finish(err);
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
} finally {
|
|
857
|
+
autofixInFlight = false;
|
|
858
|
+
const runPid = autofixChild ? autofixChild.pid : null;
|
|
859
|
+
autofixChild = null;
|
|
860
|
+
if (!exiting) {
|
|
861
|
+
// Wait for any capture.js left in the run's group before releasing.
|
|
862
|
+
for (let waited = 0; runPid && waited < 3000 && groupAlive(runPid); waited += 100) await sleep(100);
|
|
863
|
+
releaseAutofixLock(runPid);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
462
868
|
if (!acquirePidFile()) {
|
|
463
869
|
process.exit(0);
|
|
464
870
|
}
|
|
@@ -477,13 +883,34 @@ setInterval(() => {
|
|
|
477
883
|
});
|
|
478
884
|
}, INTERVAL_SECS * 1000);
|
|
479
885
|
|
|
886
|
+
// Automatic repair check, once a minute; the first one runs shortly after
|
|
887
|
+
// startup so a stuck machine is repaired without waiting a full interval.
|
|
888
|
+
setTimeout(() => {
|
|
889
|
+
runAutofix().catch((err) => {
|
|
890
|
+
process.stderr.write(`[meko-timer] Automatic repair failed: ${err.message}\n`);
|
|
891
|
+
});
|
|
892
|
+
}, AUTOFIX_FIRST_CHECK_SECS * 1000).unref();
|
|
893
|
+
setInterval(() => {
|
|
894
|
+
runAutofix().catch((err) => {
|
|
895
|
+
process.stderr.write(`[meko-timer] Automatic repair failed: ${err.message}\n`);
|
|
896
|
+
});
|
|
897
|
+
}, AUTOFIX_CHECK_SECS * 1000);
|
|
898
|
+
|
|
480
899
|
// While backlog remains, also schedule a shorter recovery follow-up without
|
|
481
|
-
// waiting for the full checkpoint interval (self-draining).
|
|
900
|
+
// waiting for the full checkpoint interval (self-draining). The same loop
|
|
901
|
+
// retries this daemon's own session once its next_retry_at passes.
|
|
482
902
|
setInterval(() => {
|
|
483
|
-
if (
|
|
903
|
+
if (exiting || recoveryInFlight || tickInFlight || ownRetryInFlight) return;
|
|
904
|
+
if (ownRetryDue(Date.now())) {
|
|
905
|
+
retryOwnSession().catch((err) => {
|
|
906
|
+
process.stderr.write(`[meko-timer] Own-session retry failed: ${err.message}\n`);
|
|
907
|
+
});
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (!workRemains) return;
|
|
484
911
|
runRecoveryCycle().catch((err) => {
|
|
485
912
|
process.stderr.write(
|
|
486
913
|
`[meko-timer] Follow-up recovery failed: ${err.message}\n`,
|
|
487
914
|
);
|
|
488
915
|
});
|
|
489
|
-
}, Math.min(
|
|
916
|
+
}, Math.min(FOLLOWUP_SECS, INTERVAL_SECS) * 1000);
|