@yugabytedb/meko-mcp 2.2.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 (63) hide show
  1. package/README.md +106 -38
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
  3. package/assets/hooks-handlers/lib/capture-notices.js +131 -14
  4. package/assets/hooks-handlers/lib/capture-state.js +95 -1
  5. package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
  6. package/assets/hooks-handlers/lib/capture.js +1237 -137
  7. package/assets/hooks-handlers/lib/checkpoint-timer.js +51 -10
  8. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  9. package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
  10. package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
  11. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  12. package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
  13. package/assets/opencode-plugin/meko.js +213 -0
  14. package/assets/scripts/meko-statusline.sh +85 -18
  15. package/assets/skills/meko-mcp-tools/SKILL.md +57 -14
  16. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  17. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
  18. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +86 -14
  19. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  20. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -6
  21. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +8 -4
  22. package/assets/skills/meko-mcp-tools/references/tools-overview.md +32 -22
  23. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  24. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +45 -1
  25. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
  26. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +54 -18
  27. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  28. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +95 -33
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +14 -10
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -25
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  36. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  37. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +5 -5
  38. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  39. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  40. package/bin/create-meko-setup.mjs +70 -7
  41. package/lib/capture-cleanup.mjs +63 -0
  42. package/lib/clients/claude-code.mjs +1 -0
  43. package/lib/clients/codex.mjs +80 -35
  44. package/lib/clients/common.mjs +7 -0
  45. package/lib/clients/index.mjs +2 -0
  46. package/lib/clients/kiro.mjs +5 -5
  47. package/lib/clients/opencode.mjs +532 -0
  48. package/lib/config.mjs +68 -1
  49. package/lib/installer.mjs +18 -2
  50. package/lib/migrate/adapters/claude-code.mjs +11 -2
  51. package/lib/migrate/adapters/codex.mjs +9 -2
  52. package/lib/migrate/adapters/memory-md.mjs +27 -8
  53. package/lib/migrate/adapters/opencode.mjs +148 -0
  54. package/lib/migrate/claude-parser.mjs +153 -9
  55. package/lib/migrate/id.mjs +195 -13
  56. package/lib/migrate/mcp-client.mjs +25 -1
  57. package/lib/migrate/opencode-normalizer.mjs +132 -0
  58. package/lib/migrate/orchestrator.mjs +293 -77
  59. package/lib/migrate/progress.mjs +239 -5
  60. package/lib/migrate/registry.mjs +72 -0
  61. package/lib/migrate.mjs +96 -8
  62. package/package.json +1 -1
  63. package/scripts/bundle-assets.mjs +4 -0
@@ -5,11 +5,14 @@
5
5
  * CommonJS, zero npm deps. Safe to require from capture.js SessionStart
6
6
  * without touching the drain loop. See docs/plans/meko-capture-v2-contracts.md.
7
7
  *
8
- * Health cache field extension (documented):
8
+ * Health cache field extensions (documented):
9
9
  * capture-health.json may include `last_notified_status` — the aggregate
10
10
  * status last shown to the user via SessionStart. Used for one-shot
11
11
  * recovery notices when status returns to `healthy`. writeHealthCache()
12
12
  * preserves this field when callers omit it.
13
+ *
14
+ * `last_notified_dropped` records the aggregate dropped-exchange count last
15
+ * shown. A complete refresh may clamp it when session watermarks disappear.
13
16
  */
14
17
 
15
18
  "use strict";
@@ -17,10 +20,15 @@
17
20
  const {
18
21
  computeAggregateHealth,
19
22
  readHealthCache,
23
+ scanCompleteForDropClamp,
20
24
  scanStates,
21
25
  writeHealthCache,
22
26
  } = require("./capture-state");
23
27
 
