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