@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.
Files changed (70) hide show
  1. package/README.md +233 -40
  2. package/assets/hooks-handlers/codex-session-end.sh +4 -69
  3. package/assets/hooks-handlers/cursor-session-start.sh +19 -1
  4. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
  5. package/assets/hooks-handlers/lib/capture-notices.js +438 -0
  6. package/assets/hooks-handlers/lib/capture-state.js +2173 -0
  7. package/assets/hooks-handlers/lib/capture-status-cli.js +86 -0
  8. package/assets/hooks-handlers/lib/capture.js +2782 -403
  9. package/assets/hooks-handlers/lib/checkpoint-timer.js +485 -81
  10. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  11. package/assets/hooks-handlers/lib/kiro-handler.js +760 -0
  12. package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
  13. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  14. package/assets/hooks-handlers/lib/terminal-handoff.js +427 -0
  15. package/assets/hooks-handlers/session-end.sh +3 -34
  16. package/assets/opencode-plugin/meko.js +213 -0
  17. package/assets/scripts/meko-statusline.sh +123 -16
  18. package/assets/skills/meko-mcp-tools/SKILL.md +116 -126
  19. package/assets/skills/meko-mcp-tools/references/_sections.md +3 -3
  20. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +25 -18
  21. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +99 -24
  22. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  23. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +53 -6
  24. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +25 -79
  25. package/assets/skills/meko-mcp-tools/references/tools-overview.md +37 -26
  26. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  27. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +66 -1
  28. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +7 -40
  29. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +56 -20
  30. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  31. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +15 -8
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +99 -36
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +18 -14
  36. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +39 -28
  37. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  38. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  39. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  40. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
  41. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  42. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  43. package/bin/create-meko-setup.mjs +113 -12
  44. package/lib/capture-cleanup.mjs +63 -0
  45. package/lib/capture-status.mjs +67 -0
  46. package/lib/clients/aliases.mjs +5 -0
  47. package/lib/clients/claude-code.mjs +1 -0
  48. package/lib/clients/claude-desktop.mjs +120 -5
  49. package/lib/clients/codex.mjs +85 -37
  50. package/lib/clients/common.mjs +7 -0
  51. package/lib/clients/index.mjs +4 -0
  52. package/lib/clients/kiro.mjs +705 -0
  53. package/lib/clients/opencode.mjs +532 -0
  54. package/lib/config.mjs +158 -1
  55. package/lib/installer.mjs +18 -1
  56. package/lib/migrate/adapters/claude-code.mjs +11 -2
  57. package/lib/migrate/adapters/codex.mjs +497 -0
  58. package/lib/migrate/adapters/memory-md.mjs +27 -8
  59. package/lib/migrate/adapters/opencode.mjs +148 -0
  60. package/lib/migrate/claude-parser.mjs +153 -9
  61. package/lib/migrate/id.mjs +217 -10
  62. package/lib/migrate/mcp-client.mjs +25 -1
  63. package/lib/migrate/opencode-normalizer.mjs +132 -0
  64. package/lib/migrate/orchestrator.mjs +299 -56
  65. package/lib/migrate/progress.mjs +239 -5
  66. package/lib/migrate/registry.mjs +72 -0
  67. package/lib/migrate.mjs +100 -10
  68. package/lib/prompt.mjs +16 -1
  69. package/package.json +4 -4
  70. package/scripts/bundle-assets.mjs +4 -0
