comfyui-mcp 0.49.2 → 0.49.4

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 (61) hide show
  1. package/dist/orchestrator/agent-backend.js +8 -0
  2. package/dist/orchestrator/agent-backend.js.map +1 -1
  3. package/dist/orchestrator/codex-backend.js +49 -0
  4. package/dist/orchestrator/codex-backend.js.map +1 -1
  5. package/dist/orchestrator/grok-backend.js +5 -0
  6. package/dist/orchestrator/grok-backend.js.map +1 -1
  7. package/dist/orchestrator/index.js +317 -22
  8. package/dist/orchestrator/index.js.map +1 -1
  9. package/dist/orchestrator/ollama-backend.js +11 -2
  10. package/dist/orchestrator/ollama-backend.js.map +1 -1
  11. package/dist/orchestrator/panel-agent.js +591 -67
  12. package/dist/orchestrator/panel-agent.js.map +1 -1
  13. package/dist/orchestrator/panel-tools.js +518 -65
  14. package/dist/orchestrator/panel-tools.js.map +1 -1
  15. package/dist/orchestrator/run-completion-journal.js +914 -0
  16. package/dist/orchestrator/run-completion-journal.js.map +1 -0
  17. package/dist/orchestrator/session-store.js +11 -3
  18. package/dist/orchestrator/session-store.js.map +1 -1
  19. package/dist/services/asset-reconcile.js +83 -0
  20. package/dist/services/asset-reconcile.js.map +1 -0
  21. package/dist/services/asset-registry.js +9 -2
  22. package/dist/services/asset-registry.js.map +1 -1
  23. package/dist/services/download-jobs.js +178 -14
  24. package/dist/services/download-jobs.js.map +1 -1
  25. package/dist/services/download-progress.js +16 -18
  26. package/dist/services/download-progress.js.map +1 -1
  27. package/dist/services/extra-paths.js +61 -9
  28. package/dist/services/extra-paths.js.map +1 -1
  29. package/dist/services/hello-retarget.js +165 -0
  30. package/dist/services/hello-retarget.js.map +1 -0
  31. package/dist/services/job-history.js +50 -0
  32. package/dist/services/job-history.js.map +1 -1
  33. package/dist/services/job-watcher.js +38 -7
  34. package/dist/services/job-watcher.js.map +1 -1
  35. package/dist/services/manifest.js +92 -9
  36. package/dist/services/manifest.js.map +1 -1
  37. package/dist/services/model-resolver.js +616 -17
  38. package/dist/services/model-resolver.js.map +1 -1
  39. package/dist/services/output-dir.js +63 -15
  40. package/dist/services/output-dir.js.map +1 -1
  41. package/dist/services/panel-pin-guard.js +6 -62
  42. package/dist/services/panel-pin-guard.js.map +1 -1
  43. package/dist/services/ui-bridge.js +111 -14
  44. package/dist/services/ui-bridge.js.map +1 -1
  45. package/dist/services/workspace-env.js +179 -3
  46. package/dist/services/workspace-env.js.map +1 -1
  47. package/dist/tools/assets.js +34 -2
  48. package/dist/tools/assets.js.map +1 -1
  49. package/dist/tools/extra-paths.js +6 -4
  50. package/dist/tools/extra-paths.js.map +1 -1
  51. package/dist/tools/model-extras.js +26 -7
  52. package/dist/tools/model-extras.js.map +1 -1
  53. package/dist/tools/model-management.js +36 -8
  54. package/dist/tools/model-management.js.map +1 -1
  55. package/dist/tools/report-issue.js +12 -2
  56. package/dist/tools/report-issue.js.map +1 -1
  57. package/dist/tools/vocabulary.js +21 -0
  58. package/dist/tools/vocabulary.js.map +1 -1
  59. package/package.json +1 -1
  60. package/scripts/gen-tool-docs.ts +138 -4
  61. package/scripts/tool-doc-examples.ts +794 -0
