@yugabytedb/meko-mcp 2.1.0 → 2.2.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 +152 -27
- package/assets/hooks-handlers/codex-session-end.sh +4 -69
- package/assets/hooks-handlers/cursor-session-start.sh +19 -1
- package/assets/hooks-handlers/lib/capture-notices.js +321 -0
- package/assets/hooks-handlers/lib/capture-state.js +2079 -0
- package/assets/hooks-handlers/lib/capture-status-cli.js +82 -0
- package/assets/hooks-handlers/lib/capture.js +1578 -299
- package/assets/hooks-handlers/lib/checkpoint-timer.js +444 -81
- package/assets/hooks-handlers/lib/kiro-handler.js +738 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +418 -0
- package/assets/hooks-handlers/session-end.sh +3 -34
- package/assets/scripts/meko-statusline.sh +41 -1
- package/assets/skills/meko-mcp-tools/SKILL.md +72 -125
- package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +12 -13
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +15 -12
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +25 -1
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +19 -77
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +8 -7
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +22 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +8 -8
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +6 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +6 -5
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +7 -6
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +1 -1
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +3 -3
- package/bin/create-meko-setup.mjs +48 -10
- package/lib/capture-status.mjs +67 -0
- package/lib/clients/aliases.mjs +5 -0
- package/lib/clients/claude-desktop.mjs +120 -5
- package/lib/clients/codex.mjs +6 -3
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +705 -0
- package/lib/config.mjs +90 -0
- package/lib/installer.mjs +1 -0
- package/lib/migrate/adapters/codex.mjs +490 -0
- package/lib/migrate/id.mjs +28 -3
- package/lib/migrate/orchestrator.mjs +34 -7
- package/lib/migrate.mjs +4 -2
- package/lib/prompt.mjs +16 -1
- package/package.json +4 -4
|
@@ -1,126 +1,489 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* checkpoint-timer.js —
|
|
3
|
+
* checkpoint-timer.js — Per-session daemon: live checkpoints + recovery scheduler.
|
|
4
4
|
*
|
|
5
|
-
* Spawned by session-start.sh at session start.
|
|
6
|
-
*
|
|
5
|
+
* Spawned by session-start.sh (and Cursor native SessionStart) at session start.
|
|
6
|
+
* Every MEKO_CHECKPOINT_INTERVAL seconds (default 600):
|
|
7
|
+
* 1. Drain this live session via `capture.js checkpoint`
|
|
8
|
+
* 2. Uncapped scan → capped fair recovery of eligible dead/closing sessions
|
|
9
|
+
* 3. Rebuild aggregate health cache
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
* by session-end.sh.
|
|
11
|
+
* Recovery also runs immediately on daemon start, then repeats while work remains.
|
|
10
12
|
*
|
|
11
13
|
* Usage: node checkpoint-timer.js <transcript_path>
|
|
12
14
|
*
|
|
13
|
-
* Environment:
|
|
14
|
-
* MEKO_CHECKPOINT_INTERVAL Seconds between ticks (default: 600)
|
|
15
|
-
* MEKO_TIMER_MAX_AGE Hard self-exit age in seconds (default: 86400 = 24h)
|
|
16
|
-
* MEKO_TIMER_IDLE_EXIT Self-exit after the transcript is untouched this
|
|
17
|
-
* long, in seconds (default: 7200 = 2h)
|
|
18
|
-
* MEKO_TIMER_STARTUP_GRACE Wait this long for the transcript to appear
|
|
19
|
-
* before self-exiting (default: 1800 = 30min)
|
|
20
|
-
* MEKO_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
|
|
21
|
-
* MEKO_MCP_URL Inherited by capture.js
|
|
22
|
-
* MEKO_API_KEY Inherited by capture.js
|
|
15
|
+
* Environment: see docs/plans/meko-capture-v2-contracts.md
|
|
23
16
|
*/
|
|
24
17
|
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
25
20
|
const { execFile } = require("child_process");
|
|
26
21
|
const fs = require("fs");
|
|
27
22
|
const path = require("path");
|
|
23
|
+
const crypto = require("crypto");
|
|
24
|
+
const {
|
|
25
|
+
checkpointInterval,
|
|
26
|
+
checkpointTimeout,
|
|
27
|
+
recoveryMaxSessions,
|
|
28
|
+
recoveryMaxExchanges,
|
|
29
|
+
maxCaptureBatchSize,
|
|
30
|
+
timerMaxAge,
|
|
31
|
+
timerPidPath,
|
|
32
|
+
watermarkDir,
|
|
33
|
+
scanStates,
|
|
34
|
+
sortOldestFirst,
|
|
35
|
+
computeAggregateHealth,
|
|
36
|
+
writeHealthCache,
|
|
37
|
+
importLegacyCaptureErrors,
|
|
38
|
+
legacyErrorsSafeToRemove,
|
|
39
|
+
} = require("./capture-state");
|
|
28
40
|
|
|
29
|
-
const INTERVAL_SECS =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// generous enough not to interrupt a genuinely long, active session.
|
|
33
|
-
const MAX_AGE_SECS = parseInt(process.env.MEKO_TIMER_MAX_AGE, 10) || 86400;
|
|
41
|
+
const INTERVAL_SECS = Math.max(1, checkpointInterval());
|
|
42
|
+
const CAPTURE_TIMEOUT_MS = Math.max(1, checkpointTimeout()) * 1000;
|
|
43
|
+
const MAX_AGE_SECS = Math.max(1, timerMaxAge());
|
|
34
44
|
const IDLE_EXIT_SECS = parseInt(process.env.MEKO_TIMER_IDLE_EXIT, 10) || 7200;
|
|
35
45
|
const STARTUP_GRACE_SECS =
|
|
36
46
|
parseInt(process.env.MEKO_TIMER_STARTUP_GRACE, 10) || 1800;
|
|
37
|
-
const startedAt = Date.now();
|
|
38
|
-
const WATERMARK_DIR =
|
|
39
|
-
process.env.MEKO_WATERMARK_DIR ||
|
|
40
|
-
path.join(process.env.HOME || "~", ".claude", "meko-capture");
|
|
41
47
|
|
|
48
|
+
const startedAt = Date.now();
|
|
49
|
+
const STATE_DIR = watermarkDir();
|
|
42
50
|
const transcriptPath = process.argv[2];
|
|
51
|
+
|
|
43
52
|
if (!transcriptPath) {
|
|
44
|
-
process.stderr.write(
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
"[meko-timer] Usage: checkpoint-timer.js <transcript_path>\n",
|
|
55
|
+
);
|
|
45
56
|
process.exit(1);
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
const sessionId = path.basename(transcriptPath, ".jsonl");
|
|
49
|
-
const pidFile =
|
|
60
|
+
const pidFile = timerPidPath(sessionId);
|
|
50
61
|
const captureScript = path.join(__dirname, "capture.js");
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
63
|
+
let tickInFlight = false;
|
|
64
|
+
let recoveryInFlight = false;
|
|
65
|
+
let workRemains = true; // force immediate recovery on start
|
|
66
|
+
let startupRecoveryPending = true;
|
|
67
|
+
let startupPersistentQueue = null;
|
|
68
|
+
|
|
69
|
+
function isProcessAlive(pid) {
|
|
70
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, 0);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Atomic PID publish (PR #254): write populated candidate inode, then linkSync.
|
|
81
|
+
* Avoids empty-lock races from create-then-write on the published path.
|
|
82
|
+
*/
|
|
83
|
+
function acquirePidFile() {
|
|
84
|
+
fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
|
|
85
|
+
const candidateFile = path.join(
|
|
86
|
+
STATE_DIR,
|
|
87
|
+
`${sessionId}.timer.pid.${process.pid}.${Date.now()}.${crypto
|
|
88
|
+
.randomBytes(4)
|
|
89
|
+
.toString("hex")}.tmp`,
|
|
90
|
+
);
|
|
91
|
+
try {
|
|
92
|
+
fs.writeFileSync(candidateFile, String(process.pid), {
|
|
93
|
+
flag: "wx",
|
|
94
|
+
mode: 0o600,
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
`[meko-timer] Could not stage PID file: ${err.message}\n`,
|
|
99
|
+
);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let acquired = false;
|
|
104
|
+
try {
|
|
105
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
106
|
+
try {
|
|
107
|
+
fs.linkSync(candidateFile, pidFile);
|
|
108
|
+
acquired = true;
|
|
109
|
+
break;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (!err || err.code !== "EEXIST") {
|
|
112
|
+
process.stderr.write(
|
|
113
|
+
`[meko-timer] PID publish failed: ${err.message}\n`,
|
|
114
|
+
);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
let existingPid = NaN;
|
|
118
|
+
try {
|
|
119
|
+
existingPid = parseInt(fs.readFileSync(pidFile, "utf8"), 10);
|
|
120
|
+
} catch {
|
|
121
|
+
existingPid = NaN;
|
|
122
|
+
}
|
|
123
|
+
if (isProcessAlive(existingPid)) {
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`[meko-timer] Timer already active for ${sessionId} (pid ${existingPid})\n`,
|
|
126
|
+
);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
fs.unlinkSync(pidFile);
|
|
131
|
+
} catch {
|
|
132
|
+
/* ignore */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
try {
|
|
138
|
+
fs.unlinkSync(candidateFile);
|
|
139
|
+
} catch {
|
|
140
|
+
/* ignore */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return acquired;
|
|
144
|
+
}
|
|
55
145
|
|
|
56
146
|
function cleanup() {
|
|
57
|
-
try {
|
|
147
|
+
try {
|
|
148
|
+
const recorded = parseInt(fs.readFileSync(pidFile, "utf8"), 10);
|
|
149
|
+
if (recorded === process.pid) fs.unlinkSync(pidFile);
|
|
150
|
+
} catch {
|
|
151
|
+
/* already gone */
|
|
152
|
+
}
|
|
58
153
|
process.exit(0);
|
|
59
154
|
}
|
|
60
155
|
|
|
61
|
-
|
|
62
|
-
|
|
156
|
+
function runCaptureChild(mode, payload, timeoutMs) {
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
const child = execFile(
|
|
159
|
+
process.execPath,
|
|
160
|
+
[captureScript, mode],
|
|
161
|
+
{ timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
|
|
162
|
+
(err, stdout, stderr) => {
|
|
163
|
+
let result = null;
|
|
164
|
+
const text = (stdout || "").trim();
|
|
165
|
+
if (text) {
|
|
166
|
+
try {
|
|
167
|
+
const line = text
|
|
168
|
+
.split(/\r?\n/)
|
|
169
|
+
.find((l) => l.trim().startsWith("{"));
|
|
170
|
+
if (line) result = JSON.parse(line);
|
|
171
|
+
} catch {
|
|
172
|
+
result = null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
resolve({
|
|
176
|
+
ok: !err,
|
|
177
|
+
error: err ? err.message : null,
|
|
178
|
+
result,
|
|
179
|
+
stderr: stderr || "",
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
child.stdin.on("error", () => {});
|
|
184
|
+
child.stdin.end(JSON.stringify(payload || {}));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
63
187
|
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
188
|
+
function rebuildHealth() {
|
|
189
|
+
try {
|
|
190
|
+
const scan = scanStates({ includeBlocked: true });
|
|
191
|
+
const health = computeAggregateHealth(scan);
|
|
192
|
+
writeHealthCache(health);
|
|
193
|
+
} catch (err) {
|
|
69
194
|
process.stderr.write(
|
|
70
|
-
`[meko-timer]
|
|
195
|
+
`[meko-timer] Health rebuild failed: ${err.message}\n`,
|
|
71
196
|
);
|
|
72
|
-
cleanup();
|
|
73
|
-
return;
|
|
74
197
|
}
|
|
198
|
+
}
|
|
75
199
|
|
|
76
|
-
|
|
77
|
-
// SessionStart can precede transcript creation by up to 20 minutes, so a
|
|
78
|
-
// missing file during the startup grace period is expected.
|
|
79
|
-
let stat;
|
|
200
|
+
function migrateLegacyErrors() {
|
|
80
201
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
202
|
+
importLegacyCaptureErrors();
|
|
203
|
+
const uncapped = scanStates({ includeBlocked: true });
|
|
204
|
+
for (const legacyPath of legacyErrorsSafeToRemove(uncapped)) {
|
|
205
|
+
try {
|
|
206
|
+
fs.unlinkSync(legacyPath);
|
|
207
|
+
} catch {
|
|
208
|
+
/* best effort; a future uncapped scan retries */
|
|
209
|
+
}
|
|
85
210
|
}
|
|
86
|
-
|
|
87
|
-
|
|
211
|
+
} catch (err) {
|
|
212
|
+
process.stderr.write(
|
|
213
|
+
`[meko-timer] Legacy capture error import failed: ${err.message}\n`,
|
|
214
|
+
);
|
|
88
215
|
}
|
|
216
|
+
}
|
|
89
217
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
218
|
+
function refreshStartupPersistentQueue(scan, forcePersistentRetry) {
|
|
219
|
+
const forcedIds = new Set(
|
|
220
|
+
(scan.entries || [])
|
|
221
|
+
.filter(
|
|
222
|
+
(entry) =>
|
|
223
|
+
entry &&
|
|
224
|
+
entry.ok &&
|
|
225
|
+
entry.eligibility &&
|
|
226
|
+
entry.eligibility.persistent_startup_override,
|
|
227
|
+
)
|
|
228
|
+
.map((entry) => entry.session_id),
|
|
229
|
+
);
|
|
230
|
+
if (forcePersistentRetry && startupPersistentQueue === null && scan.ok) {
|
|
231
|
+
// Snapshot the daemon-start backlog. A bounded cycle may attempt only a
|
|
232
|
+
// subset; retain the rest for follow-up cycles without also forcing new
|
|
233
|
+
// persistent failures that happen later in this daemon's lifetime.
|
|
234
|
+
startupPersistentQueue = new Set(forcedIds);
|
|
235
|
+
} else if (startupPersistentQueue) {
|
|
236
|
+
for (const queuedId of startupPersistentQueue) {
|
|
237
|
+
if (!forcedIds.has(queuedId)) startupPersistentQueue.delete(queuedId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return forcedIds;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Uncapped discovery, then capped fair recovery (oldest first).
|
|
245
|
+
* Returns true if more recoverable work may remain for a later cycle.
|
|
246
|
+
*/
|
|
247
|
+
async function runRecoveryCycle() {
|
|
248
|
+
if (recoveryInFlight) return workRemains;
|
|
249
|
+
recoveryInFlight = true;
|
|
250
|
+
const forcePersistentRetry = startupRecoveryPending;
|
|
251
|
+
let moreWork = false;
|
|
252
|
+
try {
|
|
253
|
+
const scan = scanStates({
|
|
254
|
+
includeBlocked: true,
|
|
255
|
+
forcePersistentRetry,
|
|
256
|
+
});
|
|
257
|
+
refreshStartupPersistentQueue(scan, forcePersistentRetry);
|
|
258
|
+
const eligible = sortOldestFirst(
|
|
259
|
+
(scan.entries || []).filter(
|
|
260
|
+
(e) =>
|
|
261
|
+
e &&
|
|
262
|
+
e.ok &&
|
|
263
|
+
e.eligibility &&
|
|
264
|
+
e.eligibility.eligible_for_recovery &&
|
|
265
|
+
(!e.eligibility.persistent_startup_override ||
|
|
266
|
+
(startupPersistentQueue && startupPersistentQueue.has(e.session_id))),
|
|
267
|
+
),
|
|
95
268
|
);
|
|
96
|
-
|
|
97
|
-
|
|
269
|
+
|
|
270
|
+
const maxSessions = recoveryMaxSessions();
|
|
271
|
+
const maxExchanges = recoveryMaxExchanges();
|
|
272
|
+
const selected = eligible.slice(0, maxSessions);
|
|
273
|
+
moreWork = eligible.length > selected.length;
|
|
274
|
+
|
|
275
|
+
let budget = maxExchanges;
|
|
276
|
+
for (const entry of selected) {
|
|
277
|
+
if (budget <= 0) {
|
|
278
|
+
moreWork = true;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
// Skip the live session this daemon owns — live tick handles it.
|
|
282
|
+
if (entry.session_id === sessionId) continue;
|
|
283
|
+
|
|
284
|
+
if (
|
|
285
|
+
entry.eligibility &&
|
|
286
|
+
entry.eligibility.persistent_startup_override &&
|
|
287
|
+
startupPersistentQueue
|
|
288
|
+
) {
|
|
289
|
+
// One forced attempt per session per daemon start. Remove before
|
|
290
|
+
// launch so a child crash cannot make this daemon hammer the session.
|
|
291
|
+
startupPersistentQueue.delete(entry.session_id);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const state = entry.state || {};
|
|
295
|
+
const payload = {
|
|
296
|
+
session_id: entry.session_id,
|
|
297
|
+
transcript_path: state.transcript_path || undefined,
|
|
298
|
+
lifecycle: state.lifecycle === "closing" ? "closing" : "closing",
|
|
299
|
+
confirmed_dead: true,
|
|
300
|
+
recorded_owner_only: true,
|
|
301
|
+
max_exchanges: Math.min(budget, maxCaptureBatchSize()),
|
|
302
|
+
force_retry: Boolean(
|
|
303
|
+
entry.eligibility &&
|
|
304
|
+
entry.eligibility.persistent_startup_override,
|
|
305
|
+
),
|
|
306
|
+
};
|
|
307
|
+
const outcome = await runCaptureChild(
|
|
308
|
+
"recover",
|
|
309
|
+
payload,
|
|
310
|
+
CAPTURE_TIMEOUT_MS,
|
|
311
|
+
);
|
|
312
|
+
const result = outcome.result;
|
|
313
|
+
if (!outcome.ok) {
|
|
314
|
+
process.stderr.write(
|
|
315
|
+
`[meko-timer] Recovery drain failed for ${entry.session_id}: ${outcome.error}\n`,
|
|
316
|
+
);
|
|
317
|
+
moreWork = true;
|
|
318
|
+
// Stop hammering an unhealthy server this cycle.
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
const captured = Number((result && result.captured) || 0);
|
|
322
|
+
budget -= captured;
|
|
323
|
+
const queued = Number((result && result.queued_remaining) || 0);
|
|
324
|
+
if (queued > 0 || (result && result.status === "retry_wait")) {
|
|
325
|
+
moreWork = true;
|
|
326
|
+
}
|
|
327
|
+
if (result && (result.status === "partial" || result.status === "retry_wait" || result.status === "blocked")) {
|
|
328
|
+
// Continue other sessions unless this looks like a systemic outage.
|
|
329
|
+
if (result.failure_class === "transient" && captured === 0) {
|
|
330
|
+
moreWork = true;
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
startupRecoveryPending =
|
|
337
|
+
startupPersistentQueue === null || startupPersistentQueue.size > 0;
|
|
338
|
+
if (startupRecoveryPending) moreWork = true;
|
|
339
|
+
|
|
340
|
+
// Also consider pending entries we skipped due to retry_wait not due yet.
|
|
341
|
+
if (!moreWork) {
|
|
342
|
+
moreWork = (scan.entries || []).some(
|
|
343
|
+
(e) =>
|
|
344
|
+
e &&
|
|
345
|
+
e.ok &&
|
|
346
|
+
e.eligibility &&
|
|
347
|
+
e.session_id !== sessionId &&
|
|
348
|
+
Number(e.eligibility.queued_exchanges || 0) > 0 &&
|
|
349
|
+
e.eligibility.retry_due === false,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
rebuildHealth();
|
|
354
|
+
} catch (err) {
|
|
355
|
+
process.stderr.write(`[meko-timer] Recovery cycle error: ${err.message}\n`);
|
|
356
|
+
moreWork = true;
|
|
357
|
+
} finally {
|
|
358
|
+
recoveryInFlight = false;
|
|
98
359
|
}
|
|
360
|
+
workRemains = moreWork;
|
|
361
|
+
return moreWork;
|
|
362
|
+
}
|
|
99
363
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
364
|
+
async function tick() {
|
|
365
|
+
if (tickInFlight) return;
|
|
366
|
+
tickInFlight = true;
|
|
367
|
+
try {
|
|
368
|
+
if (Date.now() - startedAt >= MAX_AGE_SECS * 1000) {
|
|
369
|
+
process.stderr.write(
|
|
370
|
+
`[meko-timer] Max age (${MAX_AGE_SECS}s) reached; self-exiting (outbox left pending).\n`,
|
|
371
|
+
);
|
|
372
|
+
cleanup();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let stat;
|
|
377
|
+
try {
|
|
378
|
+
stat = fs.statSync(transcriptPath);
|
|
379
|
+
} catch {
|
|
380
|
+
if (Date.now() - startedAt < STARTUP_GRACE_SECS * 1000) {
|
|
381
|
+
// Still start recovery even if our transcript isn't ready yet.
|
|
382
|
+
await runRecoveryCycle();
|
|
383
|
+
return;
|
|
113
384
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
385
|
+
// Transcript gone after grace — run one closing recovery for self, then exit.
|
|
386
|
+
await runCaptureChild(
|
|
387
|
+
"drain",
|
|
388
|
+
{
|
|
389
|
+
session_id: sessionId,
|
|
390
|
+
transcript_path: transcriptPath,
|
|
391
|
+
lifecycle: "closing",
|
|
392
|
+
confirmed_dead: true,
|
|
393
|
+
recorded_owner_only: true,
|
|
394
|
+
},
|
|
395
|
+
CAPTURE_TIMEOUT_MS,
|
|
396
|
+
);
|
|
397
|
+
await runRecoveryCycle();
|
|
398
|
+
cleanup();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (Date.now() - stat.mtimeMs >= IDLE_EXIT_SECS * 1000) {
|
|
403
|
+
process.stderr.write(
|
|
404
|
+
`[meko-timer] Transcript idle > ${IDLE_EXIT_SECS}s; final drain then exit.\n`,
|
|
405
|
+
);
|
|
406
|
+
await runCaptureChild(
|
|
407
|
+
"drain",
|
|
408
|
+
{
|
|
409
|
+
session_id: sessionId,
|
|
410
|
+
transcript_path: transcriptPath,
|
|
411
|
+
lifecycle: "closing",
|
|
412
|
+
confirmed_dead: true,
|
|
413
|
+
recorded_owner_only: true,
|
|
414
|
+
},
|
|
415
|
+
CAPTURE_TIMEOUT_MS,
|
|
416
|
+
);
|
|
417
|
+
await runRecoveryCycle();
|
|
418
|
+
cleanup();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Live session checkpoint. Recovery skips the daemon's own session so it
|
|
423
|
+
// cannot accidentally finalize a live transcript; consume that session's
|
|
424
|
+
// one-shot persistent retry here instead.
|
|
425
|
+
let forceLivePersistentRetry = false;
|
|
426
|
+
if (startupRecoveryPending) {
|
|
427
|
+
const startupScan = scanStates({
|
|
428
|
+
includeBlocked: true,
|
|
429
|
+
forcePersistentRetry: true,
|
|
430
|
+
});
|
|
431
|
+
const forcedIds = refreshStartupPersistentQueue(startupScan, true);
|
|
432
|
+
forceLivePersistentRetry = Boolean(
|
|
433
|
+
forcedIds.has(sessionId) &&
|
|
434
|
+
startupPersistentQueue &&
|
|
435
|
+
startupPersistentQueue.has(sessionId),
|
|
436
|
+
);
|
|
437
|
+
if (forceLivePersistentRetry) {
|
|
438
|
+
// Remove before launch so a child crash still consumes the one-shot.
|
|
439
|
+
startupPersistentQueue.delete(sessionId);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const live = await runCaptureChild(
|
|
443
|
+
"checkpoint",
|
|
444
|
+
{
|
|
445
|
+
transcript_path: transcriptPath,
|
|
446
|
+
force_retry: forceLivePersistentRetry,
|
|
447
|
+
},
|
|
448
|
+
CAPTURE_TIMEOUT_MS,
|
|
449
|
+
);
|
|
450
|
+
if (!live.ok) {
|
|
451
|
+
process.stderr.write(
|
|
452
|
+
`[meko-timer] Checkpoint tick failed: ${live.error}\n`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await runRecoveryCycle();
|
|
457
|
+
} finally {
|
|
458
|
+
tickInFlight = false;
|
|
459
|
+
}
|
|
124
460
|
}
|
|
125
461
|
|
|
126
|
-
|
|
462
|
+
if (!acquirePidFile()) {
|
|
463
|
+
process.exit(0);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
process.on("SIGTERM", cleanup);
|
|
467
|
+
process.on("SIGINT", cleanup);
|
|
468
|
+
|
|
469
|
+
// Immediate recovery on start, then periodic ticks.
|
|
470
|
+
migrateLegacyErrors();
|
|
471
|
+
tick().catch((err) => {
|
|
472
|
+
process.stderr.write(`[meko-timer] Startup tick failed: ${err.message}\n`);
|
|
473
|
+
});
|
|
474
|
+
setInterval(() => {
|
|
475
|
+
tick().catch((err) => {
|
|
476
|
+
process.stderr.write(`[meko-timer] Tick failed: ${err.message}\n`);
|
|
477
|
+
});
|
|
478
|
+
}, INTERVAL_SECS * 1000);
|
|
479
|
+
|
|
480
|
+
// While backlog remains, also schedule a shorter recovery follow-up without
|
|
481
|
+
// waiting for the full checkpoint interval (self-draining).
|
|
482
|
+
setInterval(() => {
|
|
483
|
+
if (!workRemains || recoveryInFlight || tickInFlight) return;
|
|
484
|
+
runRecoveryCycle().catch((err) => {
|
|
485
|
+
process.stderr.write(
|
|
486
|
+
`[meko-timer] Follow-up recovery failed: ${err.message}\n`,
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
}, Math.min(30, INTERVAL_SECS) * 1000);
|