@@ -0,0 +1,2173 @@
1
+ /**
2
+ * capture-state.js — Durable outbox / lease / health helpers for Meko Capture V2.
3
+ *
4
+ * CommonJS, zero npm deps. Workstream A surface consumed by capture.js (B),
5
+ * checkpoint-timer.js (C), and status/CLI (D). See:
6
+ * docs/plans/meko-capture-v2-contracts.md
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const crypto = require("crypto");
12
+ const fs = require("fs");
13
+ const os = require("os");
14
+ const path = require("path");
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Defaults (contracts env table) + readers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const DEFAULTS = Object.freeze({
21
+ WATERMARK_DIR: null, // resolved via watermarkDir()
22
+ MAX_CAPTURE_BATCH_SIZE: 5,
23
+ CHECKPOINT_INTERVAL: 600,
24
+ CHECKPOINT_TIMEOUT: 120,
25
+ RECOVERY_MAX_SESSIONS: 3,
26
+ RECOVERY_MAX_EXCHANGES: 20,
27
+ RECOVERY_MIN_IDLE: 900,
28
+ LEASE_TTL_SECONDS: 120,
29
+ RETRY_BASE_SECONDS: 30,
30
+ RETRY_MAX_SECONDS: 600,
31
+ RETRY_JITTER: 0.2,
32
+ PERSISTENT_RETRY_SECONDS: 21600,
33
+ WORKER_MAX_AGE_SECONDS: 86400,
34
+ TIMER_MAX_AGE: 86400,
35
+ HEALTH_CACHE: "capture-health.json",
36
+ });
37
+
38
+ const FAILURE_CLASSES = Object.freeze([
39
+ "transient",
40
+ "persistent",
41
+ "state_corrupt",
42
+ "ownership",
43
+ "datapack",
44
+ ]);
45
+
46
+ const LIFECYCLES = Object.freeze(["active", "closing", "closed"]);
47
+ const DELIVERIES = Object.freeze([
48
+ "needs_conversation",
49
+ "idle",
50
+ "pending",
51
+ "draining",
52
+ "retry_wait",
53
+ "blocked",
54
+ "complete",
55
+ ]);
56
+
57
+ const HEALTH_STATUSES = Object.freeze([
58
+ "blocked_action_required",
59
+ "degraded_retrying",
60
+ "catching_up",
61
+ "healthy",
62
+ ]);
63
+
64
+ const HEALTH_PRECEDENCE = Object.freeze({
65
+ blocked_action_required: 4,
66
+ degraded_retrying: 3,
67
+ catching_up: 2,
68
+ healthy: 1,
69
+ });
70
+
71
+ function envInt(name, fallback) {
72
+ const raw = process.env[name];
73
+ if (raw == null || raw === "") return fallback;
74
+ const n = Number.parseInt(String(raw), 10);
75
+ return Number.isFinite(n) ? n : fallback;
76
+ }
77
+
78
+ function envFloat(name, fallback) {
79
+ const raw = process.env[name];
80
+ if (raw == null || raw === "") return fallback;
81
+ const n = Number.parseFloat(String(raw));
82
+ return Number.isFinite(n) ? n : fallback;
83
+ }
84
+
85
+ function envString(name, fallback) {
86
+ const raw = process.env[name];
87
+ if (raw == null || raw === "") return fallback;
88
+ return String(raw);
89
+ }
90
+
91
+ function maxCaptureBatchSize() {
92
+ return Math.max(1, envInt("MEKO_MAX_CAPTURE_BATCH_SIZE", DEFAULTS.MAX_CAPTURE_BATCH_SIZE));
93
+ }
94
+ function checkpointInterval() {
95
+ return envInt("MEKO_CHECKPOINT_INTERVAL", DEFAULTS.CHECKPOINT_INTERVAL);
96
+ }
97
+ function checkpointTimeout() {
98
+ return envInt("MEKO_CHECKPOINT_TIMEOUT", DEFAULTS.CHECKPOINT_TIMEOUT);
99
+ }
100
+ function recoveryMaxSessions() {
101
+ return envInt("MEKO_RECOVERY_MAX_SESSIONS", DEFAULTS.RECOVERY_MAX_SESSIONS);
102
+ }
103
+ function recoveryMaxExchanges() {
104
+ return envInt("MEKO_RECOVERY_MAX_EXCHANGES", DEFAULTS.RECOVERY_MAX_EXCHANGES);
105
+ }
106
+ function recoveryMinIdle() {
107
+ return envInt("MEKO_RECOVERY_MIN_IDLE", DEFAULTS.RECOVERY_MIN_IDLE);
108
+ }
109
+ function leaseTtlSeconds() {
110
+ return envInt("MEKO_LEASE_TTL_SECONDS", DEFAULTS.LEASE_TTL_SECONDS);
111
+ }
112
+ function retryBaseSeconds() {
113
+ return envInt("MEKO_RETRY_BASE_SECONDS", DEFAULTS.RETRY_BASE_SECONDS);
114
+ }
115
+ function retryMaxSeconds() {
116
+ return envInt("MEKO_RETRY_MAX_SECONDS", DEFAULTS.RETRY_MAX_SECONDS);
117
+ }
118
+ function retryJitter() {
119
+ return envFloat("MEKO_RETRY_JITTER", DEFAULTS.RETRY_JITTER);
120
+ }
121
+ function persistentRetrySeconds() {
122
+ return envInt("MEKO_PERSISTENT_RETRY_SECONDS", DEFAULTS.PERSISTENT_RETRY_SECONDS);
123
+ }
124
+ function workerMaxAgeSeconds() {
125
+ return envInt("MEKO_WORKER_MAX_AGE_SECONDS", DEFAULTS.WORKER_MAX_AGE_SECONDS);
126
+ }
127
+ function timerMaxAge() {
128
+ return envInt("MEKO_TIMER_MAX_AGE", DEFAULTS.TIMER_MAX_AGE);
129
+ }
130
+ function healthCacheFilename() {
131
+ return envString("MEKO_HEALTH_CACHE", DEFAULTS.HEALTH_CACHE);
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Path helpers
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function watermarkDir() {
139
+ return (
140
+ process.env.MEKO_WATERMARK_DIR ||
141
+ path.join(os.homedir(), ".claude", "meko-capture")
142
+ );
143
+ }
144
+
145
+ function watermarkPath(sessionId) {
146
+ return path.join(watermarkDir(), `${sessionId}.watermark.json`);
147
+ }
148
+
149
+ function leasePath(sessionId) {
150
+ return path.join(watermarkDir(), `${sessionId}.drain.lease`);
151
+ }
152
+
153
+ function mutationLockPath(sessionId) {
154
+ return path.join(watermarkDir(), `${sessionId}.state.lock`);
155
+ }
156
+
157
+ function timerPidPath(sessionId) {
158
+ return path.join(watermarkDir(), `${sessionId}.timer.pid`);
159
+ }
160
+
161
+ function healthCachePath() {
162
+ return path.join(watermarkDir(), healthCacheFilename());
163
+ }
164
+
165
+ /**
166
+ * Convert an agent_id into the filesystem slug used by legacy error files and
167
+ * datapack pins (matches capture.js datapackPinSlug).
168
+ */
169
+ function agentSlug(agentId) {
170
+ const trimmed = (typeof agentId === "string" ? agentId : "").trim();
171
+ if (!trimmed) return "meko_agent";
172
+ return (
173
+ trimmed.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") ||
174
+ "meko_agent"
175
+ );
176
+ }
177
+
178
+ function legacyErrorPath(agentSlugOrId) {
179
+ // Accept either a pre-slugged name or a raw agent_id; agentSlug is idempotent
180
+ // for already-safe slugs.
181
+ return path.join(
182
+ watermarkDir(),
183
+ `last-capture-error-${agentSlug(agentSlugOrId)}.json`,
184
+ );
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Time / PID / atomic IO helpers
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function nowIso(nowMs) {
192
+ return new Date(nowMs != null ? nowMs : Date.now()).toISOString();
193
+ }
194
+
195
+ function parseIsoMs(value) {
196
+ if (value == null || value === "") return null;
197
+ const ms = Date.parse(String(value));
198
+ return Number.isFinite(ms) ? ms : null;
199
+ }
200
+
201
+ function isPidAlive(pid) {
202
+ if (!Number.isInteger(pid) || pid <= 0) return false;
203
+ try {
204
+ process.kill(pid, 0);
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ function ensureDir(dir) {
212
+ fs.mkdirSync(dir, { recursive: true });
213
+ }
214
+
215
+ /**
216
+ * Persist JSON atomically: temp (mode 0600) → fsync → rename.
217
+ * Never writes the destination in place.
218
+ */
219
+ function atomicWriteJson(destPath, value, options) {
220
+ const opts = options && typeof options === "object" ? options : {};
221
+ ensureDir(path.dirname(destPath));
222
+ const payload = `${JSON.stringify(value)}\n`;
223
+ const tmp = `${destPath}.${process.pid}.${Date.now()}.${crypto
224
+ .randomBytes(4)
225
+ .toString("hex")}.tmp`;
226
+ let fd;
227
+ try {
228
+ fd = fs.openSync(tmp, "w", 0o600);
229
+ fs.writeFileSync(fd, payload, "utf8");
230
+ fs.fsyncSync(fd);
231
+ } finally {
232
+ if (fd != null) {
233
+ try {
234
+ fs.closeSync(fd);
235
+ } catch {
236
+ // ignore
237
+ }
238
+ }
239
+ }
240
+ try {
241
+ if (typeof opts.beforeRename === "function") opts.beforeRename();
242
+ fs.renameSync(tmp, destPath);
243
+ } catch (err) {
244
+ try {
245
+ fs.unlinkSync(tmp);
246
+ } catch {
247
+ // ignore
248
+ }
249
+ throw err;
250
+ }
251
+ try {
252
+ fs.chmodSync(destPath, 0o600);
253
+ } catch {
254
+ // Best-effort; some platforms ignore mode bits.
255
+ }
256
+ }
257
+
258
+ function emptyInFlight() {
259
+ return { seed: null, user_line: null, user_turn_id: null };
260
+ }
261
+
262
+ function hasConversationId(value) {
263
+ return typeof value === "string" && value.trim() !== "";
264
+ }
265
+
266
+ function deliveryFromConversation(conversationId) {
267
+ return hasConversationId(conversationId) ? "idle" : "needs_conversation";
268
+ }
269
+
270
+ function cloneInFlight(raw) {
271
+ if (!raw || typeof raw !== "object") return emptyInFlight();
272
+ return {
273
+ seed: raw.seed == null ? null : String(raw.seed),
274
+ user_line:
275
+ raw.user_line == null || raw.user_line === ""
276
+ ? null
277
+ : Number(raw.user_line),
278
+ user_turn_id: raw.user_turn_id == null ? null : String(raw.user_turn_id),
279
+ };
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // V2 validation / V1 normalize
284
+ // ---------------------------------------------------------------------------
285
+
286
+ function blockedOutcome(failureClass, error, extras) {
287
+ return {
288
+ ok: false,
289
+ delivery: "blocked",
290
+ failure_class: failureClass || "state_corrupt",
291
+ error: error || "state blocked",
292
+ ...(extras || {}),
293
+ };
294
+ }
295
+
296
+ /**
297
+ * MEKO-635: resumable sessions (OpenCode) must not be finalized as
298
+ * confirmed_dead by timer idle or cross-session recovery. Only OpenCode sets
299
+ * this policy today; other clients continue to finalize as before. Existing
300
+ * pre-MEKO-635 `client: "opencode"` watermarks without an explicit
301
+ * `pending_turn_policy` field are also treated as resumable so upgrades don't
302
+ * finalize a turn that a later snapshot could still complete.
303
+ */
304
+ function isResumableState(state) {
305
+ if (!state || typeof state !== "object") return false;
306
+ if (state.pending_turn_policy === "resumable") return true;
307
+ if (state.pending_turn_policy == null && state.client === "opencode") return true;
308
+ return false;
309
+ }
310
+
311
+ /**
312
+ * Validate a candidate v2 state object.
313
+ * @returns {{ ok:true, state:Object } | { ok:false, delivery:"blocked", failure_class:"state_corrupt", error:string }}
314
+ */
315
+ function validateV2(state) {
316
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
317
+ return blockedOutcome("state_corrupt", "state is not an object");
318
+ }
319
+ if (state.schema_version !== 2) {
320
+ return blockedOutcome(
321
+ "state_corrupt",
322
+ `unsupported schema_version: ${state.schema_version}`,
323
+ );
324
+ }
325
+ if (typeof state.session_id !== "string" || !state.session_id.trim()) {
326
+ return blockedOutcome("state_corrupt", "session_id must be a non-empty string");
327
+ }
328
+ if (
329
+ typeof state.conversation_epoch !== "number" ||
330
+ !Number.isInteger(state.conversation_epoch) ||
331
+ state.conversation_epoch < 0
332
+ ) {
333
+ return blockedOutcome("state_corrupt", "conversation_epoch must be a non-negative integer");
334
+ }
335
+ if (
336
+ typeof state.last_line_number !== "number" ||
337
+ !Number.isInteger(state.last_line_number) ||
338
+ state.last_line_number < 0
339
+ ) {
340
+ return blockedOutcome("state_corrupt", "last_line_number must be a non-negative integer");
341
+ }
342
+ if (!LIFECYCLES.includes(state.lifecycle)) {
343
+ return blockedOutcome("state_corrupt", `invalid lifecycle: ${state.lifecycle}`);
344
+ }
345
+ if (!DELIVERIES.includes(state.delivery)) {
346
+ return blockedOutcome("state_corrupt", `invalid delivery: ${state.delivery}`);
347
+ }
348
+ if (!state.in_flight || typeof state.in_flight !== "object") {
349
+ return blockedOutcome("state_corrupt", "in_flight must be an object");
350
+ }
351
+ if (
352
+ state.failure_class != null &&
353
+ !FAILURE_CLASSES.includes(state.failure_class)
354
+ ) {
355
+ return blockedOutcome(
356
+ "state_corrupt",
357
+ `invalid failure_class: ${state.failure_class}`,
358
+ );
359
+ }
360
+ if (
361
+ state.consecutive_exchange_drops != null &&
362
+ (!Number.isInteger(state.consecutive_exchange_drops) ||
363
+ state.consecutive_exchange_drops < 0)
364
+ ) {
365
+ return blockedOutcome(
366
+ "state_corrupt",
367
+ "consecutive_exchange_drops must be a non-negative integer",
368
+ );
369
+ }
370
+ return { ok: true, state };
371
+ }
372
+
373
+ function coerceNonNegInt(value, fallback) {
374
+ const n = Number(value);
375
+ if (Number.isInteger(n) && n >= 0) return n;
376
+ return fallback;
377
+ }
378
+
379
+ /**
380
+ * Lazy v1→v2 normalize without advancing/resetting the cursor.
381
+ * Malformed / unsupported schema → typed blocked outcome (does not throw).
382
+ *
383
+ * @param {any} raw
384
+ * @param {object} [context]
385
+ * @returns {{ ok:true, state:Object, migrated?:boolean } | blocked outcome}
386
+ */
387
+ function normalizeState(raw, context) {
388
+ const ctx = context && typeof context === "object" ? context : {};
389
+ if (raw == null) {
390
+ return blockedOutcome("state_corrupt", "state is null or undefined");
391
+ }
392
+ if (typeof raw !== "object" || Array.isArray(raw)) {
393
+ return blockedOutcome("state_corrupt", "state is not an object");
394
+ }
395
+
396
+ const schemaVersion = raw.schema_version;
397
+ if (schemaVersion != null && schemaVersion !== 1 && schemaVersion !== 2) {
398
+ return blockedOutcome(
399
+ "state_corrupt",
400
+ `unsupported schema_version: ${schemaVersion}`,
401
+ { raw },
402
+ );
403
+ }
404
+
405
+ if (schemaVersion === 2) {
406
+ const validated = validateV2(raw);
407
+ if (!validated.ok) return validated;
408
+ return { ok: true, state: validated.state, migrated: false };
409
+ }
410
+
411
+ // V1 (no schema_version) or explicit schema_version:1 — migrate in memory.
412
+ const conversationId =
413
+ raw.conversation_id == null || raw.conversation_id === ""
414
+ ? null
415
+ : String(raw.conversation_id);
416
+ const now = nowIso(ctx.now);
417
+ const sessionId =
418
+ (typeof ctx.session_id === "string" && ctx.session_id) ||
419
+ (typeof raw.session_id === "string" && raw.session_id) ||
420
+ "";
421
+ if (!sessionId) {
422
+ return blockedOutcome(
423
+ "state_corrupt",
424
+ "cannot normalize v1 state without session_id",
425
+ { raw },
426
+ );
427
+ }
428
+
429
+ const lastLine = coerceNonNegInt(raw.last_line_number, 0);
430
+ const agentId =
431
+ raw.agent_id != null
432
+ ? String(raw.agent_id)
433
+ : ctx.agent_id != null
434
+ ? String(ctx.agent_id)
435
+ : "";
436
+
437
+ const state = {
438
+ schema_version: 2,
439
+ session_id: sessionId,
440
+ client:
441
+ (typeof raw.client === "string" && raw.client) ||
442
+ (typeof ctx.client === "string" && ctx.client) ||
443
+ "",
444
+ agent_id: agentId,
445
+ // Preserve provenance used by capture.js owner reconciliation.
446
+ agent_id_source:
447
+ raw.agent_id_source != null
448
+ ? String(raw.agent_id_source)
449
+ : ctx.agent_id_source != null
450
+ ? String(ctx.agent_id_source)
451
+ : "",
452
+ transcript_path:
453
+ raw.transcript_path != null
454
+ ? String(raw.transcript_path)
455
+ : ctx.transcript_path != null
456
+ ? String(ctx.transcript_path)
457
+ : null,
458
+ datapack_id:
459
+ raw.datapack_id == null || raw.datapack_id === ""
460
+ ? null
461
+ : String(raw.datapack_id),
462
+ datapack_name:
463
+ raw.datapack_name == null || raw.datapack_name === ""
464
+ ? null
465
+ : String(raw.datapack_name),
466
+ created_at:
467
+ (typeof raw.created_at === "string" && raw.created_at) ||
468
+ (typeof raw.updated_at === "string" && raw.updated_at) ||
469
+ now,
470
+ updated_at: (typeof raw.updated_at === "string" && raw.updated_at) || now,
471
+ last_activity_at:
472
+ (typeof raw.last_activity_at === "string" && raw.last_activity_at) ||
473
+ (typeof raw.updated_at === "string" && raw.updated_at) ||
474
+ now,
475
+ conversation_id: conversationId,
476
+ conversation_epoch: 0,
477
+ last_line_number: lastLine,
478
+ in_flight: emptyInFlight(),
479
+ lifecycle: "active",
480
+ delivery: deliveryFromConversation(conversationId),
481
+ queued_exchanges: coerceNonNegInt(raw.queued_exchanges, 0),
482
+ attempt_count: coerceNonNegInt(raw.attempt_count, 0),
483
+ next_retry_at: raw.next_retry_at == null ? null : String(raw.next_retry_at),
484
+ failure_class: null,
485
+ last_error: null,
486
+ last_success_at:
487
+ raw.last_success_at == null ? null : String(raw.last_success_at),
488
+ blocked_reason: null,
489
+ consecutive_exchange_drops: 0,
490
+ };
491
+
492
+ const validated = validateV2(state);
493
+ if (!validated.ok) return validated;
494
+ return { ok: true, state: validated.state, migrated: true };
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Atomic persistence
499
+ // ---------------------------------------------------------------------------
500
+
501
+ /**
502
+ * Read and normalize a session watermark.
503
+ * Corrupt / truncated / unsupported → typed failure; file left intact.
504
+ * Missing file → { ok:false, missing:true } (not state_corrupt).
505
+ */
506
+ function readState(sessionId) {
507
+ const filePath = watermarkPath(sessionId);
508
+ let rawText;
509
+ try {
510
+ rawText = fs.readFileSync(filePath, "utf8");
511
+ } catch (err) {
512
+ if (err && err.code === "ENOENT") {
513
+ return {
514
+ ok: false,
515
+ missing: true,
516
+ session_id: sessionId,
517
+ path: filePath,
518
+ failure_class: null,
519
+ error: "watermark missing",
520
+ };
521
+ }
522
+ return blockedOutcome("state_corrupt", `read failed: ${err.message}`, {
523
+ session_id: sessionId,
524
+ path: filePath,
525
+ });
526
+ }
527
+
528
+ let parsed;
529
+ try {
530
+ parsed = JSON.parse(rawText);
531
+ } catch (err) {
532
+ return blockedOutcome(
533
+ "state_corrupt",
534
+ `malformed JSON: ${err.message}`,
535
+ { session_id: sessionId, path: filePath },
536
+ );
537
+ }
538
+
539
+ const normalized = normalizeState(parsed, { session_id: sessionId });
540
+ if (!normalized.ok) {
541
+ return {
542
+ ...normalized,
543
+ session_id: sessionId,
544
+ path: filePath,
545
+ };
546
+ }
547
+ return {
548
+ ok: true,
549
+ session_id: sessionId,
550
+ path: filePath,
551
+ state: normalized.state,
552
+ migrated: Boolean(normalized.migrated),
553
+ };
554
+ }
555
+
556
+ const MUTATION_LOCK_TTL_MS = 5000;
557
+ const MUTATION_LOCK_RETRIES = 100;
558
+ const MUTATION_LOCK_RETRY_MS = 10;
559
+
560
+ function sleepSync(ms) {
561
+ const cell = new Int32Array(new SharedArrayBuffer(4));
562
+ Atomics.wait(cell, 0, 0, ms);
563
+ }
564
+
565
+ function readMutationLockSnapshot(sessionId) {
566
+ try {
567
+ const raw = fs.readFileSync(mutationLockPath(sessionId), "utf8");
568
+ let value = null;
569
+ try {
570
+ value = JSON.parse(raw);
571
+ } catch {
572
+ /* malformed locks are reclaimable evidence */
573
+ }
574
+ return {
575
+ raw,
576
+ lock: value && typeof value === "object" ? value : null,
577
+ };
578
+ } catch (err) {
579
+ return {
580
+ raw: null,
581
+ lock: null,
582
+ missing: Boolean(err && err.code === "ENOENT"),
583
+ error: err && err.code !== "ENOENT" ? err.message : null,
584
+ };
585
+ }
586
+ }
587
+
588
+ function readMutationLock(sessionId) {
589
+ return readMutationLockSnapshot(sessionId).lock;
590
+ }
591
+
592
+ function mutationLockReclaimable(snapshot, now) {
593
+ if (!snapshot || snapshot.raw == null) return true;
594
+ const existing = snapshot.lock;
595
+ if (!existing) return true;
596
+ const pid = Number(existing.pid);
597
+ if (Number.isInteger(pid) && pid > 0) {
598
+ // Never evict a live owner merely because its short TTL elapsed. The lock
599
+ // protects synchronous local I/O, but a paused process must still retain
600
+ // mutual exclusion until it exits.
601
+ return !isPidAlive(pid);
602
+ }
603
+ const expiry = parseIsoMs(existing.expires_at);
604
+ return expiry == null || expiry <= now;
605
+ }
606
+
607
+ function mutationLockQuarantinePath(sessionId, action) {
608
+ return (
609
+ `${mutationLockPath(sessionId)}.${action}.${process.pid}.` +
610
+ crypto.randomBytes(8).toString("hex")
611
+ );
612
+ }
613
+
614
+ function restoreQuarantinedMutationLock(filePath, quarantinePath) {
615
+ try {
616
+ // A hard link restores the exact inode without overwriting a lock that a
617
+ // competing waiter may already have created in the rename gap.
618
+ fs.linkSync(quarantinePath, filePath);
619
+ } catch (err) {
620
+ if (!err || err.code !== "EEXIST") return false;
621
+ }
622
+ try {
623
+ fs.unlinkSync(quarantinePath);
624
+ } catch {
625
+ /* best effort; the uniquely named quarantine is never treated as a lock */
626
+ }
627
+ return true;
628
+ }
629
+
630
+ /**
631
+ * Atomically move the canonical lock aside and verify that the moved bytes are
632
+ * exactly the snapshot the caller classified. A competing stale taker can no
633
+ * longer unlink a freshly created owner lock and then claim success.
634
+ */
635
+ function quarantineMutationLock(sessionId, snapshot, action) {
636
+ const filePath = mutationLockPath(sessionId);
637
+ const quarantinePath = mutationLockQuarantinePath(sessionId, action);
638
+ try {
639
+ fs.renameSync(filePath, quarantinePath);
640
+ } catch (err) {
641
+ if (err && err.code === "ENOENT") return { ok: false, raced: true };
642
+ return { ok: false, error: `state lock ${action} failed: ${err.message}` };
643
+ }
644
+
645
+ let movedRaw = null;
646
+ try {
647
+ movedRaw = fs.readFileSync(quarantinePath, "utf8");
648
+ } catch (err) {
649
+ const restored = restoreQuarantinedMutationLock(filePath, quarantinePath);
650
+ return {
651
+ ok: false,
652
+ error:
653
+ `state lock ${action} verify failed: ${err.message}` +
654
+ (restored ? "" : "; original lock could not be restored"),
655
+ };
656
+ }
657
+ if (!snapshot || movedRaw !== snapshot.raw) {
658
+ const restored = restoreQuarantinedMutationLock(filePath, quarantinePath);
659
+ return restored
660
+ ? { ok: false, raced: true }
661
+ : {
662
+ ok: false,
663
+ error: `state lock ${action} raced and could not be restored`,
664
+ };
665
+ }
666
+ return { ok: true, path: quarantinePath };
667
+ }
668
+
669
+ function acquireMutationLock(sessionId) {
670
+ ensureDir(watermarkDir());
671
+ const filePath = mutationLockPath(sessionId);
672
+ let stabilizeAfterReclaim = false;
673
+ for (let attempt = 0; attempt < MUTATION_LOCK_RETRIES; attempt++) {
674
+ const now = Date.now();
675
+ const lock = {
676
+ token: crypto.randomBytes(16).toString("hex"),
677
+ pid: process.pid,
678
+ expires_at: new Date(now + MUTATION_LOCK_TTL_MS).toISOString(),
679
+ session_id: sessionId,
680
+ };
681
+ try {
682
+ fs.writeFileSync(filePath, `${JSON.stringify(lock)}\n`, {
683
+ flag: "wx",
684
+ mode: 0o600,
685
+ });
686
+ if (stabilizeAfterReclaim) {
687
+ // Give stale contenders that read the old owner before quarantine one
688
+ // retry interval to finish their verified rename. If one displaced and
689
+ // restored this lock, the token remains ours; if another owner won the
690
+ // create race, retry instead of entering the critical section.
691
+ sleepSync(MUTATION_LOCK_RETRY_MS);
692
+ const owned = readMutationLock(sessionId);
693
+ if (!owned || String(owned.token) !== String(lock.token)) continue;
694
+ }
695
+ return { ok: true, lock };
696
+ } catch (err) {
697
+ if (!err || err.code !== "EEXIST") {
698
+ return blockedOutcome("transient", `state lock failed: ${err.message}`, {
699
+ session_id: sessionId,
700
+ });
701
+ }
702
+ }
703
+
704
+ const snapshot = readMutationLockSnapshot(sessionId);
705
+ if (mutationLockReclaimable(snapshot, now)) {
706
+ const quarantined = quarantineMutationLock(
707
+ sessionId,
708
+ snapshot,
709
+ "stale",
710
+ );
711
+ if (quarantined.ok) {
712
+ try {
713
+ fs.unlinkSync(quarantined.path);
714
+ } catch {
715
+ /* unique quarantine is inert; acquisition may still continue */
716
+ }
717
+ stabilizeAfterReclaim = true;
718
+ continue;
719
+ }
720
+ if (quarantined.error) {
721
+ return blockedOutcome("transient", quarantined.error, {
722
+ session_id: sessionId,
723
+ });
724
+ }
725
+ sleepSync(MUTATION_LOCK_RETRY_MS);
726
+ continue;
727
+ }
728
+ if (snapshot.error) {
729
+ return blockedOutcome(
730
+ "transient",
731
+ `state lock read failed: ${snapshot.error}`,
732
+ { session_id: sessionId },
733
+ );
734
+ }
735
+ sleepSync(MUTATION_LOCK_RETRY_MS);
736
+ }
737
+ return blockedOutcome("transient", "state mutation lock busy", {
738
+ session_id: sessionId,
739
+ path: mutationLockPath(sessionId),
740
+ });
741
+ }
742
+
743
+ function releaseMutationLock(sessionId, token) {
744
+ const snapshot = readMutationLockSnapshot(sessionId);
745
+ const existing = snapshot.lock;
746
+ if (!existing || String(existing.token) !== String(token)) return false;
747
+ const quarantined = quarantineMutationLock(sessionId, snapshot, "release");
748
+ if (!quarantined.ok) return false;
749
+ try {
750
+ fs.unlinkSync(quarantined.path);
751
+ return true;
752
+ } catch {
753
+ return false;
754
+ }
755
+ }
756
+
757
+ function lifecycleRank(value) {
758
+ return value === "closed" ? 2 : value === "closing" ? 1 : 0;
759
+ }
760
+
761
+ const STATE_LOCK_LOST_CODE = "MEKO_STATE_LOCK_LOST";
762
+
763
+ function assertMutationLockOwner(sessionId, token) {
764
+ const existing = readMutationLock(sessionId);
765
+ if (existing && String(existing.token) === String(token)) return;
766
+ const err = new Error("state mutation lock ownership lost before commit");
767
+ err.code = STATE_LOCK_LOST_CODE;
768
+ throw err;
769
+ }
770
+
771
+ function writeStateUnlocked(sessionId, state, options) {
772
+ const opts = options && typeof options === "object" ? options : {};
773
+ if (!opts.lockToken) {
774
+ return blockedOutcome(
775
+ "transient",
776
+ "state mutation lock token missing before commit",
777
+ { session_id: sessionId },
778
+ );
779
+ }
780
+ const validated = validateV2(state);
781
+ if (!validated.ok) return { ...validated, session_id: sessionId };
782
+
783
+ let next = { ...validated.state, session_id: sessionId };
784
+ const current = readState(sessionId);
785
+ if (!current.ok && !current.missing) return current;
786
+ if (current.ok && current.state) {
787
+ const disk = current.state;
788
+ if (next.conversation_epoch < disk.conversation_epoch) {
789
+ return blockedOutcome(
790
+ "ownership",
791
+ `stale conversation_epoch write: ${next.conversation_epoch} < ${disk.conversation_epoch}`,
792
+ { session_id: sessionId },
793
+ );
794
+ }
795
+ if (next.conversation_epoch === disk.conversation_epoch) {
796
+ const candidateMissingConversation =
797
+ !next.conversation_id && Boolean(disk.conversation_id);
798
+ if (
799
+ next.conversation_id &&
800
+ disk.conversation_id &&
801
+ next.conversation_id !== disk.conversation_id
802
+ ) {
803
+ return blockedOutcome(
804
+ "ownership",
805
+ "conversation_id changed without advancing conversation_epoch",
806
+ { session_id: sessionId },
807
+ );
808
+ }
809
+ if (candidateMissingConversation) {
810
+ // Conversation creation may complete while another process still
811
+ // holds the pre-create snapshot. Clearing ownership requires an epoch
812
+ // transition; a same-epoch stale write must retain the new ID.
813
+ next.conversation_id = disk.conversation_id;
814
+ if (next.delivery === "needs_conversation") {
815
+ next.delivery = disk.delivery;
816
+ }
817
+ }
818
+ if (next.last_line_number < disk.last_line_number) {
819
+ return blockedOutcome(
820
+ "ownership",
821
+ `non-monotonic persisted cursor: ${next.last_line_number} < ${disk.last_line_number}`,
822
+ { session_id: sessionId },
823
+ );
824
+ }
825
+ // Preserve extension fields (for example legacy import markers) and a
826
+ // concurrent SessionEnd lifecycle transition when a checkpoint writes a
827
+ // state snapshot it loaded before the transition.
828
+ next = { ...disk, ...next };
829
+ if (
830
+ !opts.allowLifecycleReset &&
831
+ lifecycleRank(next.lifecycle) < lifecycleRank(disk.lifecycle)
832
+ ) {
833
+ const candidateDelivery = next.delivery;
834
+ next.lifecycle = disk.lifecycle;
835
+ if (["idle", "complete"].includes(candidateDelivery)) {
836
+ next.delivery = disk.delivery;
837
+ next.queued_exchanges = Math.max(
838
+ coerceNonNegInt(next.queued_exchanges, 0),
839
+ coerceNonNegInt(disk.queued_exchanges, 0),
840
+ );
841
+ }
842
+ }
843
+ }
844
+ }
845
+ next.updated_at = nowIso();
846
+ const filePath = watermarkPath(sessionId);
847
+ try {
848
+ atomicWriteJson(filePath, next, {
849
+ // The temp file is fully written and fsynced before this callback. Check
850
+ // ownership at the narrowest dependency-free commit boundary: directly
851
+ // before the atomic rename that replaces durable session state.
852
+ beforeRename: () => assertMutationLockOwner(sessionId, opts.lockToken),
853
+ });
854
+ } catch (err) {
855
+ if (err && err.code === STATE_LOCK_LOST_CODE) {
856
+ return blockedOutcome("transient", err.message, {
857
+ session_id: sessionId,
858
+ path: filePath,
859
+ });
860
+ }
861
+ return blockedOutcome("transient", `write failed: ${err.message}`, {
862
+ session_id: sessionId,
863
+ path: filePath,
864
+ });
865
+ }
866
+ return { ok: true, session_id: sessionId, path: filePath, state: next };
867
+ }
868
+
869
+ /**
870
+ * Persist a v2 state object atomically under the short-lived state mutation
871
+ * lock. Cursor/epoch regressions are rejected against the fresh on-disk state.
872
+ */
873
+ function writeState(sessionId, state, options) {
874
+ const acquired = acquireMutationLock(sessionId);
875
+ if (!acquired.ok) return acquired;
876
+ try {
877
+ return writeStateUnlocked(sessionId, state, {
878
+ ...(options && typeof options === "object" ? options : {}),
879
+ lockToken: acquired.lock.token,
880
+ });
881
+ } finally {
882
+ releaseMutationLock(sessionId, acquired.lock.token);
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Serialize a read-modify-write against fresh state. The mutator receives the
888
+ * readState outcome and returns either a candidate state or a typed failure.
889
+ */
890
+ function mutateState(sessionId, mutator, options) {
891
+ if (typeof mutator !== "function") {
892
+ return blockedOutcome("persistent", "state mutator must be a function", {
893
+ session_id: sessionId,
894
+ });
895
+ }
896
+ const acquired = acquireMutationLock(sessionId);
897
+ if (!acquired.ok) return acquired;
898
+ try {
899
+ const current = readState(sessionId);
900
+ const result = mutator(current);
901
+ if (result && result.ok === false) return result;
902
+ const candidate = result && result.state ? result.state : result;
903
+ return writeStateUnlocked(sessionId, candidate, {
904
+ ...(options && typeof options === "object" ? options : {}),
905
+ lockToken: acquired.lock.token,
906
+ });
907
+ } catch (err) {
908
+ return blockedOutcome("transient", `state mutation failed: ${err.message}`, {
909
+ session_id: sessionId,
910
+ });
911
+ } finally {
912
+ releaseMutationLock(sessionId, acquired.lock.token);
913
+ }
914
+ }
915
+
916
+ /**
917
+ * SessionStart intent write — durable before any network call.
918
+ * lifecycle=active; delivery=needs_conversation when conversation_id absent.
919
+ */
920
+ function writeSessionIntent(sessionId, fields) {
921
+ const f = fields && typeof fields === "object" ? fields : {};
922
+ return mutateState(
923
+ sessionId,
924
+ (existing) => {
925
+ // Intent must not invent a cursor on corrupt files.
926
+ if (
927
+ !existing.ok &&
928
+ !existing.missing &&
929
+ existing.failure_class === "state_corrupt"
930
+ ) {
931
+ return {
932
+ ...existing,
933
+ error:
934
+ existing.error ||
935
+ "refusing writeSessionIntent over corrupt watermark",
936
+ };
937
+ }
938
+
939
+ const base =
940
+ existing.ok && existing.state
941
+ ? { ...existing.state }
942
+ : {
943
+ schema_version: 2,
944
+ session_id: sessionId,
945
+ client: "",
946
+ agent_id: "",
947
+ agent_id_source: "",
948
+ transcript_path: null,
949
+ datapack_id: null,
950
+ datapack_name: null,
951
+ created_at: nowIso(),
952
+ updated_at: nowIso(),
953
+ last_activity_at: nowIso(),
954
+ conversation_id: null,
955
+ conversation_epoch: 0,
956
+ last_line_number: 0,
957
+ in_flight: emptyInFlight(),
958
+ lifecycle: "active",
959
+ delivery: "needs_conversation",
960
+ queued_exchanges: 0,
961
+ attempt_count: 0,
962
+ next_retry_at: null,
963
+ failure_class: null,
964
+ last_error: null,
965
+ last_success_at: null,
966
+ blocked_reason: null,
967
+ // null = finalize unless client is opencode (isResumableState).
968
+ pending_turn_policy: null,
969
+ };
970
+
971
+ const conversationId =
972
+ f.conversation_id !== undefined
973
+ ? f.conversation_id == null || f.conversation_id === ""
974
+ ? null
975
+ : String(f.conversation_id)
976
+ : base.conversation_id;
977
+
978
+ const merged = {
979
+ ...base,
980
+ session_id: sessionId,
981
+ schema_version: 2,
982
+ client: f.client != null ? String(f.client) : base.client || "",
983
+ agent_id: f.agent_id != null ? String(f.agent_id) : base.agent_id || "",
984
+ agent_id_source:
985
+ f.agent_id_source != null
986
+ ? String(f.agent_id_source)
987
+ : base.agent_id_source || "",
988
+ transcript_path:
989
+ f.transcript_path !== undefined
990
+ ? f.transcript_path
991
+ : base.transcript_path,
992
+ datapack_id:
993
+ f.datapack_id !== undefined ? f.datapack_id : base.datapack_id,
994
+ datapack_name:
995
+ f.datapack_name !== undefined ? f.datapack_name : base.datapack_name,
996
+ conversation_id: conversationId,
997
+ lifecycle: "active",
998
+ delivery: hasConversationId(conversationId)
999
+ ? base.delivery === "needs_conversation"
1000
+ ? "idle"
1001
+ : base.delivery || "idle"
1002
+ : "needs_conversation",
1003
+ last_activity_at: nowIso(),
1004
+ updated_at: nowIso(),
1005
+ // MEKO-635: preserve or set pending_turn_policy. Only "resumable" is a
1006
+ // meaningful non-default value today; anything else stays null.
1007
+ pending_turn_policy:
1008
+ f.pending_turn_policy !== undefined
1009
+ ? (f.pending_turn_policy === "resumable" ? "resumable" : null)
1010
+ : (base.pending_turn_policy === "resumable" ? "resumable" : null),
1011
+ };
1012
+
1013
+ if (f.last_line_number != null && existing.missing) {
1014
+ merged.last_line_number = coerceNonNegInt(f.last_line_number, 0);
1015
+ }
1016
+ return merged;
1017
+ },
1018
+ { allowLifecycleReset: true },
1019
+ );
1020
+ }
1021
+
1022
+ // ---------------------------------------------------------------------------
1023
+ // Cursor / epoch
1024
+ // ---------------------------------------------------------------------------
1025
+
1026
+ /**
1027
+ * Advance last_line_number monotonically within the current conversation_epoch.
1028
+ * Rejects non-monotonic advances. Optionally clears in_flight seed.
1029
+ *
1030
+ * @param {object} state
1031
+ * @param {number} nextLine
1032
+ * @param {{ clearSeed?: boolean, seed?: null }} [opts]
1033
+ * @returns {{ ok:true, state } | { ok:false, failure_class, error }}
1034
+ */
1035
+ function advanceCursor(state, nextLine, opts) {
1036
+ const options = opts && typeof opts === "object" ? opts : {};
1037
+ const validated = validateV2(state);
1038
+ if (!validated.ok) return validated;
1039
+
1040
+ const next = Number(nextLine);
1041
+ if (!Number.isInteger(next) || next < 0) {
1042
+ return {
1043
+ ok: false,
1044
+ failure_class: "persistent",
1045
+ error: `nextLine must be a non-negative integer, got ${nextLine}`,
1046
+ };
1047
+ }
1048
+ if (next < validated.state.last_line_number) {
1049
+ return {
1050
+ ok: false,
1051
+ failure_class: "persistent",
1052
+ error: `non-monotonic cursor advance: ${next} < ${validated.state.last_line_number}`,
1053
+ };
1054
+ }
1055
+
1056
+ const out = {
1057
+ ...validated.state,
1058
+ last_line_number: next,
1059
+ updated_at: nowIso(),
1060
+ last_activity_at: nowIso(),
1061
+ in_flight: cloneInFlight(validated.state.in_flight),
1062
+ };
1063
+
1064
+ const clearSeed =
1065
+ options.clearSeed === true ||
1066
+ options["seed clear"] === true ||
1067
+ Object.prototype.hasOwnProperty.call(options, "seed") &&
1068
+ options.seed == null;
1069
+
1070
+ if (clearSeed) {
1071
+ out.in_flight = emptyInFlight();
1072
+ }
1073
+
1074
+ return { ok: true, state: out };
1075
+ }
1076
+
1077
+ /**
1078
+ * Explicit rebucket: increment conversation_epoch and reset cursor.
1079
+ */
1080
+ function rebucket(state, newConversationId) {
1081
+ const validated = validateV2(state);
1082
+ if (!validated.ok) return validated;
1083
+ if (!hasConversationId(newConversationId)) {
1084
+ return {
1085
+ ok: false,
1086
+ failure_class: "persistent",
1087
+ error: "rebucket requires a non-empty conversation_id",
1088
+ };
1089
+ }
1090
+ const out = {
1091
+ ...validated.state,
1092
+ conversation_id: String(newConversationId),
1093
+ conversation_epoch: validated.state.conversation_epoch + 1,
1094
+ last_line_number: 0,
1095
+ in_flight: emptyInFlight(),
1096
+ delivery: "idle",
1097
+ failure_class: null,
1098
+ last_error: null,
1099
+ blocked_reason: null,
1100
+ next_retry_at: null,
1101
+ attempt_count: 0,
1102
+ consecutive_exchange_drops: 0,
1103
+ // A replacement guard belongs only to the conversation that set it.
1104
+ recovered_from_conversation_id: null,
1105
+ missing_conversation_streak: null,
1106
+ updated_at: nowIso(),
1107
+ last_activity_at: nowIso(),
1108
+ };
1109
+ return { ok: true, state: out };
1110
+ }
1111
+
1112
+ // ---------------------------------------------------------------------------
1113
+ // Leases
1114
+ // ---------------------------------------------------------------------------
1115
+
1116
+ function readLeaseFile(sessionId) {
1117
+ const filePath = leasePath(sessionId);
1118
+ try {
1119
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
1120
+ if (!raw || typeof raw !== "object") return null;
1121
+ return raw;
1122
+ } catch (err) {
1123
+ if (err && err.code === "ENOENT") return null;
1124
+ return null;
1125
+ }
1126
+ }
1127
+
1128
+ function leaseExpired(lease, nowMs) {
1129
+ if (!lease) return true;
1130
+ const exp = parseIsoMs(lease.expires_at);
1131
+ if (exp == null) return true;
1132
+ return nowMs >= exp;
1133
+ }
1134
+
1135
+ function leaseReclaimable(lease, nowMs) {
1136
+ if (!lease) return true;
1137
+ if (leaseExpired(lease, nowMs)) return true;
1138
+ const pid = Number(lease.pid);
1139
+ if (Number.isInteger(pid) && pid > 0 && !isPidAlive(pid)) return true;
1140
+ return false;
1141
+ }
1142
+
1143
+ function buildLease(sessionId, ttlSeconds, pid, nowMs) {
1144
+ const acquired = nowIso(nowMs);
1145
+ const expires = nowIso(nowMs + ttlSeconds * 1000);
1146
+ return {
1147
+ token: crypto.randomBytes(16).toString("hex"),
1148
+ pid: Number.isInteger(pid) && pid > 0 ? pid : process.pid,
1149
+ acquired_at: acquired,
1150
+ expires_at: expires,
1151
+ session_id: sessionId,
1152
+ };
1153
+ }
1154
+
1155
+ /**
1156
+ * Acquire a tokenized drain lease (atomic create wx, or reclaim if expired/dead).
1157
+ * @returns {{ ok:true, lease } | { ok:false, failure_class:"ownership", error, lease? }}
1158
+ */
1159
+ function acquireLease(sessionId, options) {
1160
+ const opts = options && typeof options === "object" ? options : {};
1161
+ const ttl = Math.max(
1162
+ 1,
1163
+ opts.ttlSeconds != null ? Number(opts.ttlSeconds) : leaseTtlSeconds(),
1164
+ );
1165
+ const pid =
1166
+ opts.pid != null && Number.isInteger(Number(opts.pid))
1167
+ ? Number(opts.pid)
1168
+ : process.pid;
1169
+ const nowMs = opts.now != null ? Number(opts.now) : Date.now();
1170
+ const filePath = leasePath(sessionId);
1171
+ ensureDir(watermarkDir());
1172
+
1173
+ const lease = buildLease(sessionId, ttl, pid, nowMs);
1174
+ const payload = `${JSON.stringify(lease)}\n`;
1175
+
1176
+ for (let attempt = 0; attempt < 3; attempt++) {
1177
+ try {
1178
+ fs.writeFileSync(filePath, payload, { flag: "wx", mode: 0o600 });
1179
+ return { ok: true, lease };
1180
+ } catch (err) {
1181
+ if (!err || err.code !== "EEXIST") {
1182
+ return {
1183
+ ok: false,
1184
+ failure_class: "transient",
1185
+ error: `lease acquire failed: ${err.message}`,
1186
+ };
1187
+ }
1188
+ const existing = readLeaseFile(sessionId);
1189
+ if (!leaseReclaimable(existing, nowMs)) {
1190
+ return {
1191
+ ok: false,
1192
+ failure_class: "ownership",
1193
+ error: "lease held by another worker",
1194
+ lease: existing,
1195
+ };
1196
+ }
1197
+ try {
1198
+ fs.unlinkSync(filePath);
1199
+ } catch (unlinkErr) {
1200
+ if (unlinkErr && unlinkErr.code !== "ENOENT") {
1201
+ return {
1202
+ ok: false,
1203
+ failure_class: "ownership",
1204
+ error: `could not reclaim lease: ${unlinkErr.message}`,
1205
+ lease: existing,
1206
+ };
1207
+ }
1208
+ }
1209
+ }
1210
+ }
1211
+ return {
1212
+ ok: false,
1213
+ failure_class: "ownership",
1214
+ error: "lease acquire raced; giving up",
1215
+ };
1216
+ }
1217
+
1218
+ function renewLease(sessionId, token, options) {
1219
+ const opts = options && typeof options === "object" ? options : {};
1220
+ const ttl = Math.max(
1221
+ 1,
1222
+ opts.ttlSeconds != null ? Number(opts.ttlSeconds) : leaseTtlSeconds(),
1223
+ );
1224
+ const nowMs = opts.now != null ? Number(opts.now) : Date.now();
1225
+ const existing = readLeaseFile(sessionId);
1226
+ if (!existing) {
1227
+ return {
1228
+ ok: false,
1229
+ failure_class: "ownership",
1230
+ error: "lease missing",
1231
+ };
1232
+ }
1233
+ if (String(existing.token) !== String(token)) {
1234
+ return {
1235
+ ok: false,
1236
+ failure_class: "ownership",
1237
+ error: "lease token mismatch",
1238
+ lease: existing,
1239
+ };
1240
+ }
1241
+ const renewed = {
1242
+ ...existing,
1243
+ expires_at: nowIso(nowMs + ttl * 1000),
1244
+ };
1245
+ try {
1246
+ atomicWriteJson(leasePath(sessionId), renewed);
1247
+ } catch (err) {
1248
+ return {
1249
+ ok: false,
1250
+ failure_class: "transient",
1251
+ error: `lease renew failed: ${err.message}`,
1252
+ };
1253
+ }
1254
+ return { ok: true, lease: renewed };
1255
+ }
1256
+
1257
+ function releaseLease(sessionId, token) {
1258
+ const filePath = leasePath(sessionId);
1259
+ const existing = readLeaseFile(sessionId);
1260
+ if (!existing) {
1261
+ return { ok: true, released: false, missing: true };
1262
+ }
1263
+ if (String(existing.token) !== String(token)) {
1264
+ return {
1265
+ ok: false,
1266
+ failure_class: "ownership",
1267
+ error: "lease token mismatch; not released",
1268
+ lease: existing,
1269
+ };
1270
+ }
1271
+ try {
1272
+ fs.unlinkSync(filePath);
1273
+ } catch (err) {
1274
+ if (err && err.code === "ENOENT") {
1275
+ return { ok: true, released: false, missing: true };
1276
+ }
1277
+ return {
1278
+ ok: false,
1279
+ failure_class: "transient",
1280
+ error: `lease release failed: ${err.message}`,
1281
+ };
1282
+ }
1283
+ return { ok: true, released: true };
1284
+ }
1285
+
1286
+ // ---------------------------------------------------------------------------
1287
+ // Retry classification / backoff
1288
+ // ---------------------------------------------------------------------------
1289
+
1290
+ const PERSISTENT_PATTERNS = [
1291
+ ["free_tier_limit_reached", /free[_\s-]?tier.*limit|free_tier_limit_reached/i],
1292
+ ["quota_exceeded", /quota[_\s-]?(reached|exceeded)|(?:reached|exceeded).*quota/i],
1293
+ ["usage_limit_reached", /usage[_\s-]?limit[_\s-]?(reached|exceeded)/i],
1294
+ ["plan_limit_reached", /plan[_\s-]?limit[_\s-]?(reached|exceeded)/i],
1295
+ ["payment_required", /payment[_\s-]?required/i],
1296
+ ["subscription_required", /subscription[_\s-]?(required|inactive|expired)/i],
1297
+ ["entitlement_denied", /entitlement.*(denied|required|missing)|(?:denied|required|missing).*entitlement/i],
1298
+ ];
1299
+
1300
+ /**
1301
+ * Classify an Error, message string, or known failure_class token.
1302
+ * @returns {"transient"|"persistent"|"state_corrupt"|"ownership"|"datapack"}
1303
+ */
1304
+ function classifyFailure(errOrClass) {
1305
+ if (errOrClass == null) return "transient";
1306
+
1307
+ if (typeof errOrClass === "string") {
1308
+ const trimmed = errOrClass.trim();
1309
+ if (FAILURE_CLASSES.includes(trimmed)) return trimmed;
1310
+ return classifyFailure({ message: trimmed });
1311
+ }
1312
+
1313
+ if (typeof errOrClass === "object") {
1314
+ if (
1315
+ typeof errOrClass.failure_class === "string" &&
1316
+ FAILURE_CLASSES.includes(errOrClass.failure_class)
1317
+ ) {
1318
+ return errOrClass.failure_class;
1319
+ }
1320
+ const message = String(
1321
+ errOrClass.message != null ? errOrClass.message : errOrClass,
1322
+ );
1323
+ const normalized = message.toLowerCase();
1324
+ const statusCode = Number(errOrClass.statusCode || errOrClass.status);
1325
+
1326
+ if (
1327
+ /state[_\s-]?corrupt|malformed json|unsupported schema/i.test(message)
1328
+ ) {
1329
+ return "state_corrupt";
1330
+ }
1331
+ if (
1332
+ /ownership|agent_id_mismatch|wrong owner|lease (held|token)/i.test(
1333
+ normalized,
1334
+ )
1335
+ ) {
1336
+ return "ownership";
1337
+ }
1338
+ if (/datapack/i.test(normalized) && /mismatch|denied|invalid|missing/i.test(normalized)) {
1339
+ return "datapack";
1340
+ }
1341
+
1342
+ if (statusCode === 402 || statusCode === 403) return "persistent";
1343
+ if (/\bmcp http (402|403)\b/.test(normalized)) return "persistent";
1344
+ if (/\bhttp[_\s-]?(402|403)\b/.test(normalized)) return "persistent";
1345
+ if (/^http_(402|403)$/i.test(message.trim())) return "persistent";
1346
+ for (const [, pattern] of PERSISTENT_PATTERNS) {
1347
+ if (pattern.test(normalized)) return "persistent";
1348
+ }
1349
+ }
1350
+
1351
+ return "transient";
1352
+ }
1353
+
1354
+ /**
1355
+ * Compute next_retry_at ISO timestamp.
1356
+ * transient: exponential 30s→600s with ±jitter; persistent/blocked: 6h.
1357
+ */
1358
+ function nextRetryAt(args) {
1359
+ const a = args && typeof args === "object" ? args : {};
1360
+ const failureClass = classifyFailure(a.failure_class || a);
1361
+ const attemptCount = Math.max(0, coerceNonNegInt(a.attempt_count, 0));
1362
+ const nowMs = a.now != null ? Number(a.now) : Date.now();
1363
+ const jitterFrac =
1364
+ a.jitter != null ? Number(a.jitter) : retryJitter();
1365
+ const random =
1366
+ typeof a.random === "number" ? a.random : Math.random();
1367
+
1368
+ let delaySec;
1369
+ if (failureClass === "transient") {
1370
+ const base = retryBaseSeconds();
1371
+ const max = retryMaxSeconds();
1372
+ const exp = Math.max(0, attemptCount <= 0 ? 0 : attemptCount - 1);
1373
+ const raw = Math.min(max, base * Math.pow(2, exp));
1374
+ const jitter = Number.isFinite(jitterFrac) ? jitterFrac : 0.2;
1375
+ const factor = 1 + (random * 2 - 1) * jitter;
1376
+ delaySec = Math.max(1, Math.round(raw * factor));
1377
+ } else {
1378
+ // persistent | state_corrupt | ownership | datapack
1379
+ delaySec = persistentRetrySeconds();
1380
+ }
1381
+
1382
+ return nowIso(nowMs + delaySec * 1000);
1383
+ }
1384
+
1385
+ /**
1386
+ * Pure delay (seconds) helper for tests — same policy as nextRetryAt without ISO.
1387
+ */
1388
+ function retryDelaySeconds(args) {
1389
+ const a = args && typeof args === "object" ? args : {};
1390
+ const failureClass = classifyFailure(a.failure_class || a);
1391
+ const attemptCount = Math.max(0, coerceNonNegInt(a.attempt_count, 0));
1392
+ const jitterFrac =
1393
+ a.jitter != null ? Number(a.jitter) : retryJitter();
1394
+ const random =
1395
+ typeof a.random === "number" ? a.random : Math.random();
1396
+
1397
+ if (failureClass === "transient") {
1398
+ const base = retryBaseSeconds();
1399
+ const max = retryMaxSeconds();
1400
+ const exp = Math.max(0, attemptCount <= 0 ? 0 : attemptCount - 1);
1401
+ const raw = Math.min(max, base * Math.pow(2, exp));
1402
+ const jitter = Number.isFinite(jitterFrac) ? jitterFrac : 0.2;
1403
+ const factor = 1 + (random * 2 - 1) * jitter;
1404
+ return Math.max(1, Math.round(raw * factor));
1405
+ }
1406
+ return persistentRetrySeconds();
1407
+ }
1408
+
1409
+ // ---------------------------------------------------------------------------
1410
+ // Scan
1411
+ // ---------------------------------------------------------------------------
1412
+
1413
+ function timerLooksLive(sessionId) {
1414
+ try {
1415
+ const pid = Number.parseInt(
1416
+ fs.readFileSync(timerPidPath(sessionId), "utf8"),
1417
+ 10,
1418
+ );
1419
+ return isPidAlive(pid);
1420
+ } catch {
1421
+ return false;
1422
+ }
1423
+ }
1424
+
1425
+ function buildEligibility(state, nowMs, options) {
1426
+ const opts = options && typeof options === "object" ? options : {};
1427
+ const now = nowMs != null ? nowMs : Date.now();
1428
+ const activityMs =
1429
+ parseIsoMs(state.last_activity_at) ||
1430
+ parseIsoMs(state.updated_at) ||
1431
+ null;
1432
+ const idleSeconds =
1433
+ activityMs == null ? null : Math.max(0, Math.floor((now - activityMs) / 1000));
1434
+ const minIdle = recoveryMinIdle();
1435
+ const abandoned =
1436
+ state.lifecycle === "active" &&
1437
+ idleSeconds != null &&
1438
+ idleSeconds >= minIdle &&
1439
+ !timerLooksLive(state.session_id);
1440
+ const closingEligible =
1441
+ state.lifecycle === "closing" || state.lifecycle === "closed";
1442
+ const hasConv = hasConversationId(state.conversation_id);
1443
+ const hasAgent =
1444
+ typeof state.agent_id === "string" && state.agent_id.trim() !== "";
1445
+ const retryMs = parseIsoMs(state.next_retry_at);
1446
+ const retryDue = retryMs == null || retryMs <= now;
1447
+ const persistentStartupOverride =
1448
+ Boolean(opts.forcePersistentRetry) &&
1449
+ state.failure_class === "persistent" &&
1450
+ !retryDue;
1451
+ const pendingWork =
1452
+ coerceNonNegInt(state.queued_exchanges, 0) > 0 ||
1453
+ ["needs_conversation", "pending", "draining", "retry_wait", "blocked"].includes(
1454
+ state.delivery,
1455
+ );
1456
+ const ownerReady = hasAgent && (hasConv || state.delivery === "needs_conversation");
1457
+
1458
+ const eligibleForRecovery =
1459
+ pendingWork &&
1460
+ ownerReady &&
1461
+ (retryDue || persistentStartupOverride) &&
1462
+ state.delivery !== "complete" &&
1463
+ (closingEligible || abandoned || state.lifecycle !== "active");
1464
+
1465
+ return {
1466
+ has_conversation: hasConv,
1467
+ has_agent: hasAgent,
1468
+ delivery: state.delivery,
1469
+ lifecycle: state.lifecycle,
1470
+ queued_exchanges: coerceNonNegInt(state.queued_exchanges, 0),
1471
+ next_retry_at: state.next_retry_at,
1472
+ retry_due: retryDue,
1473
+ persistent_startup_override: persistentStartupOverride,
1474
+ last_activity_at: state.last_activity_at || null,
1475
+ idle_seconds: idleSeconds,
1476
+ timer_live: timerLooksLive(state.session_id),
1477
+ abandoned,
1478
+ eligible_for_recovery: eligibleForRecovery,
1479
+ sort_key: state.updated_at || state.last_activity_at || "",
1480
+ };
1481
+ }
1482
+
1483
+ function sessionHealthBucket(entry) {
1484
+ if (!entry || entry.ok === false) {
1485
+ const fc = entry && entry.failure_class;
1486
+ if (
1487
+ fc === "state_corrupt" ||
1488
+ fc === "ownership" ||
1489
+ fc === "datapack" ||
1490
+ (entry && entry.delivery === "blocked")
1491
+ ) {
1492
+ return "blocked_action_required";
1493
+ }
1494
+ return "degraded_retrying";
1495
+ }
1496
+ const state = entry.state || entry;
1497
+ if (!state || typeof state !== "object") return "healthy";
1498
+
1499
+ const fc = state.failure_class;
1500
+ if (
1501
+ state.delivery === "blocked" ||
1502
+ fc === "state_corrupt" ||
1503
+ fc === "ownership" ||
1504
+ fc === "datapack"
1505
+ ) {
1506
+ return "blocked_action_required";
1507
+ }
1508
+ // A session that has delivered everything is healthy even if an earlier
1509
+ // attempt failed: failure_class records the last attempt, not outstanding
1510
+ // work. Without this, one transient blip pins a closed, fully drained
1511
+ // session to degraded_retrying forever and the rollup reports degraded with
1512
+ // an empty queue. Blocked classes are checked above and still win, since
1513
+ // those need a human.
1514
+ if (
1515
+ (state.delivery === "complete" || state.delivery === "idle") &&
1516
+ coerceNonNegInt(state.queued_exchanges, 0) === 0
1517
+ ) {
1518
+ return "healthy";
1519
+ }
1520
+ if (
1521
+ state.delivery === "retry_wait" ||
1522
+ fc === "transient" ||
1523
+ fc === "persistent"
1524
+ ) {
1525
+ return "degraded_retrying";
1526
+ }
1527
+ if (
1528
+ state.delivery === "needs_conversation" ||
1529
+ state.delivery === "pending" ||
1530
+ state.delivery === "draining" ||
1531
+ coerceNonNegInt(state.queued_exchanges, 0) > 0
1532
+ ) {
1533
+ return "catching_up";
1534
+ }
1535
+ return "healthy";
1536
+ }
1537
+
1538
+ /**
1539
+ * Uncapped discovery of all watermark files under MEKO_WATERMARK_DIR.
1540
+ * Returns normalized/blocked entries with eligibility metadata, oldest first.
1541
+ */
1542
+ function scanStates(options) {
1543
+ const opts = options && typeof options === "object" ? options : {};
1544
+ const includeBlocked = opts.includeBlocked !== false;
1545
+ const nowMs = opts.now != null ? Number(opts.now) : Date.now();
1546
+ const dir = watermarkDir();
1547
+ let names = [];
1548
+ try {
1549
+ names = fs.readdirSync(dir);
1550
+ } catch (err) {
1551
+ if (err && err.code === "ENOENT") {
1552
+ return { ok: true, entries: [], dir };
1553
+ }
1554
+ return {
1555
+ ok: false,
1556
+ failure_class: "transient",
1557
+ error: `scan failed: ${err.message}`,
1558
+ entries: [],
1559
+ dir,
1560
+ };
1561
+ }
1562
+
1563
+ const entries = [];
1564
+ for (const name of names) {
1565
+ if (!name.endsWith(".watermark.json")) continue;
1566
+ const sessionId = name.slice(0, -".watermark.json".length);
1567
+ if (!sessionId) continue;
1568
+ const read = readState(sessionId);
1569
+ if (!read.ok) {
1570
+ if (!includeBlocked) continue;
1571
+ entries.push({
1572
+ ...read,
1573
+ eligibility: null,
1574
+ health: sessionHealthBucket(read),
1575
+ });
1576
+ continue;
1577
+ }
1578
+ const eligibility = buildEligibility(read.state, nowMs, {
1579
+ forcePersistentRetry: Boolean(opts.forcePersistentRetry),
1580
+ });
1581
+ entries.push({
1582
+ ok: true,
1583
+ session_id: sessionId,
1584
+ path: read.path,
1585
+ state: read.state,
1586
+ migrated: read.migrated,
1587
+ eligibility,
1588
+ health: sessionHealthBucket(read),
1589
+ });
1590
+ }
1591
+
1592
+ entries.sort((a, b) => {
1593
+ const ak =
1594
+ (a.eligibility && a.eligibility.sort_key) ||
1595
+ (a.state && a.state.updated_at) ||
1596
+ "";
1597
+ const bk =
1598
+ (b.eligibility && b.eligibility.sort_key) ||
1599
+ (b.state && b.state.updated_at) ||
1600
+ "";
1601
+ return String(ak).localeCompare(String(bk));
1602
+ });
1603
+
1604
+ return { ok: true, entries, dir };
1605
+ }
1606
+
1607
+ function scanCompleteForDropClamp(scan) {
1608
+ return Boolean(
1609
+ scan &&
1610
+ scan.ok &&
1611
+ Array.isArray(scan.entries) &&
1612
+ scan.entries.every((entry) => entry && entry.ok !== false),
1613
+ );
1614
+ }
1615
+
1616
+ function sortOldestFirst(entries) {
1617
+ return [...(entries || [])].sort((a, b) => {
1618
+ const ak =
1619
+ (a.eligibility && a.eligibility.sort_key) ||
1620
+ (a.state && a.state.updated_at) ||
1621
+ "";
1622
+ const bk =
1623
+ (b.eligibility && b.eligibility.sort_key) ||
1624
+ (b.state && b.state.updated_at) ||
1625
+ "";
1626
+ return String(ak).localeCompare(String(bk));
1627
+ });
1628
+ }
1629
+
1630
+ // ---------------------------------------------------------------------------
1631
+ // Aggregate health
1632
+ // ---------------------------------------------------------------------------
1633
+
1634
+ function computeAggregateHealth(states, options) {
1635
+ const opts = options && typeof options === "object" ? options : {};
1636
+ const nowMs = opts.now != null ? Number(opts.now) : Date.now();
1637
+ const list = Array.isArray(states)
1638
+ ? states
1639
+ : states && Array.isArray(states.entries)
1640
+ ? states.entries
1641
+ : [];
1642
+
1643
+ const counts = {
1644
+ healthy: 0,
1645
+ catching_up: 0,
1646
+ degraded_retrying: 0,
1647
+ blocked_action_required: 0,
1648
+ };
1649
+ let queued = 0;
1650
+ let dropped = 0;
1651
+ let oldestPendingAge = null;
1652
+ let lastSuccessAt = null;
1653
+ const blockedReasons = [];
1654
+ const dropReasons = [];
1655
+ const sessions = [];
1656
+ let status = "healthy";
1657
+
1658
+ for (const entry of list) {
1659
+ const bucket = sessionHealthBucket(entry);
1660
+ counts[bucket] = (counts[bucket] || 0) + 1;
1661
+ if (HEALTH_PRECEDENCE[bucket] > HEALTH_PRECEDENCE[status]) {
1662
+ status = bucket;
1663
+ }
1664
+
1665
+ const state = entry && (entry.state || (entry.ok !== false ? entry : null));
1666
+ const sessionId =
1667
+ (entry && entry.session_id) ||
1668
+ (state && state.session_id) ||
1669
+ null;
1670
+ const q =
1671
+ state && typeof state === "object"
1672
+ ? coerceNonNegInt(state.queued_exchanges, 0)
1673
+ : 0;
1674
+ queued += q;
1675
+
1676
+ // Exchanges the server will never accept (see capture.js
1677
+ // classifyPersistentCaptureFailure, scope "exchange"). They are not
1678
+ // backlog — capture moved past them — but the user is still owed the
1679
+ // fact that some turns are missing, so they aggregate separately.
1680
+ const d =
1681
+ state && typeof state === "object"
1682
+ ? coerceNonNegInt(state.dropped_exchanges, 0)
1683
+ : 0;
1684
+ dropped += d;
1685
+ if (d > 0 && state.last_drop_reason) {
1686
+ const reason = String(state.last_drop_reason);
1687
+ if (!dropReasons.includes(reason)) dropReasons.push(reason);
1688
+ }
1689
+
1690
+ if (state && state.last_success_at) {
1691
+ if (
1692
+ !lastSuccessAt ||
1693
+ String(state.last_success_at) > String(lastSuccessAt)
1694
+ ) {
1695
+ lastSuccessAt = state.last_success_at;
1696
+ }
1697
+ }
1698
+
1699
+ const pendingLike =
1700
+ bucket !== "healthy" ||
1701
+ q > 0 ||
1702
+ (state &&
1703
+ ["pending", "draining", "retry_wait", "blocked"].includes(
1704
+ state.delivery,
1705
+ ));
1706
+ if (pendingLike && state) {
1707
+ const activityMs =
1708
+ parseIsoMs(state.last_activity_at) || parseIsoMs(state.updated_at);
1709
+ if (activityMs != null) {
1710
+ const age = Math.max(0, Math.floor((nowMs - activityMs) / 1000));
1711
+ if (oldestPendingAge == null || age > oldestPendingAge) {
1712
+ oldestPendingAge = age;
1713
+ }
1714
+ }
1715
+ }
1716
+
1717
+ if (bucket === "blocked_action_required") {
1718
+ const reason =
1719
+ (state && (state.blocked_reason || state.last_error)) ||
1720
+ (entry && entry.error) ||
1721
+ (entry && entry.failure_class) ||
1722
+ "blocked";
1723
+ if (reason && !blockedReasons.includes(String(reason))) {
1724
+ blockedReasons.push(String(reason));
1725
+ }
1726
+ }
1727
+
1728
+ sessions.push({
1729
+ session_id: sessionId,
1730
+ status: bucket,
1731
+ delivery: state ? state.delivery : entry && entry.delivery,
1732
+ failure_class:
1733
+ (state && state.failure_class) ||
1734
+ (entry && entry.failure_class) ||
1735
+ null,
1736
+ queued_exchanges: q,
1737
+ dropped_exchanges: d,
1738
+ lifecycle: state ? state.lifecycle : null,
1739
+ });
1740
+ }
1741
+
1742
+ return {
1743
+ schema_version: 1,
1744
+ status,
1745
+ updated_at: nowIso(nowMs),
1746
+ counts,
1747
+ queued_exchanges: queued,
1748
+ dropped_exchanges: dropped,
1749
+ oldest_pending_age_seconds: oldestPendingAge,
1750
+ last_success_at: lastSuccessAt,
1751
+ blocked_reasons: blockedReasons,
1752
+ drop_reasons: dropReasons,
1753
+ sessions,
1754
+ };
1755
+ }
1756
+
1757
+ function writeHealthCache(health, options) {
1758
+ const opts = options && typeof options === "object" ? options : {};
1759
+ const existing = readHealthCache();
1760
+ const merged = {
1761
+ ...(health && typeof health === "object" ? health : {}),
1762
+ schema_version: 1,
1763
+ updated_at: nowIso(),
1764
+ };
1765
+ if (
1766
+ merged.last_notified_status === undefined &&
1767
+ existing &&
1768
+ existing.last_notified_status !== undefined
1769
+ ) {
1770
+ merged.last_notified_status = existing.last_notified_status;
1771
+ }
1772
+ // Drop notices are one-shot per new count. Clamp only after a complete scan
1773
+ // proves the aggregate shrank because watermarks disappeared. A failed scan
1774
+ // or unreadable watermark may recover and must not re-announce old drops.
1775
+ if (
1776
+ merged.last_notified_dropped === undefined &&
1777
+ existing &&
1778
+ existing.last_notified_dropped !== undefined
1779
+ ) {
1780
+ const previous = coerceNonNegInt(existing.last_notified_dropped, 0);
1781
+ merged.last_notified_dropped = opts.clampDropped
1782
+ ? Math.min(previous, coerceNonNegInt(merged.dropped_exchanges, 0))
1783
+ : previous;
1784
+ }
1785
+ try {
1786
+ atomicWriteJson(healthCachePath(), merged);
1787
+ return { ok: true, path: healthCachePath(), health: merged };
1788
+ } catch (err) {
1789
+ return {
1790
+ ok: false,
1791
+ failure_class: "transient",
1792
+ error: `health cache write failed: ${err.message}`,
1793
+ };
1794
+ }
1795
+ }
1796
+
1797
+ function readHealthCache() {
1798
+ const filePath = healthCachePath();
1799
+ try {
1800
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
1801
+ if (!raw || typeof raw !== "object") return null;
1802
+ return raw;
1803
+ } catch (err) {
1804
+ if (err && err.code === "ENOENT") return null;
1805
+ return null;
1806
+ }
1807
+ }
1808
+
1809
+ // ---------------------------------------------------------------------------
1810
+ // Legacy last-capture-error-* import
1811
+ // ---------------------------------------------------------------------------
1812
+
1813
+ function listLegacyErrorFiles() {
1814
+ const dir = watermarkDir();
1815
+ let names = [];
1816
+ try {
1817
+ names = fs.readdirSync(dir);
1818
+ } catch {
1819
+ return [];
1820
+ }
1821
+ const out = [];
1822
+ for (const name of names) {
1823
+ const m = /^last-capture-error-(.+)\.json$/.exec(name);
1824
+ if (!m) continue;
1825
+ out.push({
1826
+ path: path.join(dir, name),
1827
+ slug: m[1],
1828
+ name,
1829
+ });
1830
+ }
1831
+ return out;
1832
+ }
1833
+
1834
+ function readLegacyErrorFile(filePath) {
1835
+ try {
1836
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
1837
+ return raw && typeof raw === "object" ? raw : null;
1838
+ } catch {
1839
+ return null;
1840
+ }
1841
+ }
1842
+
1843
+ /**
1844
+ * Import legacy last-capture-error-*.json into matching v2 watermark fields.
1845
+ * Does NOT delete legacy files — callers must use legacyErrorsSafeToRemove
1846
+ * after an uncapped scan proves no matching failed/pending sessions remain.
1847
+ */
1848
+ function importLegacyCaptureErrors(options) {
1849
+ const opts = options && typeof options === "object" ? options : {};
1850
+ const dryRun = Boolean(opts.dryRun);
1851
+ const scan = scanStates({ includeBlocked: true, now: opts.now });
1852
+ const byAgentSlug = new Map();
1853
+ for (const entry of scan.entries || []) {
1854
+ if (!entry.ok || !entry.state) continue;
1855
+ const slug = agentSlug(entry.state.agent_id);
1856
+ if (!byAgentSlug.has(slug)) byAgentSlug.set(slug, []);
1857
+ byAgentSlug.get(slug).push(entry);
1858
+ }
1859
+
1860
+ const imported = [];
1861
+ const skipped = [];
1862
+
1863
+ for (const file of listLegacyErrorFiles()) {
1864
+ const error = readLegacyErrorFile(file.path);
1865
+ if (!error) {
1866
+ skipped.push({ path: file.path, reason: "unreadable" });
1867
+ continue;
1868
+ }
1869
+ const agentMatches = byAgentSlug.get(file.slug) || [];
1870
+ let matches = agentMatches;
1871
+ if (error.session_id) {
1872
+ matches = matches.filter(
1873
+ (entry) => entry.session_id === String(error.session_id),
1874
+ );
1875
+ }
1876
+ if (error.conversation_id) {
1877
+ matches = matches.filter(
1878
+ (entry) =>
1879
+ entry.state.conversation_id === String(error.conversation_id),
1880
+ );
1881
+ }
1882
+ if (matches.length === 0) {
1883
+ skipped.push({
1884
+ path: file.path,
1885
+ reason:
1886
+ error.session_id || error.conversation_id
1887
+ ? "no_exact_matching_session"
1888
+ : "no_matching_session",
1889
+ });
1890
+ continue;
1891
+ }
1892
+
1893
+ for (const entry of matches) {
1894
+ if (typeof opts.beforeAcquire === "function") {
1895
+ opts.beforeAcquire(entry.session_id, file, error);
1896
+ }
1897
+ const lease = dryRun ? null : acquireLease(entry.session_id);
1898
+ if (!dryRun && !lease.ok) {
1899
+ skipped.push({
1900
+ path: file.path,
1901
+ session_id: entry.session_id,
1902
+ reason: "state_busy",
1903
+ });
1904
+ continue;
1905
+ }
1906
+ const leaseToken = lease && lease.lease && lease.lease.token;
1907
+ try {
1908
+ // The discovery scan is only for candidate selection. Reread after
1909
+ // acquiring the same per-session lease used by drainSession so a
1910
+ // concurrent cursor advance can never be replaced by stale state.
1911
+ const fresh = readState(entry.session_id);
1912
+ if (!fresh.ok) {
1913
+ skipped.push({
1914
+ path: file.path,
1915
+ session_id: entry.session_id,
1916
+ reason: fresh.missing ? "state_missing_after_lock" : "state_unreadable_after_lock",
1917
+ });
1918
+ continue;
1919
+ }
1920
+ const state = { ...fresh.state };
1921
+ // Revalidate exact identity against the fresh state, not the scan.
1922
+ if (
1923
+ (error.session_id &&
1924
+ state.session_id !== String(error.session_id)) ||
1925
+ (error.conversation_id &&
1926
+ state.conversation_id !== String(error.conversation_id))
1927
+ ) {
1928
+ skipped.push({
1929
+ path: file.path,
1930
+ session_id: entry.session_id,
1931
+ reason: "identity_changed_after_lock",
1932
+ });
1933
+ continue;
1934
+ }
1935
+ const marker = state.legacy_error_import;
1936
+ if (marker && marker.file === file.name) {
1937
+ skipped.push({
1938
+ path: file.path,
1939
+ session_id: entry.session_id,
1940
+ reason: "already_imported",
1941
+ });
1942
+ continue;
1943
+ }
1944
+ const already =
1945
+ state.failure_class ||
1946
+ state.delivery === "blocked" ||
1947
+ state.delivery === "retry_wait";
1948
+ // Prefer existing v2 diagnostics when already set.
1949
+ if (!already) {
1950
+ // PR #262 used class: "transient" | omitted for persistent.
1951
+ let classified;
1952
+ const legacyClass = error.kind || error.class;
1953
+ if (legacyClass === "transient") {
1954
+ classified = "transient";
1955
+ } else if (legacyClass === "persistent") {
1956
+ classified = "persistent";
1957
+ } else {
1958
+ classified = classifyFailure({
1959
+ message: error.reason || error.code || "persistent",
1960
+ code: error.code,
1961
+ statusCode: /^http_(402|403)$/i.test(String(error.code || ""))
1962
+ ? Number(String(error.code).slice(5))
1963
+ : undefined,
1964
+ });
1965
+ if (classified !== "transient") classified = "persistent";
1966
+ }
1967
+ state.failure_class =
1968
+ classified === "transient" ? "transient" : "persistent";
1969
+ state.last_error =
1970
+ error.reason || error.code || state.last_error || "legacy capture error";
1971
+ state.blocked_reason =
1972
+ state.failure_class === "persistent"
1973
+ ? state.last_error
1974
+ : state.blocked_reason;
1975
+ state.delivery =
1976
+ state.failure_class === "persistent" ? "blocked" : "retry_wait";
1977
+ if (error.held_exchanges != null || error.failed_exchanges != null) {
1978
+ state.queued_exchanges = Math.max(
1979
+ coerceNonNegInt(state.queued_exchanges, 0),
1980
+ coerceNonNegInt(
1981
+ error.held_exchanges != null
1982
+ ? error.held_exchanges
1983
+ : error.failed_exchanges,
1984
+ 0,
1985
+ ),
1986
+ );
1987
+ }
1988
+ if (!state.next_retry_at) {
1989
+ state.next_retry_at = nextRetryAt({
1990
+ failure_class: state.failure_class,
1991
+ attempt_count: state.attempt_count || 1,
1992
+ now: opts.now,
1993
+ random: 0.5,
1994
+ });
1995
+ }
1996
+ }
1997
+ // Mark the artifact consumed even when equivalent/newer V2
1998
+ // diagnostics already exist. Otherwise a later healthy transition can
1999
+ // make the stale sidecar eligible for import again.
2000
+ state.legacy_error_import = {
2001
+ file: file.name,
2002
+ session_id: state.session_id,
2003
+ conversation_id: state.conversation_id || null,
2004
+ imported_at: nowIso(opts.now),
2005
+ };
2006
+ if (!dryRun) {
2007
+ const written = writeState(entry.session_id, state);
2008
+ imported.push({
2009
+ session_id: entry.session_id,
2010
+ legacy_path: file.path,
2011
+ wrote: Boolean(written.ok),
2012
+ failure_class: state.failure_class,
2013
+ diagnostics_preserved: Boolean(already),
2014
+ });
2015
+ } else {
2016
+ imported.push({
2017
+ session_id: entry.session_id,
2018
+ legacy_path: file.path,
2019
+ wrote: false,
2020
+ dry_run: true,
2021
+ failure_class: state.failure_class,
2022
+ diagnostics_preserved: Boolean(already),
2023
+ });
2024
+ }
2025
+ } finally {
2026
+ if (leaseToken) releaseLease(entry.session_id, leaseToken);
2027
+ }
2028
+ }
2029
+ }
2030
+
2031
+ return {
2032
+ ok: true,
2033
+ imported,
2034
+ skipped,
2035
+ note:
2036
+ "Legacy last-capture-error-*.json files are NOT removed here. " +
2037
+ "Call legacyErrorsSafeToRemove(scanResult) only after an uncapped scan " +
2038
+ "proves every exact imported target is healthy, then unlink those paths.",
2039
+ };
2040
+ }
2041
+
2042
+ /**
2043
+ * Given an uncapped scan result, return legacy error file paths that are safe
2044
+ * to remove: the artifact is readable, every exact target carries this file's
2045
+ * import marker, and every such state is now healthy/complete.
2046
+ *
2047
+ * Removal of legacy files is ONLY permitted for paths returned here.
2048
+ */
2049
+ function legacyErrorsSafeToRemove(scanResult) {
2050
+ const entries =
2051
+ (scanResult && scanResult.entries) ||
2052
+ (Array.isArray(scanResult) ? scanResult : []);
2053
+ const safe = [];
2054
+ for (const file of listLegacyErrorFiles()) {
2055
+ const error = readLegacyErrorFile(file.path);
2056
+ // Unreadable artifacts are evidence, not garbage. Preserve them.
2057
+ if (!error) continue;
2058
+ const candidates = entries.filter((entry) => {
2059
+ const state = entry && entry.state;
2060
+ if (!state || agentSlug(state.agent_id) !== file.slug) return false;
2061
+ if (error.session_id && state.session_id !== String(error.session_id)) {
2062
+ return false;
2063
+ }
2064
+ if (
2065
+ error.conversation_id &&
2066
+ state.conversation_id !== String(error.conversation_id)
2067
+ ) {
2068
+ return false;
2069
+ }
2070
+ const marker = state.legacy_error_import;
2071
+ return marker && marker.file === file.name;
2072
+ });
2073
+ // Never delete an unmatched artifact or one that was skipped rather than
2074
+ // imported. Every exact imported target must now be healthy/complete.
2075
+ if (candidates.length === 0) continue;
2076
+ const allHealthy = candidates.every((entry) => {
2077
+ const state = entry.state;
2078
+ return (
2079
+ coerceNonNegInt(state.queued_exchanges, 0) === 0 &&
2080
+ !["needs_conversation", "pending", "draining", "retry_wait", "blocked"].includes(
2081
+ state.delivery,
2082
+ ) &&
2083
+ !state.failure_class
2084
+ );
2085
+ });
2086
+ if (allHealthy) safe.push(file.path);
2087
+ }
2088
+ return safe;
2089
+ }
2090
+
2091
+ // ---------------------------------------------------------------------------
2092
+ // Exports
2093
+ // ---------------------------------------------------------------------------
2094
+
2095
+ module.exports = {
2096
+ // constants / defaults / readers
2097
+ DEFAULTS,
2098
+ FAILURE_CLASSES,
2099
+ LIFECYCLES,
2100
+ DELIVERIES,
2101
+ HEALTH_STATUSES,
2102
+ HEALTH_PRECEDENCE,
2103
+ envInt,
2104
+ envFloat,
2105
+ envString,
2106
+ maxCaptureBatchSize,
2107
+ checkpointInterval,
2108
+ checkpointTimeout,
2109
+ recoveryMaxSessions,
2110
+ recoveryMaxExchanges,
2111
+ recoveryMinIdle,
2112
+ leaseTtlSeconds,
2113
+ retryBaseSeconds,
2114
+ retryMaxSeconds,
2115
+ retryJitter,
2116
+ persistentRetrySeconds,
2117
+ workerMaxAgeSeconds,
2118
+ timerMaxAge,
2119
+ healthCacheFilename,
2120
+
2121
+ // paths
2122
+ watermarkDir,
2123
+ watermarkPath,
2124
+ leasePath,
2125
+ mutationLockPath,
2126
+ timerPidPath,
2127
+ healthCachePath,
2128
+ legacyErrorPath,
2129
+ agentSlug,
2130
+
2131
+ // normalize / validate
2132
+ normalizeState,
2133
+ validateV2,
2134
+ isResumableState,
2135
+
2136
+ // persistence
2137
+ readState,
2138
+ writeState,
2139
+ mutateState,
2140
+ writeSessionIntent,
2141
+ atomicWriteJson,
2142
+
2143
+ // cursor / epoch
2144
+ advanceCursor,
2145
+ rebucket,
2146
+
2147
+ // leases
2148
+ acquireLease,
2149
+ renewLease,
2150
+ releaseLease,
2151
+
2152
+ // retry
2153
+ classifyFailure,
2154
+ nextRetryAt,
2155
+ retryDelaySeconds,
2156
+
2157
+ // scan
2158
+ scanStates,
2159
+ scanCompleteForDropClamp,
2160
+ sortOldestFirst,
2161
+ sessionHealthBucket,
2162
+ buildEligibility,
2163
+
2164
+ // health
2165
+ computeAggregateHealth,
2166
+ writeHealthCache,
2167
+ readHealthCache,
2168
+
2169
+ // legacy
2170
+ importLegacyCaptureErrors,
2171
+ legacyErrorsSafeToRemove,
2172
+ listLegacyErrorFiles,
2173
+ };