@@ -0,0 +1,914 @@
1
+ // Durable-across-the-turn delivery of a render COMPLETION to the panel agent
2
+ // (issue #468).
3
+ //
4
+ // THE FAILURE. `panel_run` queues a render on the user's canvas and tells the
5
+ // agent — in so many words — "end your turn, you WILL be notified when it
6
+ // finishes". That promise is kept by exactly one mechanism: the panel's
7
+ // `agent_event` frame → PanelAgentManager.injectEvent → PanelAgent.queue. That
8
+ // path used to be fire-and-forget in three independent ways:
9
+ //
10
+ // 1. UNCORRELATED. The completion carried no run identity the orchestrator
11
+ // ever looked at. `panel_run` learns ComfyUI's `prompt_id` (it already
12
+ // hands it to QueueMonitor.markSelfQueued) but nothing downstream used it,
13
+ // so a completion could not be tied to the run that was outstanding — and
14
+ // an outstanding run could never be known to be unanswered.
15
+ // 2. SILENTLY DROPPED with no live agent. injectEvent returns false for a
16
+ // missing/stopped agent and the caller only logged on success. Nothing
17
+ // recorded the loss.
18
+ // 3. DIED WITH A QUEUED-BUT-UNREAD ITEM. The event is only really delivered
19
+ // when channel() splices it into a turn. Everything before that — a
20
+ // stop()/retire(), a stall-abandoned turn, a takePending() race — discards
21
+ // it.
22
+ //
23
+ // AUTOMATIC GOAL CONTINUATION is what turns those windows from theoretical into
24
+ // routine. An ordinary single run ends the agent's turn and leaves it idle, so
25
+ // the completion lands in an empty queue and is drained immediately. A
26
+ // continuation keeps the agent BUSY for the whole render: the completion sits in
27
+ // `queue` (window 3) for minutes, and the continuation's own turn churn is
28
+ // exactly what fires the deferred session restarts (effort/model change,
29
+ // comfyui-MCP-env respawn) that applyPendingRestarts defers "until idle" and the
30
+ // self-restart loop — each of which tears the listener down (windows 2 and 3)
31
+ // underneath the very render whose completion is in flight.
32
+ //
33
+ // THE CONTRACT HERE.
34
+ // • Runs are ticketed by ComfyUI's `prompt_id`, opened by `panel_run`.
35
+ // • Every completion is CORRELATED ONCE, AT ARRIVAL, by EXACT prompt-id
36
+ // equality against an open ticket — never by recency, never re-derived later.
37
+ // • An uncorrelatable completion is still delivered, labelled UNDETERMINED. It
38
+ // is never swallowed and never allowed to answer for a run it can't be proven
39
+ // to belong to.
40
+ // • Undelivered completions are JOURNALED and replayed at the next delivery
41
+ // opportunity (a fresh agent spawn, a later completion for the same tab).
42
+ // • An entry is cleared only on a positive ack — the turn that CARRIED it
43
+ // ended. Handed to an agent that then died, it comes back and is replayed.
44
+ //
45
+ // LOCAL trust domain: this is accidental-loss bookkeeping, not a defense against
46
+ // a hostile panel. Everything is in-memory and process-scoped; an orchestrator
47
+ // restart drops the journal along with the agents it was addressing.
48
+ import { logger } from "../utils/logger.js";
49
+ /** Runs tracked at once. Ample for any real batch; bounded so a long session
50
+ * can't grow the map without limit. */
51
+ const MAX_TICKETS = 64;
52
+ /** Undelivered completions held per panel tab. */
53
+ const MAX_ENTRIES_PER_KEY = 32;
54
+ /** Undelivered completions held across ALL tabs — a global ceiling so a session
55
+ * that opens and abandons many tabs can't grow the journal without limit. */
56
+ const MAX_ENTRIES_TOTAL = 96;
57
+ /** (tab, run) pairs remembered as already delivered, to suppress a re-sent frame
58
+ * after its entry was acked and removed. Comfortably larger than MAX_TICKETS so
59
+ * it outlives the tickets that feed it; a resend later than THIS is delivered
60
+ * again, but as `foreign` — flagged UNDETERMINED, never as the awaited run. */
61
+ const MAX_DELIVERED_MEMO = 512;
62
+ /** Tabs whose evicted-completion counter is retained. */
63
+ const MAX_DROPPED_KEYS = 64;
64
+ /** How many times a completion may be CARRIED BY A TURN THAT THEN ENDED without a
65
+ * provable ack before the journal settles it anyway.
66
+ *
67
+ * Counts turns, NOT queue hand-offs. A hand-off only means the event was queued;
68
+ * an agent torn down before it drained its queue never showed the text to
69
+ * anyone, so counting those would settle — and lose — a completion that three
70
+ * ordinary provider/session replacements had merely shuffled around. The only
71
+ * cycle that needs bounding is "dispatched into a turn → that turn ended → its
72
+ * result could not be proven to be its own → replay", which a backend that
73
+ * declares turn markers but never stamps its results would otherwise repeat
74
+ * forever. Each of THOSE put the completion's text into a turn the agent read,
75
+ * so settling risks a duplicate, never a loss. */
76
+ const MAX_CARRIED_RELEASES = 3;
77
+ /**
78
+ * How long an identical ID-LESS completion is still worth FLAGGING as a possible
79
+ * repeat. Content is the only evidence of sameness these have, and it is not
80
+ * proof — ComfyUI reuses temp output names (`ComfyUI_temp_*_00001_`) after a
81
+ * restart, so two genuinely different renders can look identical. Hence the flag
82
+ * is all it drives: nothing here ever merges or suppresses a completion.
83
+ */
84
+ const IDLESS_REPEAT_HINT_MS = 10 * 60_000;
85
+ /**
86
+ * Memo key for an already-delivered run: (tab, prompt id, TICKET GENERATION).
87
+ *
88
+ * The generation is what makes this an identity rather than a guess. A prompt id
89
+ * alone is not one — ComfyUI reuses ids, and a ticket can be evicted and
90
+ * recreated — so a memo keyed on the id would let run A's delivery suppress run
91
+ * B's real completion. `gen` is `RunTicket.seq`, or 0 when this tab has no
92
+ * ticket for the id at all (an unqueued, foreign run).
93
+ */
94
+ function deliveredKey(key, promptId, gen) {
95
+ return `${key}|${promptId}|${gen}`;
96
+ }
97
+ /**
98
+ * Fingerprint for an ID-LESS completion (a panel that forwarded no `prompt_id`).
99
+ * With no run identity, content is all we have to tell "the panel re-sent the
100
+ * same frame" from "a second render finished": kind + note + error + the exact
101
+ * output list. Combined with IDLESS_DEDUPE_WINDOW_MS this collapses a repeat
102
+ * without letting a later, genuinely different render inherit the suppression.
103
+ * Filenames are NOT sorted — the panel emits them in output order, and a
104
+ * different order is a different batch.
105
+ */
106
+ function idlessFingerprint(key, payload) {
107
+ const files = (payload.images ?? [])
108
+ .map((i) => `${i.subfolder ?? ""}/${i.type ?? ""}/${i.filename}`)
109
+ .join(",");
110
+ return `${key}|~idless~|${payload.kind ?? ""}|${payload.note ?? ""}|${payload.error ?? ""}|${files}`;
111
+ }
112
+ export class RunCompletionJournalImpl {
113
+ /** prompt id → ticket. Keyed globally: ComfyUI prompt ids are UUIDs, so an
114
+ * exact match is proof of identity on its own and survives a session's tab
115
+ * id being rebound between the queue and the completion. */
116
+ tickets = new Map();
117
+ /** token → entry (insertion-ordered, which is also delivery order). */
118
+ entries = new Map();
119
+ /** (tab, run) pairs whose completion was ACKED — a bounded FIFO memo so a
120
+ * panel that re-sends the frame after the entry was removed can't produce a
121
+ * second delivery. */
122
+ delivered = new Set();
123
+ /** Content fingerprint → delivery time, for ID-LESS completions (which have no
124
+ * run identity to memoize). Bounded FIFO + a time window, so an identical
125
+ * re-send is suppressed but a later genuinely-different render is not. */
126
+ idlessSeen = new Map();
127
+ seq = 0;
128
+ /** Monotonic ticket-generation counter — see RunTicket.seq. */
129
+ ticketSeq = 0;
130
+ /** The generation this tab's ticket for `promptId` is currently on, or 0 when
131
+ * it has none. THE run identity: every proof and every merge is keyed on it,
132
+ * so an id reused (or a ticket evicted and recreated) can never let one run's
133
+ * bookkeeping answer for another's. */
134
+ generationOf(key, promptId) {
135
+ const ticket = this.tickets.get(promptId);
136
+ return ticket && ticket.tabId === key ? ticket.seq : 0;
137
+ }
138
+ /** Pull a still-queued completion back off its agent (see setRevoker). */
139
+ revoke = null;
140
+ /** Re-deliver a tab's pending completions (after a revoke re-arms one). */
141
+ reflush = null;
142
+ /**
143
+ * Wire the "unsend" hook (#468).
144
+ *
145
+ * A completion's WORDING is materialized when it is queued into an agent, so a
146
+ * correlation the journal later has to WEAKEN — a prompt id reused, a
147
+ * conversation replaced — would still reach the agent claiming "this is the run
148
+ * YOU queued". This lets the journal pull the stale copy back (only while it is
149
+ * still unread) and re-deliver the honest, downgraded version. Returns whether
150
+ * the item was actually removed.
151
+ */
152
+ setRevoker(revoke, reflush) {
153
+ this.revoke = revoke;
154
+ this.reflush = reflush ?? null;
155
+ }
156
+ /** Re-arm an entry whose correlation just weakened, if its already-queued copy
157
+ * can still be pulled back. Once the carrying turn has started the text is in
158
+ * the model's context and cannot be recalled — nothing to do but leave it. */
159
+ reissueAfterDowngrade(entry) {
160
+ if (entry.state !== "handed_off")
161
+ return;
162
+ if (!this.revoke?.(entry.key, entry.token))
163
+ return;
164
+ entry.state = "pending";
165
+ logger.info(`[run-completions] pulled a queued completion back after its correlation weakened — re-delivering it as ${describe(entry.correlation)}`);
166
+ // Re-queue it immediately with the honest wording; a revoked entry left
167
+ // merely `pending` would wait for some unrelated later flush.
168
+ this.reflush?.(entry.key);
169
+ }
170
+ /**
171
+ * A NEW run has taken over `promptId`, so every completion already journaled
172
+ * under it belongs to an OLDER run. Retire them: superseded (so they can never
173
+ * be merged into, settle a ticket, or memoize a delivery), downgraded from
174
+ * `matched` to `foreign` (so they stop claiming to be the run now outstanding),
175
+ * and re-issued if their queued copy can still be pulled back and re-worded.
176
+ *
177
+ * Runs on BOTH openRun paths — a reopen AND a fresh ticket after the old one
178
+ * was evicted. Only doing it on the reopen left the eviction ordering able to
179
+ * present an older run's result as the newly queued one's.
180
+ */
181
+ retireOlderEntriesFor(promptId) {
182
+ for (const entry of this.entries.values()) {
183
+ if (entry.correlation.status === "unidentified" ||
184
+ entry.correlation.promptId !== promptId ||
185
+ entry.superseded) {
186
+ continue;
187
+ }
188
+ entry.superseded = true;
189
+ if (entry.correlation.status === "matched") {
190
+ entry.correlation = { status: "foreign", promptId: entry.correlation.promptId };
191
+ }
192
+ logger.warn(`[run-completions] prompt ${promptId} was queued again while an earlier completion for it was still undelivered — the older entry is superseded (and reported as undetermined) so it can no longer answer for the new run`);
193
+ entry.ambiguousId = true; // the id now stands for more than one run
194
+ this.reissueAfterDowngrade(entry);
195
+ }
196
+ }
197
+ /** Has this tab ALREADY seen a completion for this prompt id — delivered (a
198
+ * memo from any generation) or still journaled? If so the id is not a fresh
199
+ * identity, however long ago that was and whether or not its ticket survives.
200
+ * Both stores are bounded, so this is a small scan. */
201
+ hasHistoryFor(key, promptId) {
202
+ const prefix = `${key}|${promptId}|`;
203
+ for (const memo of this.delivered) {
204
+ if (memo.startsWith(prefix))
205
+ return true;
206
+ }
207
+ for (const entry of this.entries.values()) {
208
+ if (entry.key === key &&
209
+ entry.correlation.status !== "unidentified" &&
210
+ entry.correlation.promptId === promptId) {
211
+ return true;
212
+ }
213
+ }
214
+ return false;
215
+ }
216
+ /** `panel_run` queued a render. Returns false when ComfyUI/the panel gave us
217
+ * no prompt id — the caller MUST then tell the agent its completion cannot be
218
+ * correlated rather than promising a notification it may not be able to
219
+ * attribute. */
220
+ openRun(promptId, meta) {
221
+ if (typeof promptId !== "string" || !promptId)
222
+ return false;
223
+ // NOTE: no memo clearing is needed here. The delivered memo is keyed by
224
+ // ticket GENERATION, and every path below either bumps the generation (a
225
+ // reopen) or mints a fresh one (a new ticket), so a newly queued run's memo
226
+ // key is unused by construction. This used to be a `delete` precisely because
227
+ // the key was generation-blind — the structural fix removed the need.
228
+ const existing = this.tickets.get(promptId);
229
+ if (existing) {
230
+ // Same prompt id queued again (a re-run of an id ComfyUI reused, or a
231
+ // duplicate reply): reopen it rather than stacking a second ticket, so one
232
+ // prompt id always means one run.
233
+ existing.settled = false;
234
+ existing.tabId = meta.tabId;
235
+ existing.queuedAt = Date.now();
236
+ existing.seq = ++this.ticketSeq; // a NEW generation of this id
237
+ // The id no longer identifies ONE run. Every completion for it from here
238
+ // on is unattributable (see RunTicket.reused) — reported UNDETERMINED, and
239
+ // never suppressed as a duplicate of the other generation.
240
+ existing.reused = true;
241
+ this.retireOlderEntriesFor(promptId);
242
+ return true;
243
+ }
244
+ // A FRESH ticket is only a fresh IDENTITY if this tab has no history for the
245
+ // id. If it does — a delivered memo from an earlier generation, or an entry
246
+ // still journaled — then ComfyUI has REUSED the id and the ticket was merely
247
+ // evicted in between. Nothing on the wire separates "run A's late resend"
248
+ // from "run B's completion" in that state, so the new ticket is born
249
+ // ambiguous: had it been treated as a clean identity, A's resend would
250
+ // correlate as B, its ack would settle B, and B's real completion would then
251
+ // be suppressed by B's own settled flag — misattribution AND loss.
252
+ const hasHistory = this.hasHistoryFor(meta.tabId, promptId);
253
+ this.tickets.set(promptId, {
254
+ promptId,
255
+ tabId: meta.tabId,
256
+ seq: ++this.ticketSeq,
257
+ queuedAt: Date.now(),
258
+ ...(typeof meta.toNodeId === "number" ? { toNodeId: meta.toNodeId } : {}),
259
+ settled: false,
260
+ ...(hasHistory ? { reused: true } : {}),
261
+ });
262
+ if (hasHistory) {
263
+ logger.warn(`[run-completions] prompt ${promptId} was queued again for tab ${meta.tabId.slice(0, 8)} after its ticket had been evicted — this tab already has history for that id, so it is treated as REUSED (every completion for it reported as undetermined)`);
264
+ }
265
+ // …and on THIS branch too. A fresh ticket after the old one was evicted is
266
+ // still a NEW run for an id that already has journaled completions: without
267
+ // this, an older `matched` entry survives untouched and goes on telling the
268
+ // agent "this is the run YOU queued" for the id now outstanding.
269
+ this.retireOlderEntriesFor(promptId);
270
+ this.trimTickets();
271
+ return true;
272
+ }
273
+ /**
274
+ * Classify a completion that arrived for panel tab `key` against the open runs.
275
+ *
276
+ * TWO conditions, both required: EXACT prompt-id equality AND the ticket must
277
+ * belong to THIS panel tab. There is deliberately no "the newest outstanding
278
+ * run" fallback and no cross-tab match, because attributing a completion to
279
+ * the wrong run — or to a different workflow's agent — is worse than not
280
+ * attributing it at all. A run whose ticket belongs to another tab reads as
281
+ * `foreign`, i.e. UNDETERMINED, which is the honest answer.
282
+ */
283
+ correlate(key, payload) {
284
+ const pid = typeof payload.prompt_id === "string" ? payload.prompt_id.trim() : "";
285
+ if (!pid)
286
+ return { status: "unidentified" };
287
+ const ticket = this.tickets.get(pid);
288
+ // A REUSED id proves nothing: the panel sends only the id, so a completion
289
+ // for it could belong to either generation. Report it as foreign — real, but
290
+ // UNDETERMINED — rather than claiming it is the run now outstanding.
291
+ return ticket && ticket.tabId === key && !ticket.reused
292
+ ? { status: "matched", promptId: pid }
293
+ : { status: "foreign", promptId: pid };
294
+ }
295
+ /**
296
+ * Journal a completion addressed to `key`. Correlation is computed here, once.
297
+ *
298
+ * DEDUPE, in both directions:
299
+ * • IDENTIFIED (matched or foreign) — collapses onto any existing undelivered
300
+ * entry for the same key + prompt id, and is suppressed outright once that
301
+ * (tab, run) has been acked. One run can never produce two deliveries,
302
+ * however many times the panel re-sends it.
303
+ * • ID-LESS — there is no run identity, so it dedupes on a CONTENT
304
+ * fingerprint within IDLESS_DEDUPE_WINDOW_MS. That is enough to stop a
305
+ * re-sent frame producing two turns (the agent double-reporting, or acting
306
+ * twice on one output), while a later genuinely different render — or the
307
+ * same content long afterwards — is still delivered.
308
+ * NEVER returns null: a completion is always journaled and always delivered.
309
+ * The most this does is COLLAPSE onto a twin that nobody has seen yet, or FLAG
310
+ * one as a possible repeat. Suppression was the source of every loss this file
311
+ * kept re-growing, because each proof it rested on is bounded and any expiry at
312
+ * the wrong moment turned a new run's result into a discarded "duplicate".
313
+ */
314
+ record(key, payload) {
315
+ const correlation = this.correlate(key, payload);
316
+ let idlessRepeat = false;
317
+ /** This id already stands for more than one run (see RunTicket.reused). */
318
+ let idReused = false;
319
+ /** No provable identity for this completion — see the `unprovable` note in
320
+ * the identified branch. Never merged into, never suppressed. */
321
+ let idUnprovable = false;
322
+ /** Ticket generation this completion belongs to — the run identity (0 = no
323
+ * ticket, i.e. a run this tab never queued). */
324
+ let gen = 0;
325
+ /** A completion for this run was already delivered. A LABEL, not a veto. */
326
+ let alreadyDelivered = false;
327
+ if (correlation.status !== "unidentified") {
328
+ // Already DELIVERED once (its carrying turn ended, so the entry is gone).
329
+ // A panel that re-sends the frame must not produce a second delivery — the
330
+ // dedupe below can't see an entry that no longer exists.
331
+ // Two independent records of "already delivered": the bounded memo, and the
332
+ // run TICKET's own `settled` flag. The memo is FIFO-capped, so a busy tab
333
+ // can age it out and then re-deliver a very late resend; the ticket outlives
334
+ // it for any run this session actually queued. Either one is proof.
335
+ const settledTicket = this.tickets.get(correlation.promptId);
336
+ // Both proofs are DISABLED for a reused id: neither can tell which
337
+ // generation a completion belongs to, so suppressing on them could swallow
338
+ // the newer run's real result. A duplicate delivery (labelled UNDETERMINED)
339
+ // is the correct trade here.
340
+ idReused = settledTicket?.reused === true && settledTicket.tabId === key;
341
+ // The memo is keyed by GENERATION, so an older run's delivery can never
342
+ // suppress a newer one that merely reuses the id (or that got a fresh
343
+ // ticket after the old one was evicted): different generation, different
344
+ // key, no match.
345
+ gen = this.generationOf(key, correlation.promptId);
346
+ // UNPROVABLE identity — no dedupe of any kind is safe:
347
+ // • `reused`: the id stands for more than one run (see RunTicket.reused).
348
+ // • gen 0: this tab never ticketed the id, so there is NO generation to
349
+ // tell one such completion from another. Every foreign completion would
350
+ // share the key `(tab, id, 0)`, which is a bucket, not an identity —
351
+ // two genuinely different external renders that reuse a prompt id (a
352
+ // ComfyUI restart does exactly that) would merge, and the second would
353
+ // be suppressed outright after the first was acked.
354
+ // Both cases fall back to the standing rule: duplicate over loss, each
355
+ // delivered on its own and labelled UNDETERMINED.
356
+ idUnprovable = idReused || gen === 0;
357
+ // ALREADY-DELIVERED IS A LABEL, NEVER A VETO.
358
+ //
359
+ // This used to `return null` — suppressing the completion outright — and
360
+ // that single decision produced defect after defect, because every proof it
361
+ // rested on (the memo, the ticket's `settled` flag, the ticket's very
362
+ // existence) is BOUNDED. Whenever one expired at the wrong moment, a
363
+ // genuinely new run's completion was mistaken for an old one's resend and
364
+ // swallowed: exactly the failure this whole file exists to prevent, and the
365
+ // one the project's rules call worse than a duplicate.
366
+ //
367
+ // So the journal now NEVER suppresses an identified completion. It delivers
368
+ // it FLAGGED as a possible repeat and lets the agent — which can read the
369
+ // prompt id, see the outputs, and call get_history — decide. No expiry can
370
+ // turn that into a loss, and the honest failure mode is a second turn
371
+ // saying "this may be the same render", not silence.
372
+ if (!idUnprovable &&
373
+ (this.delivered.has(deliveredKey(key, correlation.promptId, gen)) ||
374
+ (settledTicket?.settled === true && settledTicket.tabId === key))) {
375
+ logger.info(`[run-completions] a completion for ${describe(correlation)} was already delivered to tab ${key.slice(0, 8)} — forwarding this one FLAGGED as a possible repeat (never suppressed)`);
376
+ alreadyDelivered = true;
377
+ }
378
+ // COALESCE ONLY ONTO A `pending` ENTRY. This is the correctness rule, and
379
+ // it is deliberately a property of the TARGET'S OWN CURRENT STATE — not of
380
+ // any history that could be evicted out from under it.
381
+ //
382
+ // Once an entry is `handed_off` its text is committed to a turn that will
383
+ // ack and DELETE it. Merging a newer completion into it means the newer one
384
+ // is never delivered: the queued turn still holds the older text, and its
385
+ // ack removes the single shared entry. That is a loss, and it is reachable
386
+ // by several orderings of prompt-id reuse vs. ticket eviction — chasing
387
+ // those one at a time is how this defect kept coming back, because reuse
388
+ // DETECTION needs the old ticket, and the ticket is evictable.
389
+ //
390
+ // The same predicate already governs the id-less collapse, for the same
391
+ // reason. The `reused`/`ambiguousId` checks below are now an OPTIMISATION
392
+ // for better wording (an ambiguous run should read as UNDETERMINED rather
393
+ // than merge at all), never the thing correctness rests on.
394
+ //
395
+ // The target must ALSO be the same GENERATION. A `pending` entry from an
396
+ // older run of the same id (its ticket evicted, the id then re-queued) is
397
+ // still a DIFFERENT run: overwriting its payload would drop that run's
398
+ // result and leave the survivor stamped with the wrong generation, so its
399
+ // ack could settle neither. Same-state AND same-identity.
400
+ for (const entry of idUnprovable ? [] : this.entries.values()) {
401
+ if (entry.key === key &&
402
+ entry.state === "pending" && // never merge into text already committed to a turn
403
+ (entry.ticketSeq ?? 0) === gen && // …nor across run generations
404
+ !entry.superseded && // a re-queued run never merges into the old one's entry
405
+ !entry.ambiguousId && // nor into one that arrived under a reused id
406
+ entry.correlation.status !== "unidentified" &&
407
+ entry.correlation.promptId === correlation.promptId) {
408
+ // Safe by the predicate above: this entry is still `pending`, so
409
+ // nothing of it has been committed to a turn. Freshening its payload
410
+ // replaces a copy nobody has seen — one delivery instead of two, with
411
+ // no possibility that an older text acks and deletes the newer news.
412
+ entry.payload = payload;
413
+ return entry;
414
+ }
415
+ }
416
+ }
417
+ else {
418
+ const print = idlessFingerprint(key, payload);
419
+ const now = Date.now();
420
+ // NEVER COLLAPSE AN ID-LESS COMPLETION.
421
+ //
422
+ // The collapse was the last surviving suppression, and it fails for the
423
+ // same reason all the others did — its premise is not establishable. For an
424
+ // identified run, "a twin nobody has seen yet" is a fact: same prompt id,
425
+ // same ticket generation. For an ID-LESS one there is no id, and ComfyUI
426
+ // reuses temp output names (this file says exactly that a few hundred lines
427
+ // up), so "same tab, same content, both pending, within 30s" is PRECISELY
428
+ // the state two genuinely different renders present. Merging there deletes
429
+ // one of them, silently.
430
+ //
431
+ // So every id-less completion gets its own entry. When an identical one is
432
+ // already journaled — in ANY state — or was delivered recently, the new one
433
+ // is FLAGGED `possible_repeat` and the agent decides. That is the same trade
434
+ // already accepted everywhere else: a duplicate turn beats a lost render,
435
+ // and a wording optimisation must never be load-bearing for correctness.
436
+ const twinJournaled = [...this.entries.values()].some((e) => e.key === key && e.correlation.status === "unidentified" && e.fingerprint === print);
437
+ const seenAt = this.idlessSeen.get(print);
438
+ const repeatHint = seenAt !== undefined && now - seenAt < IDLESS_REPEAT_HINT_MS;
439
+ if (seenAt !== undefined && !repeatHint)
440
+ this.idlessSeen.delete(print); // stale
441
+ idlessRepeat = repeatHint || twinJournaled;
442
+ if (idlessRepeat) {
443
+ logger.info(`[run-completions] tab ${key.slice(0, 8)}: an id-less completion with identical content is already known — forwarding this one FLAGGED as a possible repeat (never merged, never suppressed: content is not identity)`);
444
+ }
445
+ }
446
+ const entry = {
447
+ token: `rc${++this.seq}`,
448
+ key,
449
+ payload,
450
+ correlation,
451
+ arrivedAt: Date.now(),
452
+ attempts: 0,
453
+ state: "pending",
454
+ ...(correlation.status === "unidentified"
455
+ ? { fingerprint: idlessFingerprint(key, payload) }
456
+ : {}),
457
+ // One flag, both paths: an identified run whose completion was already
458
+ // delivered, or an id-less one whose content matches a recent delivery.
459
+ ...(idlessRepeat || alreadyDelivered ? { possibleRepeat: true } : {}),
460
+ // Freeze the ambiguity onto the entry — see JournalEntry.ambiguousId.
461
+ ...(idUnprovable ? { ambiguousId: true } : {}),
462
+ // …and the GENERATION this completion belongs to, for EVERY identified
463
+ // entry (not just matched ones): it is what keeps its ack, its memo and any
464
+ // future merge bound to this run rather than to whatever the id means later.
465
+ ...(correlation.status !== "unidentified" ? { ticketSeq: gen } : {}),
466
+ };
467
+ this.entries.set(entry.token, entry);
468
+ this.trimEntries(key);
469
+ return entry;
470
+ }
471
+ /** Completions this tab lost to an eviction and has not yet been told about.
472
+ * Surfaced on the next delivery so a dropped completion is never silent. */
473
+ dropped = new Map();
474
+ /** Count a lost completion for a tab, bounding the map so a churn of one-off
475
+ * tabs can't grow it forever (the oldest unreported count is discarded — it
476
+ * was already logged at ERROR when it happened). */
477
+ /**
478
+ * Record that a completion for `key` was destroyed by an eviction, so the next
479
+ * delivery to that tab can report it as UNDETERMINED.
480
+ *
481
+ * The count is stamped onto a SURVIVING entry for the same tab whenever one
482
+ * exists — it then rides out on a real delivery and cannot be discarded. The
483
+ * side map is only for a tab with nothing left to carry it, i.e. a tab whose
484
+ * next delivery is hypothetical anyway; that is the only thing the bound can
485
+ * ever discard, and it is logged.
486
+ */
487
+ noteDropped(key, count = 1) {
488
+ if (count <= 0)
489
+ return;
490
+ // The carrier must be an entry `deliverPending` will actually READ — i.e. a
491
+ // PENDING one. A handed-off entry's payload was already built and queued, so
492
+ // stamping the count on it would attach a warning to text nobody will ever
493
+ // see again, and `ack()` would then spend it: the eviction would be neither
494
+ // replayed nor disclosed. With no pending entry the count goes to the side
495
+ // map, where the next pending delivery for this tab picks it up.
496
+ const carrier = [...this.entries.values()].find((e) => e.key === key && e.state === "pending");
497
+ if (carrier) {
498
+ carrier.disclose = (carrier.disclose ?? 0) + count;
499
+ return;
500
+ }
501
+ this.dropped.set(key, (this.dropped.get(key) ?? 0) + count);
502
+ while (this.dropped.size > MAX_DROPPED_KEYS) {
503
+ const victim = this.dropped.keys().next().value;
504
+ if (victim === undefined)
505
+ break;
506
+ const lost = this.dropped.get(victim) ?? 0;
507
+ this.dropped.delete(victim);
508
+ logger.error(`[run-completions] discarding the undelivered-completion count (${lost}) for tab ${victim.slice(0, 8)} — over ${MAX_DROPPED_KEYS} agentless tabs are tracking one; that tab will not be told`);
509
+ }
510
+ }
511
+ /** Entries awaiting a delivery attempt for this key, in arrival order. */
512
+ pending(key) {
513
+ const out = [];
514
+ for (const entry of this.entries.values()) {
515
+ if (entry.key === key && entry.state === "pending")
516
+ out.push(entry);
517
+ }
518
+ return out;
519
+ }
520
+ /**
521
+ * Deliver every pending entry for `key`, in ARRIVAL order, stopping at the
522
+ * first refusal so a newer completion can never overtake an older one that is
523
+ * still stuck.
524
+ *
525
+ * `inject` returns whether the agent TOOK the payload onto its queue — not
526
+ * that it was read. The entry stays journaled either way; only `ack` (the turn
527
+ * that carried it ended) removes it. That is the whole durability property:
528
+ * hand it to an agent that then dies and it comes back here.
529
+ *
530
+ * NOTHING is re-correlated: the payload is stamped with the verdict frozen at
531
+ * arrival, so a replay can never be re-attributed to a run that started later.
532
+ */
533
+ deliverPending(key, inject) {
534
+ let delivered = 0;
535
+ for (const entry of this.pending(key)) {
536
+ // Any completion this tab lost to an eviction rides out on the next one
537
+ // that DOES get through, so an evicted completion is reported rather than
538
+ // silently forgotten.
539
+ // The tab's evicted-completion count: whatever this entry is carrying, plus
540
+ // anything stranded in the side map from a period when the tab had no entry
541
+ // to carry it.
542
+ const lost = (entry.disclose ?? 0) + (this.dropped.get(key) ?? 0);
543
+ const payload = {
544
+ ...entry.payload,
545
+ run_correlation: entry.correlation.status,
546
+ ...(entry.correlation.status === "unidentified"
547
+ ? {}
548
+ : { prompt_id: entry.correlation.promptId }),
549
+ // Second and later attempts ARE re-deliveries — say so, so the agent
550
+ // reads a replay as "this landed late", not as another render.
551
+ ...(entry.attempts > 0 ? { replayed: true } : {}),
552
+ ...(lost > 0 ? { dropped_completions: lost } : {}),
553
+ ...(entry.possibleRepeat ? { possible_repeat: true } : {}),
554
+ };
555
+ const handedOff = inject(payload, entry.token);
556
+ this.noteAttempt(entry.token, handedOff);
557
+ if (!handedOff)
558
+ return { delivered, blockedOn: entry };
559
+ if (lost > 0) {
560
+ // CONSOLIDATE onto the entry; do NOT consider the disclosure spent yet.
561
+ // A hand-off is not consumption: if this agent is stopped before its turn
562
+ // runs, the entry is released and replayed, and the warning must go with
563
+ // it. Only `ack()` — the turn that carried it having ended — clears it.
564
+ entry.disclose = lost;
565
+ this.dropped.delete(key);
566
+ }
567
+ delivered += 1;
568
+ }
569
+ return { delivered, blockedOn: null };
570
+ }
571
+ /** All still-unacked entries for a key (pending OR handed off) — diagnostics. */
572
+ outstanding(key) {
573
+ return [...this.entries.values()].filter((e) => e.key === key);
574
+ }
575
+ /** Is ANY completion still undelivered anywhere? The orchestrator's
576
+ * self-restart gate reads this: the journal is in-memory, so restarting while
577
+ * an entry is outstanding would silently drop a render result the agent was
578
+ * promised. */
579
+ hasOutstanding() {
580
+ return this.entries.size > 0;
581
+ }
582
+ /** Every still-unacked entry, across all tabs — for the last-ditch disclosure
583
+ * the orchestrator makes when a fatal self-exit is about to destroy them. */
584
+ allOutstanding() {
585
+ return [...this.entries.values()];
586
+ }
587
+ /** Record the outcome of a delivery attempt. `handedOff` true = an agent took
588
+ * it onto its queue (not yet proof it was read). */
589
+ noteAttempt(token, handedOff) {
590
+ const entry = this.entries.get(token);
591
+ if (!entry)
592
+ return;
593
+ entry.attempts += 1;
594
+ entry.state = handedOff ? "handed_off" : "pending";
595
+ }
596
+ /** The turn that CARRIED this completion ended — it genuinely reached the
597
+ * agent. Drop the entry and settle its run. */
598
+ ack(token) {
599
+ const entry = this.entries.get(token);
600
+ if (!entry)
601
+ return;
602
+ this.entries.delete(token);
603
+ // The turn that carried it ended, so any eviction disclosure it was carrying
604
+ // has now actually reached the agent — only here is it spent. (An entry
605
+ // evicted while still holding one passes it on; see trimEntries.)
606
+ delete entry.disclose;
607
+ // A SUPERSEDED entry belongs to a run whose prompt id was queued again. It
608
+ // was still delivered (its text reached the agent), but it must not settle
609
+ // the REOPENED ticket — that would present the old result as the new run's —
610
+ // and must not memoize the id as delivered, which would then suppress the new
611
+ // run's real completion.
612
+ if (entry.superseded)
613
+ return;
614
+ if (entry.correlation.status !== "unidentified") {
615
+ // …and do not memoize a REUSED id either. DEFENSE IN DEPTH, not a live
616
+ // defect: `openRun` already clears the memo on every path, so no reachable
617
+ // sequence can let a stale one suppress a later legitimate completion (and
618
+ // therefore no test can fail on this line). It is here because writing a
619
+ // "we already reported this run" proof about an id that stands for MORE
620
+ // THAN ONE run is meaningless on its face, and a future caller that opens a
621
+ // ticket without going through openRun would inherit the trap.
622
+ const ticket = this.tickets.get(entry.correlation.promptId);
623
+ // Only a REAL generation is a proof. Generation 0 means this tab never
624
+ // ticketed the id, so `(tab, id, 0)` is a bucket shared by every foreign
625
+ // completion for it — memoizing there would let one external render's
626
+ // delivery suppress a different one that happens to reuse the id.
627
+ const provable = (entry.ticketSeq ?? 0) > 0;
628
+ if (provable && !(ticket?.reused === true && ticket.tabId === entry.key)) {
629
+ // Memoize against THIS entry's own generation, never the id's current
630
+ // meaning: an older run's ack must not write a proof that then suppresses
631
+ // the newer run which reused the id (or got a fresh ticket after the old
632
+ // one was evicted).
633
+ this.memoDelivered(entry.key, entry.correlation.promptId, entry.ticketSeq ?? 0);
634
+ }
635
+ }
636
+ else if (entry.fingerprint) {
637
+ // ID-LESS: memoize the CONTENT so a re-sent identical frame after the ack
638
+ // doesn't produce a second turn. Windowed in record(), bounded here.
639
+ this.idlessSeen.set(entry.fingerprint, Date.now());
640
+ while (this.idlessSeen.size > MAX_DELIVERED_MEMO) {
641
+ const oldest = this.idlessSeen.keys().next().value;
642
+ if (oldest === undefined)
643
+ break;
644
+ this.idlessSeen.delete(oldest);
645
+ }
646
+ }
647
+ if (entry.correlation.status === "matched") {
648
+ const ticket = this.tickets.get(entry.correlation.promptId);
649
+ // ONLY the exact ticket generation this entry was matched against. Looking
650
+ // the ticket up by prompt id alone let a late completion for run A settle
651
+ // whatever ticket that id maps to NOW — run B's — marking B answered by A's
652
+ // result. See RunTicket.seq.
653
+ if (ticket && ticket.seq === entry.ticketSeq)
654
+ ticket.settled = true;
655
+ }
656
+ }
657
+ /**
658
+ * An agent gave a hand-off back undelivered. Re-arm it for replay.
659
+ *
660
+ * `carried` distinguishes the two causes, and ONLY the first is bounded:
661
+ * • carried: true — a turn actually DISPATCHED with this completion in it and
662
+ * then ended, but its result could not be proven to be that turn's own. The
663
+ * agent read the text. A backend that declares turn markers yet never stamps
664
+ * its results would bounce the entry here on every single turn, so after
665
+ * MAX_CARRIED_RELEASES we settle rather than loop: a duplicate at worst.
666
+ * • carried: false — a teardown handed it back (agent stopped, held mail
667
+ * discarded, session died). NOBODY read it. These must never count toward
668
+ * the bound, or three ordinary provider/session replacements would settle —
669
+ * and lose — a completion that was only ever shuffled between queues.
670
+ */
671
+ release(token, opts = {}) {
672
+ const entry = this.entries.get(token);
673
+ if (!entry)
674
+ return;
675
+ if (opts.carried) {
676
+ entry.carriedReleases = (entry.carriedReleases ?? 0) + 1;
677
+ if (entry.carriedReleases >= MAX_CARRIED_RELEASES) {
678
+ logger.warn(`[run-completions] ${describe(entry.correlation)} was carried by ${entry.carriedReleases} turns that ended without a provable ack — settling it instead of replaying again`);
679
+ this.ack(token);
680
+ return;
681
+ }
682
+ }
683
+ entry.state = "pending";
684
+ }
685
+ /** Move every entry AND every open run ticket from `from` onto `to` — a panel
686
+ * tab-id migration re-keys the agent, and both must move WITH it or a later
687
+ * completion for a run queued under the old id reads as foreign. This
688
+ * re-addresses, it never broadens: other tabs' state is untouched. */
689
+ moveKey(from, to) {
690
+ if (from === to)
691
+ return;
692
+ for (const entry of this.entries.values()) {
693
+ if (entry.key === from)
694
+ entry.key = to;
695
+ }
696
+ for (const ticket of this.tickets.values()) {
697
+ if (ticket.tabId === from)
698
+ ticket.tabId = to;
699
+ }
700
+ // ALL per-tab state moves, not just the visible two. Leaving the delivered
701
+ // memo behind would let a post-migration re-send be delivered a SECOND time
702
+ // (its settled ticket moved, so it still correlates as matched); leaving the
703
+ // eviction counter behind would silently swallow the "these runs are
704
+ // undetermined" disclosure it exists to carry.
705
+ for (const memo of [...this.delivered]) {
706
+ if (!memo.startsWith(`${from}|`))
707
+ continue;
708
+ this.delivered.delete(memo);
709
+ this.delivered.add(`${to}|${memo.slice(from.length + 1)}`);
710
+ }
711
+ const lost = this.dropped.get(from);
712
+ if (lost !== undefined) {
713
+ this.dropped.delete(from);
714
+ this.dropped.set(to, (this.dropped.get(to) ?? 0) + lost);
715
+ }
716
+ // The id-less content memo and every entry's fingerprint embed the tab key,
717
+ // so they must be re-keyed too or a post-migration re-send is delivered
718
+ // twice — the same defect as the delivered memo above.
719
+ const fromPrefix = `${from}|~idless~|`;
720
+ for (const [print, at] of [...this.idlessSeen]) {
721
+ if (!print.startsWith(fromPrefix))
722
+ continue;
723
+ this.idlessSeen.delete(print);
724
+ this.idlessSeen.set(`${to}|~idless~|${print.slice(fromPrefix.length)}`, at);
725
+ }
726
+ for (const entry of this.entries.values()) {
727
+ if (entry.fingerprint?.startsWith(fromPrefix)) {
728
+ entry.fingerprint = `${to}|~idless~|${entry.fingerprint.slice(fromPrefix.length)}`;
729
+ }
730
+ }
731
+ }
732
+ /**
733
+ * Drop everything belonging to a tab that will never come back — a closed tab,
734
+ * or the workflow being switched AWAY from.
735
+ *
736
+ * Tickets go too, not just entries: a run queued under the old workflow that
737
+ * finishes AFTER the switch would otherwise still `correlate` as matched (the
738
+ * ticket map is global) and be delivered to the NEW workflow's agent as "the
739
+ * run YOU queued". Forgetting the ticket makes that completion read as
740
+ * foreign — i.e. UNDETERMINED — which is the honest answer.
741
+ *
742
+ * Logs every completion that dies unacked; a loss must never be silent.
743
+ */
744
+ forget(key) {
745
+ for (const [token, entry] of [...this.entries]) {
746
+ if (entry.key !== key)
747
+ continue;
748
+ this.entries.delete(token);
749
+ logger.warn(`[run-completions] dropping an undelivered completion for ${describe(entry.correlation)} — its tab (${key.slice(0, 8)}) is gone`);
750
+ }
751
+ for (const [pid, ticket] of [...this.tickets]) {
752
+ if (ticket.tabId === key)
753
+ this.tickets.delete(pid);
754
+ }
755
+ // An eviction disclosure this tab was still owed dies with it — the tab is
756
+ // gone, so there is no delivery left to carry it. Say so at ERROR rather than
757
+ // dropping it silently: the eviction path PROMISES the loss will be reported,
758
+ // and this is the one place that promise cannot be kept.
759
+ const owed = this.dropped.get(key) ?? 0;
760
+ if (owed > 0) {
761
+ logger.error(`[run-completions] tab ${key.slice(0, 8)} is gone still owed a disclosure for ${owed} evicted completion(s) — it will never be told; treat those runs as UNDETERMINED`);
762
+ }
763
+ this.dropped.delete(key);
764
+ for (const memo of [...this.delivered]) {
765
+ if (memo.startsWith(`${key}|`))
766
+ this.delivered.delete(memo);
767
+ }
768
+ for (const print of [...this.idlessSeen.keys()]) {
769
+ if (print.startsWith(`${key}|~idless~|`))
770
+ this.idlessSeen.delete(print);
771
+ }
772
+ }
773
+ /**
774
+ * The CONVERSATION that queued this tab's outstanding runs is gone — New chat,
775
+ * a switch to a historical session, or a workflow replaced in place.
776
+ *
777
+ * Drop the tab's run TICKETS but keep its journal entries. A render queued by
778
+ * the old conversation is still real and its completion is still delivered;
779
+ * it just can no longer be introduced to the replacement agent as "the run YOU
780
+ * queued" — it correlates as foreign, i.e. UNDETERMINED. Entries that already
781
+ * arrived keep the verdict frozen at THEIR arrival, so a completion the old
782
+ * conversation was owed is still reported to it correctly if it is still
783
+ * deliverable.
784
+ */
785
+ closeRuns(key) {
786
+ for (const [pid, ticket] of [...this.tickets]) {
787
+ if (ticket.tabId === key)
788
+ this.tickets.delete(pid);
789
+ }
790
+ // Entries still undelivered were addressed to the conversation that just
791
+ // went away, so their `matched` verdict no longer holds for whoever receives
792
+ // them next: DOWNGRADE to foreign. This does not violate "correlated once at
793
+ // arrival" — a correlation may only ever get WEAKER (matched → foreign),
794
+ // never stronger, and only in response to an explicit "that conversation is
795
+ // gone" event. Without it a completion journaled before New chat would be
796
+ // replayed to the replacement agent as "the run YOU queued".
797
+ for (const entry of this.entries.values()) {
798
+ if (entry.key === key && entry.correlation.status === "matched") {
799
+ entry.correlation = { status: "foreign", promptId: entry.correlation.promptId };
800
+ // Same as the reused-id downgrade: the queued copy still claims to be the
801
+ // run this (now-replaced) conversation queued. Recall it if we still can.
802
+ this.reissueAfterDowngrade(entry);
803
+ }
804
+ }
805
+ }
806
+ /** Remember (tab, run) as already reported, so a later re-send of the same
807
+ * frame is suppressed rather than delivered a second time. Bounded FIFO; the
808
+ * run TICKET's `settled` flag is the other record, and an evicted settled
809
+ * ticket feeds this one so neither expires before the other. */
810
+ memoDelivered(key, promptId, gen) {
811
+ this.delivered.add(deliveredKey(key, promptId, gen));
812
+ while (this.delivered.size > MAX_DELIVERED_MEMO) {
813
+ const oldest = this.delivered.values().next().value;
814
+ if (oldest === undefined)
815
+ break;
816
+ this.delivered.delete(oldest);
817
+ }
818
+ }
819
+ /** Test/diagnostic helpers. */
820
+ ticketFor(promptId) {
821
+ return this.tickets.get(promptId);
822
+ }
823
+ reset() {
824
+ this.tickets.clear();
825
+ this.entries.clear();
826
+ this.dropped.clear();
827
+ this.delivered.clear();
828
+ this.idlessSeen.clear();
829
+ this.seq = 0;
830
+ }
831
+ /** Evicted completions this tab has not been told about yet — wherever the
832
+ * count currently lives (riding an entry, or the agentless side map). */
833
+ droppedFor(key) {
834
+ const carried = [...this.entries.values()]
835
+ .filter((e) => e.key === key)
836
+ .reduce((n, e) => n + (e.disclose ?? 0), 0);
837
+ return carried + (this.dropped.get(key) ?? 0);
838
+ }
839
+ trimTickets() {
840
+ while (this.tickets.size > MAX_TICKETS) {
841
+ // Prefer evicting a settled ticket; otherwise the oldest. An evicted OPEN
842
+ // ticket means a later completion for it correlates as "foreign" — i.e.
843
+ // UNDETERMINED, which is the honest reading once we've forgotten the run.
844
+ let victim = null;
845
+ for (const [pid, t] of this.tickets) {
846
+ if (t.settled) {
847
+ victim = pid;
848
+ break;
849
+ }
850
+ }
851
+ if (!victim)
852
+ victim = this.tickets.keys().next().value ?? null;
853
+ if (!victim)
854
+ return;
855
+ // Evicting a SETTLED ticket does not lose the "already reported" proof:
856
+ // ack() writes it to the delivered memo at the same moment it sets
857
+ // `settled`, and MAX_DELIVERED_MEMO is deliberately far larger than
858
+ // MAX_TICKETS so the memo always outlives the ticket that mirrors it.
859
+ this.tickets.delete(victim);
860
+ }
861
+ }
862
+ /**
863
+ * Enforce the per-tab and global ceilings.
864
+ *
865
+ * EVICTION ORDER matters: an entry already HANDED OFF is sitting in a live
866
+ * agent's queue and will most likely be read, so it is the cheapest thing to
867
+ * forget; a still-PENDING entry has reached nobody, so evicting one is a real
868
+ * loss. Pending entries are therefore evicted last, logged at ERROR, and
869
+ * COUNTED — the count rides out on the next completion that does get through
870
+ * (`dropped_completions`), so the agent is told those runs are undetermined
871
+ * instead of the loss being silent.
872
+ */
873
+ trimEntries(key) {
874
+ const evict = (scope, limit, label) => {
875
+ let mine = [...this.entries.values()].filter(scope);
876
+ while (mine.length > limit) {
877
+ // Prefer an already-handed-off entry: it is at least sitting in a live
878
+ // agent's queue, so it is the likeliest to land anyway. But a hand-off is
879
+ // explicitly NOT proof of consumption, so an evicted hand-off is counted
880
+ // and reported exactly like an evicted pending one — evicting it removes
881
+ // our ability to replay it if that agent dies first.
882
+ const victim = mine.find((e) => e.state === "handed_off") ?? mine[0];
883
+ this.entries.delete(victim.token);
884
+ mine = mine.filter((e) => e !== victim);
885
+ // …and PULL ITS QUEUED COPY with it. The journal's cap bounds the
886
+ // journal; the agent's queue owns the actual payload, so evicting the
887
+ // record alone left the text queued forever. A panel resending in a tight
888
+ // loop would then grow that queue without limit while the journal stayed
889
+ // at 32 — and the whole backlog drains into ONE turn, which is how the
890
+ // genuine completion gets starved. Revoking keeps the two bounded
891
+ // together. (No-op once the carrying turn has started; that copy is
892
+ // already committed and is bounded by the turn instead.)
893
+ this.revoke?.(victim.key, victim.token);
894
+ // Its own loss PLUS any disclosure it was carrying for the tab — moved to
895
+ // whatever survives, so an eviction can never drop the disclosure itself.
896
+ this.noteDropped(victim.key, 1 + (victim.disclose ?? 0));
897
+ logger.error(`[run-completions] ${label} — dropped a ${victim.state === "pending" ? "still-undelivered" : "handed-off-but-unconfirmed"} completion for ${describe(victim.correlation)}; the next delivery will report it as undetermined`);
898
+ }
899
+ };
900
+ evict((e) => e.key === key, MAX_ENTRIES_PER_KEY, `journal for tab ${key.slice(0, 8)} exceeded ${MAX_ENTRIES_PER_KEY} undelivered completions`);
901
+ evict(() => true, MAX_ENTRIES_TOTAL, `journal exceeded ${MAX_ENTRIES_TOTAL} undelivered completions overall`);
902
+ }
903
+ }
904
+ /** Short human label for a correlation, for logs. */
905
+ export function describe(correlation) {
906
+ return correlation.status === "unidentified"
907
+ ? "an unidentified run"
908
+ : `${correlation.status === "matched" ? "run" : "foreign run"} ${correlation.promptId}`;
909
+ }
910
+ /** Process-wide journal (mirrors the QueueMonitor singleton): `panel_run` opens
911
+ * tickets from the tool layer while the orchestrator's panel-event handler
912
+ * records and replays completions, with no ctx plumbing between them. */
913
+ export const RunCompletions = new RunCompletionJournalImpl();
914
+ //# sourceMappingURL=run-completion-journal.js.map