28
+ // blocked_reason prefix written by `--capture-cleanup stop`
29
+ // (capture-cleanup-cli.js RESUME_HINT).
30
+ const PARKED_REASON_PREFIX = "parked by capture cleanup";
31
+
24
32
  const NON_HEALTHY = new Set([
25
33
  "catching_up",
26
34
  "degraded_retrying",
@@ -99,14 +107,72 @@ function statuslineSuffixFromHealth(health) {
99
107
  return `capture:${short}(${queued})`;
100
108
  }
101
109
 
110
+ /**
111
+ * Sentence describing exchanges that were skipped because the server will
112
+ * never accept them, or "" when there are none new to report.
113
+ *
114
+ * Drops are permanent, so they are not a health status — capture is running
115
+ * fine — but the user is owed the fact that specific turns are missing.
116
+ */
117
+ function dropSentence(dropped, previouslyNotified, health) {
118
+ const newDrops = dropped - previouslyNotified;
119
+ if (newDrops <= 0) return "";
120
+ const reasons = Array.isArray(health && health.drop_reasons)
121
+ ? health.drop_reasons.filter(Boolean)
122
+ : [];
123
+ // drop_reasons is the union of every session's last_drop_reason, including
124
+ // drops already announced. Attribute a reason only when there is one shared
125
+ // cause; otherwise point at --capture-status instead of mis-blaming.
126
+ const because =
127
+ reasons.length === 1
128
+ ? ` Reason: ${reasons[0]}.`
129
+ : reasons.length > 1
130
+ ? " Run `meko-mcp --capture-status` for drop reasons."
131
+ : "";
132
+ return (
133
+ `[Meko capture] ${newDrops} exchange(s) could not be stored and were ` +
134
+ `skipped; the rest of the session is captured normally.${because} ` +
135
+ `Tell the user those turns are missing from Meko.`
136
+ );
137
+ }
138
+
139
+ /**
140
+ * True when some session is still retrying. A session parked by
141
+ * `--capture-cleanup stop` is blocked on purpose and never retries, so it
142
+ * doesn't count; its blocked reason already says how to resume it.
143
+ */
144
+ function retriesRunning(health) {
145
+ // A degraded session with nothing left to send (leftover failure fields on
146
+ // a delivered session) isn't retrying; `repair` clears it, not `stop`.
147
+ const sessions = Array.isArray(health && health.sessions) ? health.sessions : [];
148
+ const retrying = sessions.some(
149
+ (s) =>
150
+ s &&
151
+ s.status === "degraded_retrying" &&
152
+ (Number(s.queued_exchanges || 0) > 0 ||
153
+ ["needs_conversation", "pending", "draining", "retry_wait"].includes(s.delivery)),
154
+ );
155
+ if (retrying) return true;
156
+ if (health.status !== "blocked_action_required") return false;
157
+ const reasons = Array.isArray(health.blocked_reasons)
158
+ ? health.blocked_reasons.filter(Boolean)
159
+ : [];
160
+ return (
161
+ reasons.length === 0 ||
162
+ reasons.some((r) => !String(r).startsWith(PARKED_REASON_PREFIX))
163
+ );
164
+ }
165
+
102
166
  /**
103
167
  * Build a SessionStart-injectable health notice.
104
168
  *
105
169
  * - When status ≠ healthy: always emit (exact affected-session + queued counts + reason).
106
170
  * - When status === healthy and previousStatus was non-healthy: one-shot recovery notice.
107
171
  * - When status === healthy and previous was already healthy / null: no notice.
172
+ * - Any new dropped exchanges are appended in every case, and are enough on
173
+ * their own to produce a notice while status is healthy.
108
174
  *
109
- * @param {{ health: object, previousStatus?: string|null }} opts
175
+ * @param {{ health: object, previousStatus?: string|null, previousDropped?: number }} opts
110
176
  * @returns {{
111
177
  * notice: string|null,
112
178
  * nextNotifiedStatus: string,
@@ -114,10 +180,11 @@ function statuslineSuffixFromHealth(health) {
114
180
  * status: string,
115
181
  * affectedSessions: number,
116
182
  * queuedExchanges: number,
183
+ * droppedExchanges: number,
117
184
  * reason: string,
118
185
  * }}
119
186
  */
120
- function buildHealthNotice({ health, previousStatus } = {}) {
187
+ function buildHealthNotice({ health, previousStatus, previousDropped } = {}) {
121
188
  const h = health && typeof health === "object" ? health : { status: "healthy" };
122
189
  const status = h.status || "healthy";
123
190
  const prev =
@@ -126,29 +193,37 @@ function buildHealthNotice({ health, previousStatus } = {}) {
126
193
  : String(previousStatus);
127
194
  const affected = affectedSessionCount(h);
128
195
  const queued = Number(h.queued_exchanges || 0);
196
+ const dropped = Number(h.dropped_exchanges || 0);
197
+ const notifiedDropped = Number(previousDropped || 0);
198
+ const drops = dropSentence(dropped, notifiedDropped, h);
129
199
  const reason = reasonForHealth(h);
200
+ const join = (a, b) => (a && b ? `${a}\n\n${b}` : a || b || null);
130
201
 
131
202
  if (status === "healthy") {
132
203
  if (prev && NON_HEALTHY.has(prev)) {
133
204
  return {
134
- notice:
205
+ notice: join(
135
206
  "[Meko capture] Capture recovered — all sessions healthy. " +
136
- "Queued backlog is clear. Automatic conversation capture is running normally again.",
207
+ "Queued backlog is clear. Automatic conversation capture is running normally again.",
208
+ drops,
209
+ ),
137
210
  nextNotifiedStatus: "healthy",
138
211
  shouldPersistNotified: true,
139
212
  status,
140
213
  affectedSessions: 0,
141
214
  queuedExchanges: queued,
215
+ droppedExchanges: dropped,
142
216
  reason,
143
217
  };
144
218
  }
145
219
  return {
146
- notice: null,
220
+ notice: drops || null,
147
221
  nextNotifiedStatus: "healthy",
148
- shouldPersistNotified: prev !== "healthy",
222
+ shouldPersistNotified: prev !== "healthy" || Boolean(drops),
149
223
  status,
150
224
  affectedSessions: 0,
151
225
  queuedExchanges: queued,
226
+ droppedExchanges: dropped,
152
227
  reason,
153
228
  };
154
229
  }
@@ -160,10 +235,17 @@ function buildHealthNotice({ health, previousStatus } = {}) {
160
235
  ? "degraded_retrying"
161
236
  : "catching_up";
162
237
 
163
- const notice =
238
+ const cleanupHint = retriesRunning(h)
239
+ ? " If the user wants the retries to stop, offer " +
240
+ "`npx @yugabytedb/meko-mcp@latest --capture-cleanup stop`; it parks " +
241
+ "the backlog without deleting it."
242
+ : "";
243
+ const notice = join(
164
244
  `[Meko capture] Status: ${label} — ${affected} session(s) affected, ` +
165
- `${queued} exchange(s) queued. Reason: ${reason}. ` +
166
- `Run \`meko-mcp --capture-status\` for details.`;
245
+ `${queued} exchange(s) queued. Reason: ${reason}. ` +
246
+ `Run \`meko-mcp --capture-status\` for details.${cleanupHint}`,
247
+ drops,
248
+ );
167
249
 
168
250
  return {
169
251
  notice,
@@ -172,6 +254,7 @@ function buildHealthNotice({ health, previousStatus } = {}) {
172
254
  status,
173
255
  affectedSessions: affected,
174
256
  queuedExchanges: queued,
257
+ droppedExchanges: dropped,
175
258
  reason,
176
259
  };
177
260
  }
@@ -195,14 +278,23 @@ function injectHealthNotice(context, opts) {
195
278
  const base = context == null ? "" : String(context);
196
279
 
197
280
  let health = null;
281
+ let scanComplete = false;
198
282
  if (refresh) {
199
283
  const scan = scanStates({ includeBlocked: true });
284
+ scanComplete = scanCompleteForDropClamp(scan);
200
285
  health = computeAggregateHealth(scan.entries || []);
201
286
  const existing = readHealthCache();
202
287
  if (existing && existing.last_notified_status !== undefined) {
203
288
  health.last_notified_status = existing.last_notified_status;
204
289
  }
205
- writeHealthCache(health);
290
+ if (existing && existing.last_notified_dropped !== undefined) {
291
+ const previous = Number(existing.last_notified_dropped || 0);
292
+ health.last_notified_dropped = scanComplete
293
+ ? Math.min(previous, Number(health.dropped_exchanges || 0))
294
+ : previous;
295
+ }
296
+ const written = writeHealthCache(health, { clampDropped: scanComplete });
297
+ if (written.ok) health = written.health;
206
298
  } else {
207
299
  health = readHealthCache() || {
208
300
  schema_version: 1,
@@ -214,9 +306,11 @@ function injectHealthNotice(context, opts) {
214
306
  blocked_action_required: 0,
215
307
  },
216
308
  queued_exchanges: 0,
309
+ dropped_exchanges: 0,
217
310
  oldest_pending_age_seconds: null,
218
311
  last_success_at: null,
219
312
  blocked_reasons: [],
313
+ drop_reasons: [],
220
314
  sessions: [],
221
315
  };
222
316
  }
@@ -225,13 +319,23 @@ function injectHealthNotice(context, opts) {
225
319
  health.last_notified_status !== undefined
226
320
  ? health.last_notified_status
227
321
  : null;
228
- const built = buildHealthNotice({ health, previousStatus });
322
+ const previousDropped = Number(health.last_notified_dropped || 0);
323
+ const built = buildHealthNotice({ health, previousStatus, previousDropped });
229
324
 
230
325
  if (persist && built.shouldPersistNotified) {
231
- writeHealthCache({
326
+ const nextHealth = {
232
327
  ...health,
233
328
  last_notified_status: built.nextNotifiedStatus,
234
- });
329
+ };
330
+ if (scanComplete || !refresh) {
331
+ nextHealth.last_notified_dropped = built.droppedExchanges;
332
+ } else {
333
+ nextHealth.last_notified_dropped = Math.max(
334
+ previousDropped,
335
+ built.droppedExchanges,
336
+ );
337
+ }
338
+ writeHealthCache(nextHealth, { clampDropped: scanComplete });
235
339
  }
236
340
 
237
341
  if (!built.notice) return base;
@@ -255,6 +359,7 @@ function formatCaptureStatusText(health) {
255
359
  `degraded_retrying=${Number(counts.degraded_retrying || 0)} ` +
256
360
  `blocked_action_required=${Number(counts.blocked_action_required || 0)}`,
257
361
  ` queued exchanges: ${Number(h.queued_exchanges || 0)}`,
362
+ ` dropped exchanges: ${Number(h.dropped_exchanges || 0)}`,
258
363
  ` oldest pending age (seconds): ${
259
364
  h.oldest_pending_age_seconds == null ? "n/a" : h.oldest_pending_age_seconds
260
365
  }`,
@@ -265,6 +370,15 @@ function formatCaptureStatusText(health) {
265
370
  if (blocked.length) {
266
371
  lines.push(` blocked reasons: ${blocked.join("; ")}`);
267
372
  }
373
+ const dropReasons = Array.isArray(h.drop_reasons) ? h.drop_reasons : [];
374
+ if (dropReasons.length) {
375
+ lines.push(` drop reasons: ${dropReasons.join("; ")}`);
376
+ }
377
+ if (status === "degraded_retrying" || status === "blocked_action_required") {
378
+ lines.push(
379
+ " per-session detail and cleanup: meko-mcp --capture-cleanup status",
380
+ );
381
+ }
268
382
  return lines.join("\n");
269
383
  }
270
384
 
@@ -300,15 +414,18 @@ function captureStatusJson(health) {
300
414
  ),
301
415
  },
302
416
  queued_exchanges: Number(h.queued_exchanges || 0),
417
+ dropped_exchanges: Number(h.dropped_exchanges || 0),
303
418
  oldest_pending_age_seconds:
304
419
  h.oldest_pending_age_seconds == null ? null : h.oldest_pending_age_seconds,
305
420
  last_success_at: h.last_success_at || null,
306
421
  blocked_reasons: Array.isArray(h.blocked_reasons) ? h.blocked_reasons : [],
422
+ drop_reasons: Array.isArray(h.drop_reasons) ? h.drop_reasons : [],
307
423
  sessions: Array.isArray(h.sessions) ? h.sessions : [],
308
424
  };
309
425
  }
310
426
 
311
427
  module.exports = {
428
+ PARKED_REASON_PREFIX,
312
429
  affectedSessionCount,
313
430
  reasonForHealth,
314
431
  statuslineSuffixFromHealth,
@@ -293,6 +293,21 @@ function blockedOutcome(failureClass, error, extras) {
293
293
  };
294
294
  }
295
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
+
296
311
  /**
297
312
  * Validate a candidate v2 state object.
298
313
  * @returns {{ ok:true, state:Object } | { ok:false, delivery:"blocked", failure_class:"state_corrupt", error:string }}
@@ -342,6 +357,16 @@ function validateV2(state) {
342
357
  `invalid failure_class: ${state.failure_class}`,
343
358
  );
344
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
+ }
345
370
  return { ok: true, state };
346
371
  }
347
372
 
@@ -461,6 +486,7 @@ function normalizeState(raw, context) {
461
486
  last_success_at:
462
487
  raw.last_success_at == null ? null : String(raw.last_success_at),
463
488
  blocked_reason: null,
489
+ consecutive_exchange_drops: 0,
464
490
  };
465
491
 
466
492
  const validated = validateV2(state);
@@ -938,6 +964,8 @@ function writeSessionIntent(sessionId, fields) {
938
964
  last_error: null,
939
965
  last_success_at: null,
940
966
  blocked_reason: null,
967
+ // null = finalize unless client is opencode (isResumableState).
968
+ pending_turn_policy: null,
941
969
  };
942
970
 
943
971
  const conversationId =
@@ -974,6 +1002,12 @@ function writeSessionIntent(sessionId, fields) {
974
1002
  : "needs_conversation",
975
1003
  last_activity_at: nowIso(),
976
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),
977
1011
  };
978
1012
 
979
1013
  if (f.last_line_number != null && existing.missing) {
@@ -1065,6 +1099,10 @@ function rebucket(state, newConversationId) {
1065
1099
  blocked_reason: null,
1066
1100
  next_retry_at: null,
1067
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,
1068
1106
  updated_at: nowIso(),
1069
1107
  last_activity_at: nowIso(),
1070
1108
  };
@@ -1467,6 +1505,18 @@ function sessionHealthBucket(entry) {
1467
1505
  ) {
1468
1506
  return "blocked_action_required";
1469
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
+ }
1470
1520
  if (
1471
1521
  state.delivery === "retry_wait" ||
1472
1522
  fc === "transient" ||
@@ -1554,6 +1604,15 @@ function scanStates(options) {
1554
1604
  return { ok: true, entries, dir };
1555
1605
  }
1556
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
+
1557
1616
  function sortOldestFirst(entries) {
1558
1617
  return [...(entries || [])].sort((a, b) => {
1559
1618
  const ak =
@@ -1588,9 +1647,11 @@ function computeAggregateHealth(states, options) {
1588
1647
  blocked_action_required: 0,
1589
1648
  };
1590
1649
  let queued = 0;
1650
+ let dropped = 0;
1591
1651
  let oldestPendingAge = null;
1592
1652
  let lastSuccessAt = null;
1593
1653
  const blockedReasons = [];
1654
+ const dropReasons = [];
1594
1655
  const sessions = [];
1595
1656
  let status = "healthy";
1596
1657
 
@@ -1612,6 +1673,20 @@ function computeAggregateHealth(states, options) {
1612
1673
  : 0;
1613
1674
  queued += q;
1614
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
+
1615
1690
  if (state && state.last_success_at) {
1616
1691
  if (
1617
1692
  !lastSuccessAt ||
@@ -1659,6 +1734,7 @@ function computeAggregateHealth(states, options) {
1659
1734
  (entry && entry.failure_class) ||
1660
1735
  null,
1661
1736
  queued_exchanges: q,
1737
+ dropped_exchanges: d,
1662
1738
  lifecycle: state ? state.lifecycle : null,
1663
1739
  });
1664
1740
  }
@@ -1669,14 +1745,17 @@ function computeAggregateHealth(states, options) {
1669
1745
  updated_at: nowIso(nowMs),
1670
1746
  counts,
1671
1747
  queued_exchanges: queued,
1748
+ dropped_exchanges: dropped,
1672
1749
  oldest_pending_age_seconds: oldestPendingAge,
1673
1750
  last_success_at: lastSuccessAt,
1674
1751
  blocked_reasons: blockedReasons,
1752
+ drop_reasons: dropReasons,
1675
1753
  sessions,
1676
1754
  };
1677
1755
  }
1678
1756
 
1679
- function writeHealthCache(health) {
1757
+ function writeHealthCache(health, options) {
1758
+ const opts = options && typeof options === "object" ? options : {};
1680
1759
  const existing = readHealthCache();
1681
1760
  const merged = {
1682
1761
  ...(health && typeof health === "object" ? health : {}),
@@ -1690,6 +1769,19 @@ function writeHealthCache(health) {
1690
1769
  ) {
1691
1770
  merged.last_notified_status = existing.last_notified_status;
1692
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
+ }
1693
1785
  try {
1694
1786
  atomicWriteJson(healthCachePath(), merged);
1695
1787
  return { ok: true, path: healthCachePath(), health: merged };
@@ -2039,6 +2131,7 @@ module.exports = {
2039
2131
  // normalize / validate
2040
2132
  normalizeState,
2041
2133
  validateV2,
2134
+ isResumableState,
2042
2135
 
2043
2136
  // persistence
2044
2137
  readState,
@@ -2063,6 +2156,7 @@ module.exports = {
2063
2156
 
2064
2157
  // scan
2065
2158
  scanStates,
2159
+ scanCompleteForDropClamp,
2066
2160
  sortOldestFirst,
2067
2161
  sessionHealthBucket,
2068
2162
  buildEligibility,
@@ -22,6 +22,7 @@ const {
22
22
  importLegacyCaptureErrors,
23
23
  legacyErrorsSafeToRemove,
24
24
  readHealthCache,
25
+ scanCompleteForDropClamp,
25
26
  scanStates,
26
27
  writeHealthCache,
27
28
  } = require("./capture-state");
@@ -63,12 +64,15 @@ function main() {
63
64
  );
64
65
  }
65
66
 
66
- const health = computeAggregateHealth(scan.entries || []);
67
+ let health = computeAggregateHealth(scan.entries || []);
67
68
  const existing = readHealthCache();
68
69
  if (existing && existing.last_notified_status !== undefined) {
69
70
  health.last_notified_status = existing.last_notified_status;
70
71
  }
71
- writeHealthCache(health);
72
+ const written = writeHealthCache(health, {
73
+ clampDropped: scanCompleteForDropClamp(scan),
74
+ });
75
+ if (written.ok) health = written.health;
72
76
 
73
77
  const payload = captureStatusJson(health);
74
78
  if (json) {