@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.
- package/README.md +106 -38
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
- package/assets/hooks-handlers/lib/capture-notices.js +131 -14
- package/assets/hooks-handlers/lib/capture-state.js +95 -1
- package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
- package/assets/hooks-handlers/lib/capture.js +1237 -137
- package/assets/hooks-handlers/lib/checkpoint-timer.js +51 -10
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
- package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +85 -18
- package/assets/skills/meko-mcp-tools/SKILL.md +57 -14
- package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +86 -14
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -6
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +8 -4
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +32 -22
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +45 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +54 -18
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +95 -33
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +14 -10
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -25
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +5 -5
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
- package/bin/create-meko-setup.mjs +70 -7
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/codex.mjs +80 -35
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +5 -5
- package/lib/clients/opencode.mjs +532 -0
- package/lib/config.mjs +68 -1
- package/lib/installer.mjs +18 -2
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +9 -2
- package/lib/migrate/adapters/memory-md.mjs +27 -8
- package/lib/migrate/adapters/opencode.mjs +148 -0
- package/lib/migrate/claude-parser.mjs +153 -9
- package/lib/migrate/id.mjs +195 -13
- package/lib/migrate/mcp-client.mjs +25 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +293 -77
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +96 -8
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|