comfyui-mcp 0.49.4 → 0.49.6
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/dist/orchestrator/ask-answer-journal.js +1484 -0
- package/dist/orchestrator/ask-answer-journal.js.map +1 -0
- package/dist/orchestrator/index.js +287 -20
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +133 -4
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +618 -45
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/services/crash-log.js +7 -1
- package/dist/services/crash-log.js.map +1 -1
- package/dist/services/download-cache.js +767 -124
- package/dist/services/download-cache.js.map +1 -1
- package/dist/services/download-jobs.js +135 -3
- package/dist/services/download-jobs.js.map +1 -1
- package/dist/services/download-retry.js +253 -0
- package/dist/services/download-retry.js.map +1 -0
- package/dist/services/flatten-workflow.js +341 -20
- package/dist/services/flatten-workflow.js.map +1 -1
- package/dist/services/graph-query.js +200 -26
- package/dist/services/graph-query.js.map +1 -1
- package/dist/services/job-history.js +18 -1
- package/dist/services/job-history.js.map +1 -1
- package/dist/services/launcher-env.js +532 -0
- package/dist/services/launcher-env.js.map +1 -0
- package/dist/services/listener-ownership.js +390 -0
- package/dist/services/listener-ownership.js.map +1 -0
- package/dist/services/live-interpreter.js +140 -16
- package/dist/services/live-interpreter.js.map +1 -1
- package/dist/services/node-dev.js +220 -31
- package/dist/services/node-dev.js.map +1 -1
- package/dist/services/node-management.js +93 -9
- package/dist/services/node-management.js.map +1 -1
- package/dist/services/output-dir.js +89 -4
- package/dist/services/output-dir.js.map +1 -1
- package/dist/services/panel-installer.js +2769 -25
- package/dist/services/panel-installer.js.map +1 -1
- package/dist/services/panel-pin-guard.js +33 -8
- package/dist/services/panel-pin-guard.js.map +1 -1
- package/dist/services/panel-recovery.js +210 -0
- package/dist/services/panel-recovery.js.map +1 -0
- package/dist/services/panel-sync.js +93 -12
- package/dist/services/panel-sync.js.map +1 -1
- package/dist/services/panel-workspace.js +360 -0
- package/dist/services/panel-workspace.js.map +1 -0
- package/dist/services/port-owner.js +706 -30
- package/dist/services/port-owner.js.map +1 -1
- package/dist/services/process-control.js +1278 -75
- package/dist/services/process-control.js.map +1 -1
- package/dist/services/ui-bridge.js +412 -28
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/workflow-converter.js +823 -103
- package/dist/services/workflow-converter.js.map +1 -1
- package/dist/services/workflow-validator.js +4 -1
- package/dist/services/workflow-validator.js.map +1 -1
- package/dist/services/workspace-env.js +26 -1
- package/dist/services/workspace-env.js.map +1 -1
- package/dist/tools/install-panel.js +17 -6
- package/dist/tools/install-panel.js.map +1 -1
- package/dist/tools/model-management.js +67 -12
- package/dist/tools/model-management.js.map +1 -1
- package/dist/tools/node-dev.js +10 -6
- package/dist/tools/node-dev.js.map +1 -1
- package/dist/tools/registry-search.js +8 -1
- package/dist/tools/registry-search.js.map +1 -1
- package/dist/tools/run-template.js +9 -2
- package/dist/tools/run-template.js.map +1 -1
- package/dist/tools/template-schema.js +17 -0
- package/dist/tools/template-schema.js.map +1 -1
- package/dist/tools/workflow-library.js +23 -8
- package/dist/tools/workflow-library.js.map +1 -1
- package/dist/utils/errors.js +7 -0
- package/dist/utils/errors.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1484 @@
|
|
|
1
|
+
// Durable-across-a-tool-timeout delivery of a VALIDATED panel_ask answer
|
|
2
|
+
// (issue #486).
|
|
3
|
+
//
|
|
4
|
+
// THE FAILURE. `panel_ask` renders a choice card and BLOCKS on the user's pick.
|
|
5
|
+
// The user's answer has exactly one delivery channel: the return value of the
|
|
6
|
+
// enclosing MCP `tools/call`. That call has its own budget (~300s) and its own
|
|
7
|
+
// lifetime — a turn that ends, a client that gives up, a session torn down — and
|
|
8
|
+
// when it dies the answer dies with it, in three distinct ways:
|
|
9
|
+
//
|
|
10
|
+
// 1. ANSWERED IN TIME, NOBODY LEFT TO RETURN TO. `bridge.send` resolves with a
|
|
11
|
+
// validated pick, `askUserWithGrace` returns it, and the ToolResult is
|
|
12
|
+
// written to a request the client already abandoned. Nothing recorded it.
|
|
13
|
+
// 2. ANSWERED DURING THE GRACE POLL. Same, one layer down: the poll TAKES the
|
|
14
|
+
// answer out of the bridge's late-reply buffer (destroying it there) and
|
|
15
|
+
// returns it into the same dead request.
|
|
16
|
+
// 3. ANSWERED AFTER THE GRACE. The handler has already returned "not answered
|
|
17
|
+
// in time". The answer lands in the bridge's late-reply buffer keyed by
|
|
18
|
+
// `ask_id` and NOBODY EVER POLLS IT AGAIN — it is TTL-pruned five minutes
|
|
19
|
+
// later, unread.
|
|
20
|
+
//
|
|
21
|
+
// In every case the user answered, the answer validated, and the agent either
|
|
22
|
+
// asked again or proceeded without it. That is the whole of #486.
|
|
23
|
+
//
|
|
24
|
+
// THE CONTRACT HERE — deliberately the one #468/PR #786 arrived at for run
|
|
25
|
+
// completions (see run-completion-journal.ts), because this is the same problem
|
|
26
|
+
// class and the same traps are waiting:
|
|
27
|
+
//
|
|
28
|
+
// • Asks are TICKETED by their `ask_id`, opened when the card is dispatched.
|
|
29
|
+
// • Every answer is CORRELATED ONCE, AT ARRIVAL, by EXACT ask-id equality
|
|
30
|
+
// against an open ticket — never by recency, never re-derived later. The
|
|
31
|
+
// ticket's QUESTION FINGERPRINT is frozen onto the entry at that moment.
|
|
32
|
+
// • An answer that cannot be correlated is still journaled and still delivered,
|
|
33
|
+
// labelled UNDETERMINED. It is never swallowed, and it can never be presented
|
|
34
|
+
// as the answer to a question it isn't provably an answer to.
|
|
35
|
+
// • THE JOURNAL NEVER MERGES AND NEVER SUPPRESSES — IT ONLY EVER LABELS. Every
|
|
36
|
+
// "already delivered" proof available here (tickets, entries, the bridge's own
|
|
37
|
+
// 5-minute buffer) is BOUNDED, so any rule that SUPPRESSES on one becomes a
|
|
38
|
+
// silent LOSS the moment it expires at the wrong time. #468 removed
|
|
39
|
+
// suppression outright after five successive fixes were each defeated one
|
|
40
|
+
// bound away; nothing here reintroduces it.
|
|
41
|
+
// • Undelivered answers are REPLAYED at the next delivery opportunity, and an
|
|
42
|
+
// entry is cleared only when the turn that CARRIED it ended (or when a
|
|
43
|
+
// matching re-ask provably took it as its result).
|
|
44
|
+
//
|
|
45
|
+
// THE CROSS-QUESTION GUARD is the one rule this file adds over #468's, and it is
|
|
46
|
+
// strictly the more important direction. Losing an answer costs a re-ask;
|
|
47
|
+
// MISATTRIBUTING one makes the agent act on a decision the user never made about
|
|
48
|
+
// the thing at hand. So a recovered answer may only ever satisfy an ask whose
|
|
49
|
+
// QUESTION FINGERPRINT (question text + option labels, in order + multi-select +
|
|
50
|
+
// header) is EXACTLY equal to the fingerprint frozen on the entry at arrival, on
|
|
51
|
+
// the SAME panel tab. There is deliberately no fuzzy match, no "closest
|
|
52
|
+
// question", and no "the only outstanding ask" fallback: an answer that doesn't
|
|
53
|
+
// match exactly is reported as an unattributed answer to ITS OWN question —
|
|
54
|
+
// quoted with that question, so it cannot be read as an answer to anything else.
|
|
55
|
+
//
|
|
56
|
+
// WHAT IS DELIBERATELY NOT GUARANTEED. Three residuals survived four rounds of
|
|
57
|
+
// adversarial review; each is an ACCEPTED trade with its reasoning, not an
|
|
58
|
+
// oversight, and each is stated where it lives as well as here.
|
|
59
|
+
//
|
|
60
|
+
// 1. A RETURNED ANSWER EVICTED BY THE PER-TAB CEILING IS LOGGED, NOT COUNTED as
|
|
61
|
+
// an undetermined loss (see trimEntries). It reached a caller; what its
|
|
62
|
+
// eviction costs is the ability to RECOVER it. Counting each one would fire
|
|
63
|
+
// after ~48 ordinary successful asks and tell the agent that answers it
|
|
64
|
+
// almost certainly received are unknown — and a warning that is usually
|
|
65
|
+
// false is its own kind of silence.
|
|
66
|
+
// 2. A RESTART IS NOT DEFERRED FOR A RETURNED ANSWER (see hasOutstanding vs.
|
|
67
|
+
// allOutstanding). Deferring on one would stall the self-restarter for the
|
|
68
|
+
// whole recovery window after EVERY ask. The fatal-exit disclosure names it
|
|
69
|
+
// instead: saying so on the way out costs nothing.
|
|
70
|
+
// 3. A CARD ANSWERED BEYOND THE BRIDGE'S OPEN-CARD CEILING cannot be recognised
|
|
71
|
+
// at all (UiBridge.MAX_ASK_RID_MAPPINGS). Reaching it needs 1024 unanswered
|
|
72
|
+
// cards outstanding at once; it is logged at ERROR, and reporting it as a
|
|
73
|
+
// "dropped answer" would be a lie about questions nobody answered.
|
|
74
|
+
//
|
|
75
|
+
// LOCAL trust domain: this is accidental-loss bookkeeping, not a defence against
|
|
76
|
+
// a hostile panel. Everything is in-memory and process-scoped.
|
|
77
|
+
import { createHash } from "node:crypto";
|
|
78
|
+
import { logger } from "../utils/logger.js";
|
|
79
|
+
import { tabIncarnationSlot } from "../services/ui-bridge.js";
|
|
80
|
+
/**
|
|
81
|
+
* Cards tracked at once. A ticket is a handful of small fields, and it is what
|
|
82
|
+
* makes an answer ATTRIBUTABLE, so this is sized for a whole session's asks
|
|
83
|
+
* rather than for the two that are usually open. There is deliberately no age
|
|
84
|
+
* limit: a question card sits on screen until the user deals with it, and any
|
|
85
|
+
* clock here would be a bound quietly deciding that an answer no longer counts.
|
|
86
|
+
* Overflowing is logged at ERROR and only ever WEAKENS a correlation to
|
|
87
|
+
* UNDETERMINED — it never drops an answer.
|
|
88
|
+
*/
|
|
89
|
+
const MAX_TICKETS = 1024;
|
|
90
|
+
/**
|
|
91
|
+
* Prefix every `panel_ask` card's ask id carries.
|
|
92
|
+
*
|
|
93
|
+
* The bridge's late-answer sink is fed by EVERY `ask_user` card — the confirm
|
|
94
|
+
* gate, the 18+ consent card, the secret prompt — and only `panel_ask` answers
|
|
95
|
+
* belong in this journal. Ownership therefore has to be decidable from the id
|
|
96
|
+
* ITSELF: an earlier draft answered it from a bounded set of remembered ids,
|
|
97
|
+
* which meant an eviction turned a validated answer into nothing. A bounded
|
|
98
|
+
* store must never be what protects against loss, so the test is syntactic and
|
|
99
|
+
* cannot expire, evict, or be raced.
|
|
100
|
+
*/
|
|
101
|
+
export const PANEL_ASK_ID_PREFIX = "pa-";
|
|
102
|
+
/** Undelivered/unconsumed answers held per panel tab. */
|
|
103
|
+
const MAX_ENTRIES_PER_KEY = 48;
|
|
104
|
+
/** …and across all tabs. */
|
|
105
|
+
const MAX_ENTRIES_TOTAL = 192;
|
|
106
|
+
/**
|
|
107
|
+
* How long a journaled answer may still be RECOVERED as the result of a re-ask
|
|
108
|
+
* of the identical question.
|
|
109
|
+
*
|
|
110
|
+
* This bound is a LABEL boundary, not a suppression: past it the answer is not
|
|
111
|
+
* returned as "the user's answer to the question you just asked" (it is stale
|
|
112
|
+
* enough that the user plausibly meant it for the earlier moment), but it is
|
|
113
|
+
* still DISCLOSED, quoted with its own question, so it is never silently
|
|
114
|
+
* discarded.
|
|
115
|
+
*/
|
|
116
|
+
const RECOVER_MAX_AGE_MS = 10 * 60_000;
|
|
117
|
+
/** Turns that may carry a pushed answer and end without a provable ack before we
|
|
118
|
+
* settle it anyway. Same rationale as MAX_CARRIED_RELEASES in #468: each of
|
|
119
|
+
* those put the text into a turn the agent read, so settling risks a duplicate,
|
|
120
|
+
* never a loss. */
|
|
121
|
+
const MAX_CARRIED_RELEASES = 3;
|
|
122
|
+
/**
|
|
123
|
+
* EXACT identity of a question, and the whole of the cross-question guard.
|
|
124
|
+
*
|
|
125
|
+
* EVERYTHING THE USER READS ON THE CARD GOES IN, in order: the question text,
|
|
126
|
+
* the header chip, the multi-select flag, and every option's LABEL **and
|
|
127
|
+
* DESCRIPTION**. Two asks are "the same question" only when a user looking at
|
|
128
|
+
* both cards would see the same thing.
|
|
129
|
+
*
|
|
130
|
+
* The descriptions are not decoration: they are the one-line explanations the
|
|
131
|
+
* user reads to decide, so "euler / fast, lower quality" and "euler / now the
|
|
132
|
+
* recommended default" are different decisions wearing the same label. Leaving
|
|
133
|
+
* them out would let a re-worded card recover an answer the user gave to
|
|
134
|
+
* different information. The guard errs strict in every direction: a stricter
|
|
135
|
+
* fingerprint costs at most a re-ask, a looser one lets an answer satisfy a
|
|
136
|
+
* question it was not given for.
|
|
137
|
+
*
|
|
138
|
+
* Deliberately NOT included: the tab id (an entry carries its tab separately, so
|
|
139
|
+
* a tab-id migration re-keys it without invalidating every fingerprint) and any
|
|
140
|
+
* timestamp.
|
|
141
|
+
*
|
|
142
|
+
* Whitespace is trimmed and internal runs collapsed on the text fields only,
|
|
143
|
+
* because a re-ask is often the model re-emitting the same prompt with different
|
|
144
|
+
* wrapping. Nothing else is normalized: case, punctuation and ordering are all
|
|
145
|
+
* significant, so "Delete the file?" never matches "delete the file".
|
|
146
|
+
*/
|
|
147
|
+
export function askFingerprint(ask) {
|
|
148
|
+
const norm = (s) => s.replace(/\s+/g, " ").trim();
|
|
149
|
+
const labels = Array.isArray(ask.options)
|
|
150
|
+
? ask.options.map((o) => {
|
|
151
|
+
const opt = o;
|
|
152
|
+
if (typeof opt?.label !== "string")
|
|
153
|
+
return JSON.stringify(o ?? null);
|
|
154
|
+
// Label AND description — an option is the whole thing the user reads.
|
|
155
|
+
return JSON.stringify([
|
|
156
|
+
norm(opt.label),
|
|
157
|
+
typeof opt.description === "string" ? norm(opt.description) : "",
|
|
158
|
+
]);
|
|
159
|
+
})
|
|
160
|
+
: [];
|
|
161
|
+
// JSON.stringify is the delimiter: it escapes the payload itself, so no
|
|
162
|
+
// separator character can ever be smuggled in by a question or an option label
|
|
163
|
+
// to make two DIFFERENT questions hash the same. (And it keeps this file pure
|
|
164
|
+
// ASCII — no control bytes, no exotic separators.)
|
|
165
|
+
const parts = [
|
|
166
|
+
norm(ask.question ?? ""),
|
|
167
|
+
typeof ask.header === "string" ? norm(ask.header) : "",
|
|
168
|
+
ask.multi_select === true ? "multi" : "single",
|
|
169
|
+
...labels,
|
|
170
|
+
];
|
|
171
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
|
|
172
|
+
}
|
|
173
|
+
/** Coerce a bridge reply into the answer text the user actually gave. Panel
|
|
174
|
+
* ask cards reply with a string; anything else is preserved verbatim as JSON so
|
|
175
|
+
* nothing the user chose is ever silently reshaped away. */
|
|
176
|
+
export function answerText(reply) {
|
|
177
|
+
if (typeof reply === "string")
|
|
178
|
+
return reply;
|
|
179
|
+
try {
|
|
180
|
+
return JSON.stringify(reply);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return String(reply);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export class AskAnswerJournalImpl {
|
|
187
|
+
/** ask id → ticket. Ask ids are UUIDs, so exact equality is proof of identity. */
|
|
188
|
+
tickets = new Map();
|
|
189
|
+
/**
|
|
190
|
+
* Ask ids this journal has EVER opened, kept long after their ticket is gone.
|
|
191
|
+
*
|
|
192
|
+
* This is what `tracks()` answers on, and it exists because the ticket map is
|
|
193
|
+
* bounded: if "is this one of ours?" were answered from the tickets alone, an
|
|
194
|
+
* eviction would make the bridge's late-answer sink DROP a validated answer
|
|
195
|
+
* outright — a bounded store silently protecting against a bounded store,
|
|
196
|
+
* which is the exact shape of every defect #468 kept re-growing. Losing a
|
|
197
|
+
* ticket may only ever WEAKEN a correlation (matched → foreign, i.e.
|
|
198
|
+
* UNDETERMINED); it must never turn an answer into nothing.
|
|
199
|
+
*/
|
|
200
|
+
/** Panel tab -> the generation of the conversation currently attached to it.
|
|
201
|
+
* Bumped by closeAsks(); a ticket minted under an older generation belongs to
|
|
202
|
+
* a conversation that is gone. See AskTicket.epoch. */
|
|
203
|
+
tabEpoch = new Map();
|
|
204
|
+
/** token → entry (insertion-ordered, which is also delivery order). */
|
|
205
|
+
entries = new Map();
|
|
206
|
+
/** Answers this tab lost to an eviction and has not yet been told about. */
|
|
207
|
+
dropped = new Map();
|
|
208
|
+
/** How many tool results have already carried each tab's eviction debt. The
|
|
209
|
+
* debt is spent by a COUNT of reports, never by a single hand-off — see
|
|
210
|
+
* reportDropped(). */
|
|
211
|
+
/** Outstanding eviction-disclosure tokens: token -> panel tab. Retired only
|
|
212
|
+
* by the ack of the turn that carried the warning (see reportDropped). */
|
|
213
|
+
debtTokens = new Map();
|
|
214
|
+
/**
|
|
215
|
+
* Per tab: the eviction total that has already been SURFACED and confirmed.
|
|
216
|
+
*
|
|
217
|
+
* A warning names a number, and only that number is settled when the turn
|
|
218
|
+
* carrying it ends. Without this the ack cleared the tab's whole current debt,
|
|
219
|
+
* so an eviction that happened AFTER the warning was rendered — while its turn
|
|
220
|
+
* was still running — was wiped by an ack that never mentioned it. A watermark
|
|
221
|
+
* also makes the ack idempotent: two results reporting the same total settle
|
|
222
|
+
* the same total, and a later one settles more.
|
|
223
|
+
*/
|
|
224
|
+
disclosedUpTo = new Map();
|
|
225
|
+
seq = 0;
|
|
226
|
+
ticketSeq = 0;
|
|
227
|
+
/**
|
|
228
|
+
* Which browser-tab INCARNATION currently holds a panel tab (#486).
|
|
229
|
+
*
|
|
230
|
+
* A `wf:<hash>` tab id names a saved workflow, so it recurs: a second browser
|
|
231
|
+
* tab opening that workflow takes the key over. Anything that holds or settles
|
|
232
|
+
* per-tab state must therefore be scoped to (tab, incarnation) — otherwise the
|
|
233
|
+
* newcomer reads, reports and ACKS the departed tab's bookkeeping, and the
|
|
234
|
+
* departed tab's own disclosure is silently marked told. Undefined when the tab
|
|
235
|
+
* is not currently connected.
|
|
236
|
+
*/
|
|
237
|
+
incarnationOf = null;
|
|
238
|
+
/** Deliver a tab's newly-orphaned answers (see setFlusher). */
|
|
239
|
+
flush = null;
|
|
240
|
+
/** Pull a still-queued answer event back off its agent (see setRevoker). */
|
|
241
|
+
revoke = null;
|
|
242
|
+
/** Ride a token on the tab's in-flight turn, returning the CARRIER IDENTITY it
|
|
243
|
+
* attached to (null when there was no live turn). See setTurnAttacher. */
|
|
244
|
+
attachTurn = null;
|
|
245
|
+
/**
|
|
246
|
+
* Wire the push channel (#486).
|
|
247
|
+
*
|
|
248
|
+
* An answer becomes an ORPHAN at moments the tool layer cannot act on — the
|
|
249
|
+
* bridge's late-answer sink, or an ask handler unwinding on an error without
|
|
250
|
+
* having returned one. Whoever owns the agents registers here so those
|
|
251
|
+
* transitions deliver immediately instead of waiting for some unrelated later
|
|
252
|
+
* trigger (which is how "journaled" quietly becomes "never delivered").
|
|
253
|
+
*/
|
|
254
|
+
setFlusher(flush) {
|
|
255
|
+
this.flush = flush;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Wire the "unsend" hook, mirroring #468's.
|
|
259
|
+
*
|
|
260
|
+
* An answer's WORDING is materialised when it is queued into an agent, and it
|
|
261
|
+
* says "a question card YOU put up". If the conversation is replaced before
|
|
262
|
+
* that item is read, the sentence becomes false — the fork never put that card
|
|
263
|
+
* up. This pulls the stale copy back while it is still unread. Nothing is
|
|
264
|
+
* re-delivered afterwards: the addressee is gone, which is the whole point.
|
|
265
|
+
*/
|
|
266
|
+
setRevoker(revoke) {
|
|
267
|
+
this.revoke = revoke;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Wire the TOOL-RESULT ack (#486).
|
|
271
|
+
*
|
|
272
|
+
* An answer PUSHED as an event is acked when the turn carrying it ends. An
|
|
273
|
+
* answer handed back as a TOOL RESULT had no such proof — and treating the
|
|
274
|
+
* hand-off itself as proof is exactly the assumption #486 exists to demolish.
|
|
275
|
+
* This rides the token on the turn the tool call is running inside, so the
|
|
276
|
+
* SAME ack-on-carry machinery settles it: either that turn's own marked result
|
|
277
|
+
* lands (the model ran to completion after receiving the answer), or it never
|
|
278
|
+
* does and the answer stays accounted for.
|
|
279
|
+
*/
|
|
280
|
+
setTurnAttacher(attach) {
|
|
281
|
+
this.attachTurn = attach;
|
|
282
|
+
}
|
|
283
|
+
/** Wire the per-tab incarnation lookup — see `incarnationOf`. */
|
|
284
|
+
setIncarnationResolver(resolve) {
|
|
285
|
+
this.incarnationOf = resolve;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* May the tab's CURRENT occupant act on this answer — recover it, or be pushed
|
|
289
|
+
* it?
|
|
290
|
+
*
|
|
291
|
+
* Only the occupant it was given by. A different browser tab holding the same
|
|
292
|
+
* recurring key never asked the question and must not be handed its answer;
|
|
293
|
+
* unknown provenance on either side is not a match either, because "we could
|
|
294
|
+
* not tell who this belongs to" must never resolve to "it belongs to you".
|
|
295
|
+
* When NOTHING can resolve incarnations at all (no resolver wired) the check is
|
|
296
|
+
* inert and tab-keying alone governs, exactly as before.
|
|
297
|
+
*/
|
|
298
|
+
/**
|
|
299
|
+
* May this answer be handed to a LIVE TURN — recovered, replayed, or pushed?
|
|
300
|
+
*
|
|
301
|
+
* The single gate for the whole set. Both boundaries are checked here, because
|
|
302
|
+
* they are independent and each alone is insufficient:
|
|
303
|
+
* • RETIRED — its conversation is gone (New chat, rewind, provider switch,
|
|
304
|
+
* workflow replacement, takeover). Same tab, same incarnation, so no amount
|
|
305
|
+
* of incarnation keying can see it.
|
|
306
|
+
* • a DIFFERENT OCCUPANT — another browser tab holds this recurring key. Same
|
|
307
|
+
* conversation identity as far as the tab is concerned, so no amount of
|
|
308
|
+
* epoch bumping can see it.
|
|
309
|
+
*
|
|
310
|
+
* Gating the paths one at a time is how the rid bypass, the re-arm bypass and
|
|
311
|
+
* the debt-reporting bypass each arrived separately; making it a property of
|
|
312
|
+
* the SET is the fix.
|
|
313
|
+
*/
|
|
314
|
+
mayReachLiveTurn(entry) {
|
|
315
|
+
return entry.retired !== true && this.sameOccupant(entry);
|
|
316
|
+
}
|
|
317
|
+
sameOccupant(entry) {
|
|
318
|
+
if (this.incarnationOf === null)
|
|
319
|
+
return true; // nothing to distinguish
|
|
320
|
+
const now = this.incarnationOf(entry.key);
|
|
321
|
+
if (entry.incarnation === undefined || now === undefined)
|
|
322
|
+
return false;
|
|
323
|
+
return entry.incarnation === now;
|
|
324
|
+
}
|
|
325
|
+
/** The debt bucket for a tab's CURRENT occupant. */
|
|
326
|
+
debtSlot(key) {
|
|
327
|
+
return tabIncarnationSlot(key, this.incarnationOf?.(key));
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* A `panel_ask` card is being dispatched. Opens the ticket that makes any
|
|
331
|
+
* answer for `askId` attributable to THIS question.
|
|
332
|
+
*
|
|
333
|
+
* Must be called BEFORE the card is sent: the answer can come back on the
|
|
334
|
+
* bridge's late-reply sink at any moment after that, and an answer that
|
|
335
|
+
* arrives with no ticket is (correctly, but uselessly) foreign.
|
|
336
|
+
*/
|
|
337
|
+
openAsk(askId, meta) {
|
|
338
|
+
const epoch = this.tabEpoch.get(meta.tabId) ?? 0;
|
|
339
|
+
const existing = this.tickets.get(askId);
|
|
340
|
+
// REUSE is also proven by an ANSWER already on file for this id, not only by
|
|
341
|
+
// a surviving ticket. The ticket map is bounded, so keying the guard on it
|
|
342
|
+
// alone would let an eviction restore a "fresh" identity to an id that a
|
|
343
|
+
// still-rendered card can answer — the old card's click would then be frozen
|
|
344
|
+
// with the NEW question's fingerprint and could satisfy it.
|
|
345
|
+
const answered = [...this.entries.values()].some((e) => e.askId === askId);
|
|
346
|
+
if (existing || answered) {
|
|
347
|
+
// The same ask id dispatched AGAIN. Reopen rather than stack a second
|
|
348
|
+
// ticket (one ask id always means one ticket) — but the id now stands for
|
|
349
|
+
// MORE THAN ONE question, and the panel's reply carries only the id, so
|
|
350
|
+
// nothing can ever say which card an answer for it came from. Mark it
|
|
351
|
+
// reused: every answer for it is reported UNDETERMINED from here on, and
|
|
352
|
+
// in particular the older card's late click can no longer be frozen with
|
|
353
|
+
// the newer question's fingerprint and recovered as its answer.
|
|
354
|
+
this.tickets.set(askId, {
|
|
355
|
+
...(existing ?? {}),
|
|
356
|
+
askId,
|
|
357
|
+
tabId: meta.tabId,
|
|
358
|
+
fingerprint: meta.fingerprint,
|
|
359
|
+
question: meta.question,
|
|
360
|
+
openedAt: Date.now(),
|
|
361
|
+
epoch,
|
|
362
|
+
seq: ++this.ticketSeq,
|
|
363
|
+
awaiting: true,
|
|
364
|
+
reused: true,
|
|
365
|
+
});
|
|
366
|
+
logger.warn(`[ask-answers] ask id ${askId.slice(0, 12)} was opened again — it no longer identifies one question, so every answer for it is reported as UNDETERMINED`);
|
|
367
|
+
// FREEZE THE AMBIGUITY ONTO THE ENTRIES THAT ALREADY EXIST for this id.
|
|
368
|
+
// The ticket is evictable and this fact is not: once it is trimmed away,
|
|
369
|
+
// anything keyed on `ticket.reused` silently reverts to treating the id as
|
|
370
|
+
// a clean identity. Each such entry keeps its OWN question text — that is
|
|
371
|
+
// still honest and is what makes its disclosure useful — but loses the
|
|
372
|
+
// licence to answer anything.
|
|
373
|
+
for (const entry of this.entries.values()) {
|
|
374
|
+
if (entry.askId !== askId)
|
|
375
|
+
continue;
|
|
376
|
+
entry.ambiguousId = true;
|
|
377
|
+
entry.recoverable = false;
|
|
378
|
+
entry.fingerprint = null;
|
|
379
|
+
if (entry.correlation.status === "matched") {
|
|
380
|
+
entry.correlation = { status: "foreign", askId };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
this.trimTickets();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
this.tickets.set(askId, {
|
|
387
|
+
askId,
|
|
388
|
+
tabId: meta.tabId,
|
|
389
|
+
fingerprint: meta.fingerprint,
|
|
390
|
+
question: meta.question,
|
|
391
|
+
openedAt: Date.now(),
|
|
392
|
+
epoch,
|
|
393
|
+
seq: ++this.ticketSeq,
|
|
394
|
+
awaiting: true,
|
|
395
|
+
});
|
|
396
|
+
this.trimTickets();
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Is the conversation that opened this ask STILL the one on the tab?
|
|
400
|
+
*
|
|
401
|
+
* The debt footnote rides whatever result is going back, and `reportDropped`
|
|
402
|
+
* attaches its token to the turn that is live NOW. So a result belonging to a
|
|
403
|
+
* conversation that has since been replaced must not carry it: the live turn's
|
|
404
|
+
* ack would settle a warning that conversation never saw. Unknown ticket → true,
|
|
405
|
+
* because a caller with no ticket has nothing to have been replaced.
|
|
406
|
+
*/
|
|
407
|
+
askBelongsToLiveConversation(askId) {
|
|
408
|
+
const ticket = this.tickets.get(askId);
|
|
409
|
+
if (!ticket)
|
|
410
|
+
return true;
|
|
411
|
+
return ticket.epoch === (this.tabEpoch.get(ticket.tabId) ?? 0);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* May this answer's CONTENT be shown to whoever holds its tab right now?
|
|
415
|
+
*
|
|
416
|
+
* The public face of the boundary gate, for outlets that live outside this
|
|
417
|
+
* class. The fatal-exit notice is one: it renders answer text and pushes it to
|
|
418
|
+
* the tab, so it is a delivery like any other and a retired conversation's pick
|
|
419
|
+
* must not appear in it. Counts may always cross; content may not.
|
|
420
|
+
*/
|
|
421
|
+
mayDisclose(entry) {
|
|
422
|
+
return this.mayReachLiveTurn(entry);
|
|
423
|
+
}
|
|
424
|
+
/** Does this journal know the ask id — i.e. is a late answer for it one of
|
|
425
|
+
* OURS? The bridge's late-answer sink is fed by every `ask_user` card
|
|
426
|
+
* (confirm/consent/secret gates included) and only `panel_ask` answers belong
|
|
427
|
+
* here; those other cards have their own, deliberately non-recoverable paths
|
|
428
|
+
* (a recovered "Yes, go ahead" must never authorise a different destructive
|
|
429
|
+
* operation). */
|
|
430
|
+
tracks(askId) {
|
|
431
|
+
return askId.startsWith(PANEL_ASK_ID_PREFIX);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The `panel_ask` handler that opened `askId` has RETURNED. Anything that
|
|
435
|
+
* arrives from here on has nobody to be handed to, so it is orphaned on
|
|
436
|
+
* arrival and pushed to the agent on its own.
|
|
437
|
+
*
|
|
438
|
+
* If an answer is already journaled and the handler never claimed it
|
|
439
|
+
* (`markReturned` was not called), arm it for the push now.
|
|
440
|
+
*/
|
|
441
|
+
closeAsk(askId) {
|
|
442
|
+
const ticket = this.tickets.get(askId);
|
|
443
|
+
if (ticket)
|
|
444
|
+
ticket.awaiting = false;
|
|
445
|
+
let armed = null;
|
|
446
|
+
let answered = false;
|
|
447
|
+
for (const entry of this.entries.values()) {
|
|
448
|
+
if (entry.askId !== askId)
|
|
449
|
+
continue;
|
|
450
|
+
answered = true;
|
|
451
|
+
// A RETIRED answer is never armed. Arming it produces an entry that
|
|
452
|
+
// `pending()` correctly refuses to deliver and `hasOutstanding()` counted
|
|
453
|
+
// as owed — a permanent blocker on the self-restart gate, cleared only by
|
|
454
|
+
// some unrelated later boundary or eviction.
|
|
455
|
+
if (!entry.returned && entry.delivery === "none" && entry.retired !== true) {
|
|
456
|
+
entry.delivery = "pending";
|
|
457
|
+
armed = entry;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// The card has been ANSWERED and its handler has finished, so this ticket can
|
|
461
|
+
// never be needed again — the panel removes an answered card, and everything
|
|
462
|
+
// the ticket carried (tab, question, fingerprint, conversation generation) is
|
|
463
|
+
// frozen onto the entry. Releasing it keeps the ticket map to CARDS STILL ON
|
|
464
|
+
// SCREEN, which is what makes its ceiling unreachable in practice: otherwise
|
|
465
|
+
// a long session's ordinary successful asks fill it and evict a genuinely
|
|
466
|
+
// outstanding card's ticket, taking its retired-conversation marker with it.
|
|
467
|
+
// …EXCEPT under a reused id, where "an entry exists for this ask id" no
|
|
468
|
+
// longer means "this card was answered" — it may be the OTHER card's answer,
|
|
469
|
+
// and this one is still on screen. Keeping the ticket keeps its reused flag,
|
|
470
|
+
// which is what stops either answer satisfying either question.
|
|
471
|
+
if (answered && ticket?.reused !== true)
|
|
472
|
+
this.tickets.delete(askId);
|
|
473
|
+
// It is an orphan NOW — deliver it now. Leaving it merely `pending` would
|
|
474
|
+
// make it wait for an unrelated later flush, and this transition happens on
|
|
475
|
+
// the ask handler's unwind, where there may never be one.
|
|
476
|
+
if (armed)
|
|
477
|
+
this.flush?.(armed.key);
|
|
478
|
+
return armed;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Journal a validated answer. Correlation is computed HERE, once, by exact ask
|
|
482
|
+
* id.
|
|
483
|
+
*
|
|
484
|
+
* Idempotent per ask id: the same card can be observed twice (the handler's
|
|
485
|
+
* grace poll takes it out of the bridge buffer while the bridge's sink has
|
|
486
|
+
* already forwarded it), and both observations are the SAME answer to the SAME
|
|
487
|
+
* question. Collapsing them is identity, not suppression — nothing is ever
|
|
488
|
+
* dropped because it "looks like" something already seen.
|
|
489
|
+
*/
|
|
490
|
+
record(askId, reply, meta) {
|
|
491
|
+
const text = answerText(reply);
|
|
492
|
+
const existing = [...this.entries.values()].find((e) => e.askId === askId);
|
|
493
|
+
const ticket = this.tickets.get(askId);
|
|
494
|
+
// Collapse ONLY an identical observation. The same card's reply can be seen
|
|
495
|
+
// twice (the handler's grace poll takes it out of the bridge buffer while the
|
|
496
|
+
// sink has already forwarded it) and that is one answer, not two — identity,
|
|
497
|
+
// not suppression.
|
|
498
|
+
//
|
|
499
|
+
// A DIFFERENT answer under the same id is a different validated answer (two
|
|
500
|
+
// cards, one reused id). Returning the old entry there would silently
|
|
501
|
+
// discard what the user just chose, so it gets its own entry, and BOTH are
|
|
502
|
+
// demoted to unattributable: nothing on the wire says which card either came
|
|
503
|
+
// from, so neither may satisfy a question.
|
|
504
|
+
//
|
|
505
|
+
// …and the collapse is OFF ENTIRELY for a REUSED id. "Same id, same text" is
|
|
506
|
+
// only evidence of one observation while the id means one card; once two
|
|
507
|
+
// cards share it, two users' picks that happen to read the same ("euler" on
|
|
508
|
+
// both) are two validated answers to two questions, and merging them leaves
|
|
509
|
+
// the second question unanswered with no record at all.
|
|
510
|
+
if (existing) {
|
|
511
|
+
// The ENTRY's own ambiguity flag is the load-bearing half — see
|
|
512
|
+
// AskEntry.ambiguousId. `ticket?.reused` is kept alongside it as the
|
|
513
|
+
// earliest signal, but it may have been evicted, and a bound must never be
|
|
514
|
+
// what decides whether an answer is allowed to disappear.
|
|
515
|
+
if (existing.answer === text && existing.ambiguousId !== true && ticket?.reused !== true) {
|
|
516
|
+
return existing;
|
|
517
|
+
}
|
|
518
|
+
logger.warn(`[ask-answers] a SECOND answer arrived under ask id ${askId.slice(0, 12)} ("${text}"${existing.answer === text ? " — same text, but the id is REUSED so this is a different card" : ` vs "${existing.answer}"`}) — both are kept and both are reported as UNDETERMINED; neither can satisfy a question`);
|
|
519
|
+
existing.correlation = { status: "foreign", askId };
|
|
520
|
+
existing.recoverable = false;
|
|
521
|
+
// …and the ambiguity is frozen here too, so it survives the ticket.
|
|
522
|
+
existing.ambiguousId = true;
|
|
523
|
+
}
|
|
524
|
+
// A REUSED id proves nothing: the panel sends only the id, so an answer for
|
|
525
|
+
// it could belong to either card. Report it as foreign — real, but
|
|
526
|
+
// UNDETERMINED — rather than claiming it answers the question now open.
|
|
527
|
+
// The conversation that opened this ticket has been replaced (New chat, or a
|
|
528
|
+
// resume of a historical session) while its card stayed on screen.
|
|
529
|
+
const conversationGone = ticket !== undefined && ticket.epoch !== (this.tabEpoch.get(ticket.tabId) ?? 0);
|
|
530
|
+
const attributable = ticket !== undefined && ticket.reused !== true && existing === undefined;
|
|
531
|
+
const correlation = attributable
|
|
532
|
+
? { status: "matched", askId }
|
|
533
|
+
: { status: "foreign", askId };
|
|
534
|
+
const entry = {
|
|
535
|
+
token: `aa${++this.seq}`,
|
|
536
|
+
askId,
|
|
537
|
+
// The TICKET's tab is authoritative only while the ticket is ATTRIBUTABLE.
|
|
538
|
+
// A REUSED id can name a ticket opened by a DIFFERENT tab, and taking its
|
|
539
|
+
// tab would re-address the answer to a conversation that never rendered the
|
|
540
|
+
// card — labelled `foreign`, so it could not satisfy anything, but still
|
|
541
|
+
// delivered to the wrong place. The bridge-supplied tab is the PROVEN
|
|
542
|
+
// source of this reply (it comes from the routed send, not from the id), so
|
|
543
|
+
// it wins whenever the ticket cannot be trusted.
|
|
544
|
+
key: attributable ? ticket.tabId : meta.tabId,
|
|
545
|
+
// The fingerprint is the LICENCE to satisfy a re-ask, so a reused id gets
|
|
546
|
+
// none — its answer may only ever be reported, never returned as an answer.
|
|
547
|
+
fingerprint: attributable ? ticket.fingerprint : null,
|
|
548
|
+
// …and NOR is the question text, for a REUSED id. The ticket then holds the
|
|
549
|
+
// LATER card's question, and printing that beside the EARLIER card's answer
|
|
550
|
+
// states a false association — the very mistake this file exists to
|
|
551
|
+
// prevent, committed in the disclosure meant to be the honest fallback. A
|
|
552
|
+
// ticket that is merely RETIRED (its conversation replaced) is different:
|
|
553
|
+
// its question is still that card's own, and keeping it is what lets the
|
|
554
|
+
// report name what was actually asked.
|
|
555
|
+
question: ticket === undefined || ticket.reused === true ? null : ticket.question,
|
|
556
|
+
answer: text,
|
|
557
|
+
answeredAt: Date.now(),
|
|
558
|
+
correlation,
|
|
559
|
+
ticketSeq: ticket?.seq ?? 0,
|
|
560
|
+
// WHO was holding the tab when this answer arrived — see AskEntry.incarnation.
|
|
561
|
+
...(this.incarnationOf?.(ticket?.tabId ?? meta.tabId) !== undefined
|
|
562
|
+
? { incarnation: this.incarnationOf(ticket?.tabId ?? meta.tabId) }
|
|
563
|
+
: {}),
|
|
564
|
+
returned: false,
|
|
565
|
+
// An answer that lands while its own handler is still running is that
|
|
566
|
+
// handler's to return; one that lands afterwards has nobody left and is
|
|
567
|
+
// armed for the push immediately.
|
|
568
|
+
//
|
|
569
|
+
// TWO cases are never pushed, and are journaled for disclosure only:
|
|
570
|
+
// • its CONVERSATION was replaced — see closeAsks();
|
|
571
|
+
// • THERE IS NO TICKET AT ALL. Without one we cannot tell whether the card
|
|
572
|
+
// belongs to the conversation now on this tab or to a retired one (the
|
|
573
|
+
// ticket carried that generation), and announcing it anyway would fold
|
|
574
|
+
// "could not determine" into "determined not retired". It is also the
|
|
575
|
+
// one delivery with nothing to offer: the wording for an unattributable
|
|
576
|
+
// answer already says its meaning is UNDETERMINED and that nothing may
|
|
577
|
+
// be inferred from it, so pushing it into a conversation that may not be
|
|
578
|
+
// the one that asked buys nothing and risks exactly the
|
|
579
|
+
// wrong-conversation delivery the epoch guard exists to prevent. The
|
|
580
|
+
// answer is still logged with its text and still reported by a later ask
|
|
581
|
+
// on this tab — it is not swallowed, it is just not announced.
|
|
582
|
+
delivery: ticket === undefined || ticket.awaiting === true || conversationGone
|
|
583
|
+
? "none"
|
|
584
|
+
: "pending",
|
|
585
|
+
// A question the CURRENT conversation never asked may be reported, never
|
|
586
|
+
// returned as its answer.
|
|
587
|
+
...(conversationGone || existing !== undefined ? { recoverable: false } : {}),
|
|
588
|
+
// The BOUNDARY axis only — `existing !== undefined` is ambiguity, which is
|
|
589
|
+
// still deliverable (labelled), so it must not set this.
|
|
590
|
+
...(conversationGone ? { retired: true } : {}),
|
|
591
|
+
// A SECOND answer under one ask id: the id is ambiguous for this entry too,
|
|
592
|
+
// and that must outlive the ticket that proved it.
|
|
593
|
+
...(existing !== undefined || ticket?.reused === true ? { ambiguousId: true } : {}),
|
|
594
|
+
attempts: 0,
|
|
595
|
+
};
|
|
596
|
+
this.entries.set(entry.token, entry);
|
|
597
|
+
if (conversationGone) {
|
|
598
|
+
logger.warn(`[ask-answers] an answer arrived for a card whose conversation was replaced (ask ${askId.slice(0, 8)}, tab ${entry.key.slice(0, 8)}): "${entry.answer}" — journaled for disclosure, NOT announced to the replacement conversation`);
|
|
599
|
+
}
|
|
600
|
+
else if (!attributable) {
|
|
601
|
+
logger.warn(`[ask-answers] a validated answer arrived for ask ${askId.slice(0, 8)} with ${ticket ? "a REUSED (ambiguous) ticket" : "no open ticket"} — journaled as UNATTRIBUTED; it can never satisfy a question, only be reported`);
|
|
602
|
+
}
|
|
603
|
+
// Same release as closeAsk (and the same reused-id exception).
|
|
604
|
+
if (ticket && !ticket.awaiting && ticket.reused !== true)
|
|
605
|
+
this.tickets.delete(askId);
|
|
606
|
+
this.trimEntries(entry.key);
|
|
607
|
+
// Born orphaned (no handler is waiting on this ask) — push it straight away.
|
|
608
|
+
if (entry.delivery === "pending")
|
|
609
|
+
this.flush?.(entry.key);
|
|
610
|
+
return entry;
|
|
611
|
+
}
|
|
612
|
+
/** The ask handler put this answer into its ToolResult. A hand-off, not proof
|
|
613
|
+
* of consumption — see AskEntry.returned. */
|
|
614
|
+
markReturned(token, opts = {}) {
|
|
615
|
+
// BY TOKEN, not by ask id: under a reused id one ask id can own two entries,
|
|
616
|
+
// and marking the sibling returned would quietly retire an answer nobody has
|
|
617
|
+
// been given.
|
|
618
|
+
const entry = this.entries.get(token);
|
|
619
|
+
if (entry) {
|
|
620
|
+
entry.returned = true;
|
|
621
|
+
if (opts.replay)
|
|
622
|
+
entry.replayHint = true;
|
|
623
|
+
// Ride the turn this tool call is running inside, so that turn's result —
|
|
624
|
+
// and nothing less — acks the answer. No live turn to ride means no proof,
|
|
625
|
+
// so the answer simply stays unacked, which is the conservative reading.
|
|
626
|
+
//
|
|
627
|
+
// THE CARRIER IS CAPTURED HERE, at the instant of return, and frozen onto
|
|
628
|
+
// the entry. Resolving it later from "whoever owns this tab now" is how a
|
|
629
|
+
// switched provider's turn could ack an answer it never saw: a stale
|
|
630
|
+
// pre-switch ask handler resumes, attaches to the NEW provider's turn, and
|
|
631
|
+
// that turn's result settles an answer the new conversation was never
|
|
632
|
+
// shown. `ack` compares against this, so only the turn that actually
|
|
633
|
+
// carried the answer can settle it.
|
|
634
|
+
//
|
|
635
|
+
// …and an answer whose CONVERSATION is gone never attaches at all. Capturing
|
|
636
|
+
// the carrier is not enough on its own here: a stale pre-switch ask handler
|
|
637
|
+
// that resumes when its card is clicked would attach to whatever turn is
|
|
638
|
+
// running NOW, and that turn's result would then certify — accurately, and
|
|
639
|
+
// uselessly — an answer the current conversation was never shown. The
|
|
640
|
+
// conversation boundary already marked this entry unrecoverable; it makes it
|
|
641
|
+
// unackable too, so the answer stays honestly unproven.
|
|
642
|
+
// Gated on the BOUNDARY axis only. An AMBIGUOUS answer (an ask id that
|
|
643
|
+
// stopped identifying one card) is still handed to a caller, so it must
|
|
644
|
+
// still be ackable by the turn that carried it — refusing the carrier there
|
|
645
|
+
// left a delivered answer permanently unconfirmed, i.e. surfacing later as
|
|
646
|
+
// a loss that did not happen. Only a RETIRED answer, which no live turn may
|
|
647
|
+
// receive at all, gets no carrier.
|
|
648
|
+
entry.carrier =
|
|
649
|
+
entry.retired === true ? null : (this.attachTurn?.(entry.key, token) ?? null);
|
|
650
|
+
// It reached A caller. Do not ALSO push it as an orphan; a re-ask can
|
|
651
|
+
// still recover it if that caller was already dead.
|
|
652
|
+
//
|
|
653
|
+
// DEFENCE IN DEPTH, not a live path: an ask handler only marks an answer
|
|
654
|
+
// returned while its own ticket is still `awaiting`, and `record` never
|
|
655
|
+
// arms one in that state — so nothing reachable enters this branch and no
|
|
656
|
+
// test can fail on it. It is here because "a caller took this" and
|
|
657
|
+
// "announce it to the agent as unclaimed" are contradictory states, and a
|
|
658
|
+
// future caller marking an answer returned from outside the ask handler
|
|
659
|
+
// would otherwise double-report it.
|
|
660
|
+
if (entry.delivery === "pending")
|
|
661
|
+
entry.delivery = "none";
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Find the answer the user already gave to THIS EXACT question on THIS tab.
|
|
666
|
+
*
|
|
667
|
+
* The match is exact fingerprint equality and nothing else — see the
|
|
668
|
+
* cross-question guard at the top of this file. When several qualify the
|
|
669
|
+
* NEWEST wins: it is the user's most recent statement of intent about that
|
|
670
|
+
* question.
|
|
671
|
+
*
|
|
672
|
+
* Returns a discriminated result: "no answer at all" and "answers exist but
|
|
673
|
+
* none is provably for this question" are DIFFERENT facts and must not be
|
|
674
|
+
* folded into one falsy value (#796).
|
|
675
|
+
*
|
|
676
|
+
* The CURRENT ask's own id is deliberately NOT excluded: an answer that landed
|
|
677
|
+
* for the very card we just gave up on is the most direct answer there is, and
|
|
678
|
+
* excluding it would re-open the race the grace poll cannot close (the sink
|
|
679
|
+
* journals an answer microseconds after the last poll).
|
|
680
|
+
*/
|
|
681
|
+
recover(key, fingerprint) {
|
|
682
|
+
const now = Date.now();
|
|
683
|
+
const mine = [...this.entries.values()].filter((e) => e.key === key);
|
|
684
|
+
if (mine.length === 0)
|
|
685
|
+
return { status: "none" };
|
|
686
|
+
const eligible = mine
|
|
687
|
+
.filter((e) => e.correlation.status === "matched" &&
|
|
688
|
+
e.recoverable !== false &&
|
|
689
|
+
this.mayReachLiveTurn(e) &&
|
|
690
|
+
e.fingerprint !== null &&
|
|
691
|
+
e.fingerprint === fingerprint &&
|
|
692
|
+
now - e.answeredAt <= RECOVER_MAX_AGE_MS)
|
|
693
|
+
.sort((a, b) => b.answeredAt - a.answeredAt);
|
|
694
|
+
if (eligible.length > 0)
|
|
695
|
+
return { status: "recovered", entry: eligible[0] };
|
|
696
|
+
// SPLIT by what this conversation is allowed to see. `others` goes through
|
|
697
|
+
// exactly the same gate as every other outlet — nothing about a disclosure
|
|
698
|
+
// makes it exempt just because it is not a hand-off.
|
|
699
|
+
const others = mine.filter((e) => this.mayReachLiveTurn(e));
|
|
700
|
+
return { status: "unattributed", others, withheld: mine.length - others.length };
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* A recovery (or a push the agent provably consumed) took this answer. Drop
|
|
704
|
+
* it so the same answer can never be handed over twice.
|
|
705
|
+
*
|
|
706
|
+
* Deleting an entry that was already pushed and is sitting unread in an
|
|
707
|
+
* agent's queue is deliberate: the tool result the caller is about to return
|
|
708
|
+
* carries the SAME answer with the SAME question attached, so the worst case
|
|
709
|
+
* is the agent reading it twice — never acting on an answer it wasn't given.
|
|
710
|
+
*
|
|
711
|
+
* Any eviction disclosure the entry was CARRYING is handed back to the tab
|
|
712
|
+
* first. The disclosure is a debt owed to the tab, not a property of whichever
|
|
713
|
+
* entry happens to be carrying it: consuming the carrier without re-homing it
|
|
714
|
+
* would let an eviction that the journal promised to report disappear because
|
|
715
|
+
* an unrelated answer was recovered.
|
|
716
|
+
*/
|
|
717
|
+
markSurfaced(token) {
|
|
718
|
+
const entry = this.entries.get(token);
|
|
719
|
+
if (!entry)
|
|
720
|
+
return;
|
|
721
|
+
// NOT DELETED. An earlier draft consumed a recovered answer outright, on the
|
|
722
|
+
// reasoning that it had now been handed over — which is the same mistake as
|
|
723
|
+
// every suppression #468 removed, one level up: the ToolResult carrying it
|
|
724
|
+
// can itself be abandoned (that IS #486), so deleting the entry destroys the
|
|
725
|
+
// only durable copy at exactly the moment it is most likely to be needed
|
|
726
|
+
// again. It is marked handed-over and flagged, so a further re-ask of the
|
|
727
|
+
// same question gets it again, labelled "you have seen this".
|
|
728
|
+
//
|
|
729
|
+
// IT GOES THROUGH markReturned, and must: a recovery hands the answer to a
|
|
730
|
+
// live turn exactly as a fresh answer does, so it has to attach a token and
|
|
731
|
+
// capture its carrier BY CONSTRUCTION. An earlier draft set the flags here
|
|
732
|
+
// by hand and forgot to attach — so a recovery that completed perfectly
|
|
733
|
+
// stayed unacked forever, aged into apparent loss, and eventually raised
|
|
734
|
+
// eviction debt and warnings despite the agent having read it. That is the
|
|
735
|
+
// routine-false-warning failure the ack split exists to prevent, arriving
|
|
736
|
+
// through a different door.
|
|
737
|
+
this.markReturned(token, { replay: true });
|
|
738
|
+
}
|
|
739
|
+
/** Every unconsumed ORPHAN answer for a tab — those that were never handed
|
|
740
|
+
* back to any tool result. These are what a failing ask discloses, so a
|
|
741
|
+
* validated answer is never silently dropped. */
|
|
742
|
+
orphansFor(key) {
|
|
743
|
+
return [...this.entries.values()].filter((e) => e.key === key && !e.returned);
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Take the eviction debt that has NO other carrier — the count parked in the
|
|
747
|
+
* side map because this tab had no pending entry to stamp it onto, i.e. no
|
|
748
|
+
* push is coming to disclose it. The ask path reports it instead.
|
|
749
|
+
*
|
|
750
|
+
* Debt riding on an ENTRY is deliberately left alone: that copy goes out with
|
|
751
|
+
* a real delivery and is cleared only when the turn carrying it ends, so
|
|
752
|
+
* taking it here could drop a disclosure that push still owes.
|
|
753
|
+
*
|
|
754
|
+
* Reporting into a ToolResult is a hand-off, not a proof, so this can still be
|
|
755
|
+
* lost with an abandoned call — but the eviction itself was already logged at
|
|
756
|
+
* ERROR, and repeating the warning on every subsequent ask forever is worse
|
|
757
|
+
* than reporting it once at the first opportunity.
|
|
758
|
+
*/
|
|
759
|
+
reportDropped(key) {
|
|
760
|
+
// THIS occupant's bucket only. A different browser tab that inherited the
|
|
761
|
+
// key has its own, so it can neither read nor settle the departed tab's.
|
|
762
|
+
const slot = this.debtSlot(key);
|
|
763
|
+
const total = this.dropped.get(slot) ?? 0;
|
|
764
|
+
const owed = total - (this.disclosedUpTo.get(slot) ?? 0);
|
|
765
|
+
if (owed <= 0)
|
|
766
|
+
return 0;
|
|
767
|
+
// The debt is NOT spent by being written into a tool result. A ToolResult is
|
|
768
|
+
// a hand-off, not a receipt — that IS #486 — so counting reports (an earlier
|
|
769
|
+
// draft retired the debt after three) lets three abandoned calls carry the
|
|
770
|
+
// only disclosure away with them, and the answer's payload is already gone.
|
|
771
|
+
//
|
|
772
|
+
// It rides the SAME ack as everything else instead: a token attached to the
|
|
773
|
+
// turn this tool call is running inside, retired only when that turn produces
|
|
774
|
+
// its own result. No live turn to ride means no proof and no retirement — the
|
|
775
|
+
// debt simply stands and the next result reports it again.
|
|
776
|
+
const token = `aad${++this.seq}`;
|
|
777
|
+
const carrier = this.attachTurn?.(key, token) ?? null;
|
|
778
|
+
// Bound to the turn that carried it, for the same reason an answer is: a
|
|
779
|
+
// switched provider must not be able to certify a warning it never showed.
|
|
780
|
+
// The token remembers the TOTAL it is showing, so its ack settles exactly
|
|
781
|
+
// that and nothing that arrives afterwards.
|
|
782
|
+
if (carrier !== null)
|
|
783
|
+
this.debtTokens.set(token, { key: slot, carrier, upTo: total });
|
|
784
|
+
return owed;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* The tab's connection is gone. SURFACE any disclosure it is still owed, then
|
|
788
|
+
* retire it.
|
|
789
|
+
*
|
|
790
|
+
* This is the lifecycle wire that keeps the debt map from growing forever now
|
|
791
|
+
* that it has no ceiling — and the distinction matters: a debt must end because
|
|
792
|
+
* it was SAID or because its tab genuinely went away, never because a counter
|
|
793
|
+
* filled up and picked a victim. The durable ERROR log is the disclosure here;
|
|
794
|
+
* there is no live tab left to tell in-band, which is precisely why keeping the
|
|
795
|
+
* counter any longer would be bookkeeping for nobody.
|
|
796
|
+
*
|
|
797
|
+
* JOURNAL ENTRIES ARE NOT TOUCHED. A disconnect is often a reload, and the
|
|
798
|
+
* answers themselves are still deliverable to the tab when it comes back; only
|
|
799
|
+
* the count of answers whose TEXT is already gone is retired.
|
|
800
|
+
*/
|
|
801
|
+
retireDebt(key, incarnation) {
|
|
802
|
+
this.surfaceAndClearDebt(key, incarnation, "disconnected");
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* SURFACE a debt that can no longer be delivered, then clear it.
|
|
806
|
+
*
|
|
807
|
+
* Used for both endings a debt can have: the tab went away, or the
|
|
808
|
+
* conversation it was owed to was replaced. Both are "there is no longer
|
|
809
|
+
* anywhere to deliver this", and both must REPORT before clearing — retiring a
|
|
810
|
+
* warning to stop it being misattributed, without saying it anywhere, would
|
|
811
|
+
* trade a misattribution for a silent loss, which is the one trade this file
|
|
812
|
+
* refuses everywhere else.
|
|
813
|
+
*
|
|
814
|
+
* Clears the entry-carried disclosures too: those ride out on a push, and a
|
|
815
|
+
* retired conversation's entries are no longer pushed, so leaving them there
|
|
816
|
+
* would strand the warning on text nobody will read.
|
|
817
|
+
*/
|
|
818
|
+
surfaceAndClearDebt(key, incarnation, why) {
|
|
819
|
+
const slot = tabIncarnationSlot(key, incarnation);
|
|
820
|
+
// COUNT FIRST, WITHOUT MUTATING. An earlier draft cleared each carrier as it
|
|
821
|
+
// totalled them, so the only consolidated copy of the debt was gone BEFORE
|
|
822
|
+
// the report — and a logger that throws would take the whole disclosure with
|
|
823
|
+
// it, leaving nothing for a later ack to re-home. Clear-then-report is not
|
|
824
|
+
// report-then-clear, and this helper exists precisely so that fixing a
|
|
825
|
+
// misattribution cannot create a silent loss.
|
|
826
|
+
const carriers = [...this.entries.values()].filter((e) => e.key === key && e.incarnation === incarnation && (e.disclose ?? 0) > 0);
|
|
827
|
+
const owed = this.undisclosedDrop(slot) + carriers.reduce((n, e) => n + e.disclose, 0);
|
|
828
|
+
if (owed > 0) {
|
|
829
|
+
// If this throws, NOTHING below has run: the debt is still whole and still
|
|
830
|
+
// owed, and the next retirement (or the next report) will surface it.
|
|
831
|
+
logger.error(`[ask-answers] tab ${key.slice(0, 8)} ${why} still owed a disclosure for ${owed} validated answer(s) whose content was already dropped — recording it here, as there is no longer anywhere to deliver it; treat those answers as UNDETERMINED`);
|
|
832
|
+
}
|
|
833
|
+
for (const entry of carriers)
|
|
834
|
+
delete entry.disclose;
|
|
835
|
+
this.dropped.delete(slot);
|
|
836
|
+
this.disclosedUpTo.delete(slot);
|
|
837
|
+
for (const [t, x] of [...this.debtTokens])
|
|
838
|
+
if (x.key === slot)
|
|
839
|
+
this.debtTokens.delete(t);
|
|
840
|
+
}
|
|
841
|
+
/** The part of a tab's eviction total that has NOT yet been surfaced and
|
|
842
|
+
* confirmed. See disclosedUpTo. */
|
|
843
|
+
undisclosedDrop(slot) {
|
|
844
|
+
return Math.max(0, (this.dropped.get(slot) ?? 0) - (this.disclosedUpTo.get(slot) ?? 0));
|
|
845
|
+
}
|
|
846
|
+
/** Every debt bucket belonging to a panel tab, across all its incarnations. */
|
|
847
|
+
debtSlotsFor(key) {
|
|
848
|
+
// A slot is `["<tabId>",<incarnation>]`, so the tab's slots are exactly those
|
|
849
|
+
// starting `["<tabId>",` — JSON-escaped, so no tab id can spoof another's.
|
|
850
|
+
const prefix = `[${JSON.stringify(key)},`;
|
|
851
|
+
return [...new Set([...this.dropped.keys(), ...this.disclosedUpTo.keys()])].filter((s) => s.startsWith(prefix));
|
|
852
|
+
}
|
|
853
|
+
/** Answers this tab lost to an eviction and has not yet been told about. */
|
|
854
|
+
droppedFor(key) {
|
|
855
|
+
const carried = [...this.entries.values()]
|
|
856
|
+
.filter((e) => e.key === key)
|
|
857
|
+
.reduce((n, e) => n + (e.disclose ?? 0), 0);
|
|
858
|
+
// Every incarnation's bucket: a diagnostic view of what this TAB is owed,
|
|
859
|
+
// whoever is holding it now.
|
|
860
|
+
return (carried + this.debtSlotsFor(key).reduce((n, slot) => n + this.undisclosedDrop(slot), 0));
|
|
861
|
+
}
|
|
862
|
+
/** Entries awaiting a push attempt for this key, in arrival order. */
|
|
863
|
+
pending(key) {
|
|
864
|
+
const out = [];
|
|
865
|
+
for (const entry of this.entries.values()) {
|
|
866
|
+
// A different browser tab now holds this key: its conversation never put
|
|
867
|
+
// the card up, so the answer waits (for its own tab to come back) rather
|
|
868
|
+
// than being announced to a stranger. It is still disclosed by a failing
|
|
869
|
+
// ask, and still named on the way out — held, never swallowed.
|
|
870
|
+
if (entry.key === key && entry.delivery === "pending" && this.mayReachLiveTurn(entry)) {
|
|
871
|
+
out.push(entry);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return out;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Push every orphaned answer for `key` to the tab's agent, in ARRIVAL order,
|
|
878
|
+
* stopping at the first refusal so a newer answer never overtakes an older one
|
|
879
|
+
* that is still stuck.
|
|
880
|
+
*
|
|
881
|
+
* `inject` returns whether the agent TOOK the payload onto its queue — not
|
|
882
|
+
* that it was read. The entry stays journaled either way; only `ack` (the turn
|
|
883
|
+
* that carried it ended) removes it. That is the durability property: hand it
|
|
884
|
+
* to an agent that then dies and it comes back here.
|
|
885
|
+
*
|
|
886
|
+
* NOTHING is re-correlated. The verdict and the question were frozen at
|
|
887
|
+
* arrival, so a replay can never be re-attributed to a question asked later.
|
|
888
|
+
*/
|
|
889
|
+
deliverPending(key, inject) {
|
|
890
|
+
let delivered = 0;
|
|
891
|
+
for (const entry of this.pending(key)) {
|
|
892
|
+
const lost = (entry.disclose ?? 0) + this.undisclosedDrop(this.debtSlot(key));
|
|
893
|
+
const payload = {
|
|
894
|
+
kind: "ask_answer",
|
|
895
|
+
ask_question: entry.question,
|
|
896
|
+
ask_answer: entry.answer,
|
|
897
|
+
ask_correlation: entry.correlation.status,
|
|
898
|
+
ask_answered_at: entry.answeredAt,
|
|
899
|
+
...(entry.attempts > 0 ? { replayed: true } : {}),
|
|
900
|
+
...(entry.replayHint ? { possible_repeat: true } : {}),
|
|
901
|
+
...(lost > 0 ? { dropped_answers: lost } : {}),
|
|
902
|
+
};
|
|
903
|
+
const handedOff = inject(payload, entry.token);
|
|
904
|
+
entry.attempts += 1;
|
|
905
|
+
entry.delivery = handedOff ? "handed_off" : "pending";
|
|
906
|
+
if (!handedOff)
|
|
907
|
+
return { delivered, blockedOn: entry };
|
|
908
|
+
if (lost > 0) {
|
|
909
|
+
// CONSOLIDATE onto the entry; the disclosure is NOT spent by a hand-off.
|
|
910
|
+
// If this agent dies before its turn runs, the entry is released and
|
|
911
|
+
// replayed, and the warning must go with it. Only ack() clears it.
|
|
912
|
+
entry.disclose = lost;
|
|
913
|
+
const slot = this.debtSlot(key);
|
|
914
|
+
this.dropped.delete(slot);
|
|
915
|
+
this.disclosedUpTo.delete(slot);
|
|
916
|
+
}
|
|
917
|
+
delivered += 1;
|
|
918
|
+
}
|
|
919
|
+
return { delivered, blockedOn: null };
|
|
920
|
+
}
|
|
921
|
+
/** The turn that CARRIED this answer ended — it genuinely reached the agent. */
|
|
922
|
+
ack(token, from) {
|
|
923
|
+
// An eviction-DISCLOSURE token (see reportDropped): the turn that carried the
|
|
924
|
+
// warning produced its result, so the tab has genuinely been told.
|
|
925
|
+
const debt = this.debtTokens.get(token);
|
|
926
|
+
if (debt !== undefined) {
|
|
927
|
+
if (!this.carriedBy(debt.carrier, from))
|
|
928
|
+
return;
|
|
929
|
+
this.debtTokens.delete(token);
|
|
930
|
+
// Settle ONLY what this warning actually showed. An eviction that landed
|
|
931
|
+
// after it was rendered is not covered by it, and must still be reported.
|
|
932
|
+
const seen = Math.max(this.disclosedUpTo.get(debt.key) ?? 0, debt.upTo);
|
|
933
|
+
const total = this.dropped.get(debt.key) ?? 0;
|
|
934
|
+
if (seen >= total) {
|
|
935
|
+
// Fully told — nothing outstanding, so drop both halves.
|
|
936
|
+
this.dropped.delete(debt.key);
|
|
937
|
+
this.disclosedUpTo.delete(debt.key);
|
|
938
|
+
for (const [t, x] of [...this.debtTokens])
|
|
939
|
+
if (x.key === debt.key)
|
|
940
|
+
this.debtTokens.delete(t);
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
this.disclosedUpTo.set(debt.key, seen);
|
|
944
|
+
}
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const entry = this.entries.get(token);
|
|
948
|
+
if (!entry)
|
|
949
|
+
return;
|
|
950
|
+
// ONLY THE TURN THAT ACTUALLY CARRIED IT may settle it. The carrier was
|
|
951
|
+
// frozen at hand-off (see AskEntry.carrier); a result arriving from anything
|
|
952
|
+
// else — most concretely, the NEW provider's turn after a switch moved the
|
|
953
|
+
// tab's agent mapping — proves nothing about this answer, so it is refused
|
|
954
|
+
// and the answer stays honestly unacked.
|
|
955
|
+
if (!this.carriedBy(entry.carrier, from)) {
|
|
956
|
+
logger.warn(`[ask-answers] refusing an ack for the user's answer to "${preview(entry.question)}" from ${from?.carrier ?? "an unidentified turn"} — it was handed to ${entry.carrier ?? "no turn at all"}; the answer stays unconfirmed`);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
// The push is done, but the ANSWER stays journaled while it is still within
|
|
960
|
+
// the recovery window: a re-ask of the identical question must still be able
|
|
961
|
+
// to return it as its result rather than making the user answer twice. It is
|
|
962
|
+
// flagged so any later surfacing reads as "you have seen this".
|
|
963
|
+
//
|
|
964
|
+
// The eviction DISCLOSURE it carries is only SPENT when a real delivery
|
|
965
|
+
// actually rendered it — `deliverPending` stamps the count onto the entry and
|
|
966
|
+
// puts `dropped_answers` in that payload, and `attempts > 0` is the record of
|
|
967
|
+
// it. An entry acked without ever having been pushed (a TOOL-RESULT answer
|
|
968
|
+
// riding its turn) never showed anyone that count, so deleting it here would
|
|
969
|
+
// spend a warning nobody was given: re-home it instead.
|
|
970
|
+
if (entry.disclose) {
|
|
971
|
+
const owed = entry.disclose;
|
|
972
|
+
delete entry.disclose;
|
|
973
|
+
if (entry.attempts === 0)
|
|
974
|
+
this.noteDropped(entry.key, owed, entry.incarnation);
|
|
975
|
+
}
|
|
976
|
+
entry.delivery = "none";
|
|
977
|
+
entry.returned = true;
|
|
978
|
+
// THE proof. Everything that may forget an answer QUIETLY keys on this and
|
|
979
|
+
// never on `returned`.
|
|
980
|
+
entry.acked = true;
|
|
981
|
+
entry.replayHint = true;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* An agent gave a push back undelivered. Re-arm it for replay.
|
|
985
|
+
*
|
|
986
|
+
* `carried` distinguishes the two causes, and ONLY the first is bounded:
|
|
987
|
+
* • carried: true — a turn actually DISPATCHED with this answer in it and
|
|
988
|
+
* then ended, but its result could not be proven to be that turn's own. The
|
|
989
|
+
* agent read the text; after MAX_CARRIED_RELEASES we settle rather than
|
|
990
|
+
* loop (a duplicate at worst).
|
|
991
|
+
* • carried: false — a teardown handed it back (agent stopped, session died).
|
|
992
|
+
* NOBODY read it. These must never count toward the bound.
|
|
993
|
+
*/
|
|
994
|
+
release(token, opts = {}) {
|
|
995
|
+
// A disclosure token handed back: that turn never proved it was read, so the
|
|
996
|
+
// debt STANDS and the next result reports it again. Only the token mapping
|
|
997
|
+
// goes; there is deliberately no bound on how often a warning may be repeated
|
|
998
|
+
// when nothing has confirmed it.
|
|
999
|
+
if (this.debtTokens.delete(token))
|
|
1000
|
+
return;
|
|
1001
|
+
const entry = this.entries.get(token);
|
|
1002
|
+
if (!entry)
|
|
1003
|
+
return;
|
|
1004
|
+
// A TOOL-RESULT answer riding a turn (see setTurnAttacher) is not in a
|
|
1005
|
+
// delivery loop: nothing re-sends it, so there is no cycle to bound and
|
|
1006
|
+
// nothing to re-arm. Its turn ended without proving it was read, so the only
|
|
1007
|
+
// correct outcome is that it stays UNACKED — which is what keeps it
|
|
1008
|
+
// recoverable and makes its eviction a disclosed loss rather than a silent
|
|
1009
|
+
// one. Settling it here on a bound would forge the very proof this split
|
|
1010
|
+
// exists to withhold.
|
|
1011
|
+
if (entry.returned && entry.delivery === "none")
|
|
1012
|
+
return;
|
|
1013
|
+
// A boundary happened while a turn was carrying this. Re-arming it would
|
|
1014
|
+
// hand the OLD conversation's answer to the replacement one — the same bypass
|
|
1015
|
+
// as the rid path, through the release/push door. It stays journaled and
|
|
1016
|
+
// disclosable; it just stops being deliverable.
|
|
1017
|
+
if (entry.retired === true) {
|
|
1018
|
+
entry.delivery = "none";
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (opts.carried) {
|
|
1022
|
+
entry.carriedReleases = (entry.carriedReleases ?? 0) + 1;
|
|
1023
|
+
if (entry.carriedReleases >= MAX_CARRIED_RELEASES) {
|
|
1024
|
+
logger.warn(`[ask-answers] an answer was carried by ${entry.carriedReleases} turns that ended without a provable ack — settling it instead of replaying again`);
|
|
1025
|
+
this.ack(token);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
entry.delivery = "pending";
|
|
1030
|
+
}
|
|
1031
|
+
/** Is ANY orphaned answer still awaiting a push? The orchestrator's
|
|
1032
|
+
* self-restart gate reads this: the journal is in-memory, so restarting while
|
|
1033
|
+
* one is outstanding silently drops an answer the user actually gave. */
|
|
1034
|
+
hasOutstanding() {
|
|
1035
|
+
// The eviction DEBT counts too. It is a promise to tell the agent that an
|
|
1036
|
+
// answer was lost, and it is destroyed by a teardown exactly as an entry is —
|
|
1037
|
+
// tearing down while one is owed turns a disclosed loss back into a silent
|
|
1038
|
+
// one, which is the whole thing this journal exists to prevent.
|
|
1039
|
+
for (const slot of this.dropped.keys())
|
|
1040
|
+
if (this.undisclosedDrop(slot) > 0)
|
|
1041
|
+
return true;
|
|
1042
|
+
// …but a RETIRED answer is not owed to anyone. It can never be delivered —
|
|
1043
|
+
// the conversation it belongs to is gone — so it is finished, whatever its
|
|
1044
|
+
// `delivery` flag says, and holding the restart gate open for it would wait
|
|
1045
|
+
// forever. Two predicates disagreeing about what `pending` means is what made
|
|
1046
|
+
// this a deadlock: `pending()` reads it as "deliverable", this reads it as
|
|
1047
|
+
// "owed", and only a retirement makes those differ permanently.
|
|
1048
|
+
//
|
|
1049
|
+
// NOT extended to a different-occupant mismatch, which is TEMPORARY: that
|
|
1050
|
+
// answer becomes deliverable again the moment its own browser tab returns, so
|
|
1051
|
+
// tearing down while one is waiting would be a real loss.
|
|
1052
|
+
return [...this.entries.values()].some((e) => (e.delivery !== "none" && e.retired !== true) || (e.disclose ?? 0) > 0);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Everything the last-ditch, we-are-about-to-die disclosure must name.
|
|
1056
|
+
*
|
|
1057
|
+
* Broader than `hasOutstanding` on purpose. A restart is a CHOICE we can defer,
|
|
1058
|
+
* so it waits only on what is still deliverable; a fatal exit is not, so it
|
|
1059
|
+
* reports everything a validated answer might still have been needed for:
|
|
1060
|
+
* • answers awaiting a push (nobody has them);
|
|
1061
|
+
* • answers that went into a ToolResult RECENTLY — "returned" is a hand-off,
|
|
1062
|
+
* not a receipt (that IS #486), and inside the recovery window a re-ask was
|
|
1063
|
+
* still able to produce it. Deferring restarts on these would stall the
|
|
1064
|
+
* self-restarter after every single ask; SAYING so on the way out costs
|
|
1065
|
+
* nothing and keeps the loss from being silent.
|
|
1066
|
+
*/
|
|
1067
|
+
allOutstanding() {
|
|
1068
|
+
return [...this.entries.values()].filter((e) => e.delivery !== "none" ||
|
|
1069
|
+
(e.disclose ?? 0) > 0 ||
|
|
1070
|
+
// UNACKED, with NO TIME BOUND. An answer the agent provably read is not
|
|
1071
|
+
// news on the way out; one whose receipt was never confirmed is, and it
|
|
1072
|
+
// stays news for as long as it exists.
|
|
1073
|
+
//
|
|
1074
|
+
// An earlier draft also required it to be inside the recovery window,
|
|
1075
|
+
// which quietly undid the whole `returned`/`acked` split: past ten
|
|
1076
|
+
// minutes an unacked answer was neither pending nor debt-bearing, so the
|
|
1077
|
+
// fatal-exit path was handed nothing and a validated choice vanished in
|
|
1078
|
+
// silence. UNACKED MEANS UNPROVEN, and time does not turn unproven into
|
|
1079
|
+
// delivered — the window governs whether an answer may be RECOVERED, and
|
|
1080
|
+
// has no business governing whether a loss is reported.
|
|
1081
|
+
e.acked !== true);
|
|
1082
|
+
}
|
|
1083
|
+
/** Tabs still owed an eviction disclosure, with the count — so a fatal exit can
|
|
1084
|
+
* name a loss whose carrier is only a counter. */
|
|
1085
|
+
outstandingDebt() {
|
|
1086
|
+
const byTab = new Map();
|
|
1087
|
+
for (const slot of this.dropped.keys()) {
|
|
1088
|
+
const owed = this.undisclosedDrop(slot);
|
|
1089
|
+
if (owed <= 0)
|
|
1090
|
+
continue;
|
|
1091
|
+
// The exit disclosure names TABS, not incarnations — every incarnation's
|
|
1092
|
+
// loss belongs to the tab the user is looking at.
|
|
1093
|
+
const key = JSON.parse(slot)[0];
|
|
1094
|
+
byTab.set(key, (byTab.get(key) ?? 0) + owed);
|
|
1095
|
+
}
|
|
1096
|
+
for (const e of this.entries.values()) {
|
|
1097
|
+
if ((e.disclose ?? 0) > 0)
|
|
1098
|
+
byTab.set(e.key, (byTab.get(e.key) ?? 0) + e.disclose);
|
|
1099
|
+
}
|
|
1100
|
+
return [...byTab].map(([key, count]) => ({ key, count }));
|
|
1101
|
+
}
|
|
1102
|
+
/** Move every entry AND every open ticket from `from` onto `to` — a panel
|
|
1103
|
+
* tab-id migration re-keys the agent and both must move with it, or an answer
|
|
1104
|
+
* for a card dispatched under the old id becomes unattributable. */
|
|
1105
|
+
moveKey(from, to) {
|
|
1106
|
+
if (from === to)
|
|
1107
|
+
return;
|
|
1108
|
+
for (const entry of this.entries.values()) {
|
|
1109
|
+
if (entry.key === from)
|
|
1110
|
+
entry.key = to;
|
|
1111
|
+
}
|
|
1112
|
+
// The conversation GENERATION moves with the tickets, or they would all read
|
|
1113
|
+
// as belonging to a replaced conversation at the destination (or, worse, a
|
|
1114
|
+
// retired one would read as live there). A ticket whose conversation was
|
|
1115
|
+
// still current at the source is current at the destination — the agent moved
|
|
1116
|
+
// with it; one already retired stays retired.
|
|
1117
|
+
const fromEpoch = this.tabEpoch.get(from) ?? 0;
|
|
1118
|
+
const toEpoch = this.tabEpoch.get(to) ?? 0;
|
|
1119
|
+
for (const ticket of this.tickets.values()) {
|
|
1120
|
+
if (ticket.tabId !== from)
|
|
1121
|
+
continue;
|
|
1122
|
+
ticket.tabId = to;
|
|
1123
|
+
ticket.epoch = ticket.epoch === fromEpoch ? toEpoch : toEpoch - 1;
|
|
1124
|
+
}
|
|
1125
|
+
this.tabEpoch.delete(from);
|
|
1126
|
+
// BOTH HALVES OF THE DEBT MOVE TOGETHER. The count is tab-keyed and the
|
|
1127
|
+
// outstanding disclosure TOKENS carry that same key, but the ack that
|
|
1128
|
+
// settles one is bound to the AGENT INSTANCE (see AskEntry.carrier) — which
|
|
1129
|
+
// deliberately survives a tab-id migration. So a token left pointing at the
|
|
1130
|
+
// old key is acked by a perfectly valid result and clears a debt nobody
|
|
1131
|
+
// owes, while the real one sits under the new key forever: false outstanding
|
|
1132
|
+
// debt that blocks the restart gate and later emits a spurious warning. That
|
|
1133
|
+
// is the cry-wolf failure the acked/unacked split exists to prevent,
|
|
1134
|
+
// arriving through migration.
|
|
1135
|
+
for (const debt of this.debtTokens.values()) {
|
|
1136
|
+
if (debt.key === from)
|
|
1137
|
+
debt.key = to;
|
|
1138
|
+
}
|
|
1139
|
+
// Every incarnation's bucket moves with the tab id, keeping its own
|
|
1140
|
+
// incarnation: the id changed, the browser tabs did not.
|
|
1141
|
+
for (const slot of this.debtSlotsFor(from)) {
|
|
1142
|
+
const [, incarnation] = JSON.parse(slot);
|
|
1143
|
+
// Carry the UNDISCLOSED remainder only — what was already told is told,
|
|
1144
|
+
// and re-importing it would warn the destination twice.
|
|
1145
|
+
const lost = this.undisclosedDrop(slot);
|
|
1146
|
+
this.dropped.delete(slot);
|
|
1147
|
+
this.disclosedUpTo.delete(slot);
|
|
1148
|
+
if (lost <= 0)
|
|
1149
|
+
continue;
|
|
1150
|
+
const moved = tabIncarnationSlot(to, incarnation ?? undefined);
|
|
1151
|
+
this.dropped.set(moved, (this.dropped.get(moved) ?? 0) + lost);
|
|
1152
|
+
}
|
|
1153
|
+
for (const debt of this.debtTokens.values()) {
|
|
1154
|
+
const [tab, incarnation] = JSON.parse(debt.key);
|
|
1155
|
+
if (tab === from)
|
|
1156
|
+
debt.key = tabIncarnationSlot(to, incarnation ?? undefined);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
/** Drop everything belonging to a tab that will never come back. Logs every
|
|
1160
|
+
* answer that dies unconsumed; a loss must never be silent. */
|
|
1161
|
+
forget(key) {
|
|
1162
|
+
for (const [token, entry] of [...this.entries]) {
|
|
1163
|
+
if (entry.key !== key)
|
|
1164
|
+
continue;
|
|
1165
|
+
this.entries.delete(token);
|
|
1166
|
+
// …and its queued copy goes with it: the journal's record and the agent's
|
|
1167
|
+
// queue must be dropped together, or the text outlives the entry that was
|
|
1168
|
+
// supposed to bound it.
|
|
1169
|
+
if (entry.delivery === "handed_off")
|
|
1170
|
+
this.revoke?.(entry.key, entry.token);
|
|
1171
|
+
// EVERY answer is logged, returned ones included: "it went into a
|
|
1172
|
+
// ToolResult" is not proof it was received, so a returned answer inside
|
|
1173
|
+
// the recovery window is still the only copy of a decision that may never
|
|
1174
|
+
// have reached the model.
|
|
1175
|
+
logger.warn(`[ask-answers] dropping a validated answer ("${entry.answer}") to "${preview(entry.question)}" — its tab (${key.slice(0, 8)}) is gone${entry.returned ? " (it had been handed to a tool result, which is not proof it was received)" : ""}`);
|
|
1176
|
+
}
|
|
1177
|
+
// The TAB is gone, so every conversation it hosted is gone too: bump the
|
|
1178
|
+
// epoch rather than deleting the tickets. A card left on screen can still be
|
|
1179
|
+
// clicked, and the surviving ticket is what names the question that answer
|
|
1180
|
+
// belongs to — while the newer epoch keeps it from being armed for a push at
|
|
1181
|
+
// a tab id no agent answers to, exactly as for a replaced conversation.
|
|
1182
|
+
this.tabEpoch.set(key, (this.tabEpoch.get(key) ?? 0) + 1);
|
|
1183
|
+
const slots = this.debtSlotsFor(key);
|
|
1184
|
+
const owed = slots.reduce((n, slot) => n + this.undisclosedDrop(slot), 0);
|
|
1185
|
+
if (owed > 0) {
|
|
1186
|
+
logger.error(`[ask-answers] tab ${key.slice(0, 8)} is gone still owed a disclosure for ${owed} evicted answer(s) — it will never be told`);
|
|
1187
|
+
}
|
|
1188
|
+
for (const slot of slots) {
|
|
1189
|
+
this.dropped.delete(slot);
|
|
1190
|
+
this.disclosedUpTo.delete(slot);
|
|
1191
|
+
}
|
|
1192
|
+
// Both halves together — see moveKey. A token left behind here would let a
|
|
1193
|
+
// later ack clear a debt for a tab that no longer exists.
|
|
1194
|
+
for (const [t, x] of [...this.debtTokens]) {
|
|
1195
|
+
if (JSON.parse(x.key)[0] === key)
|
|
1196
|
+
this.debtTokens.delete(t);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* The CONVERSATION that asked this tab's questions is gone — New chat, or a
|
|
1201
|
+
* switch to a historical session.
|
|
1202
|
+
*
|
|
1203
|
+
* Drop the tab's TICKETS and DOWNGRADE its journaled answers to `foreign`, so
|
|
1204
|
+
* an answer given to the old conversation's question can never be returned to
|
|
1205
|
+
* the replacement agent as "the answer to the question YOU just asked". The
|
|
1206
|
+
* answers themselves are kept and still delivered — labelled UNDETERMINED,
|
|
1207
|
+
* quoted with their own question. A correlation may only ever get WEAKER.
|
|
1208
|
+
*/
|
|
1209
|
+
closeAsks(key) {
|
|
1210
|
+
// ORDER IS THE WHOLE POINT HERE. The rule this and surfaceAndClearDebt share:
|
|
1211
|
+
//
|
|
1212
|
+
// FENCE BEFORE YOU REPORT. REPORT BEFORE YOU DESTROY.
|
|
1213
|
+
//
|
|
1214
|
+
// Nothing that can throw may stand between a decision and the state change
|
|
1215
|
+
// that makes it safe. In surfaceAndClearDebt the irreversible act is the
|
|
1216
|
+
// clear, so the report goes first. HERE the protective act is the retire, so
|
|
1217
|
+
// it goes first — an earlier draft reported the debt (through a logger, which
|
|
1218
|
+
// can fail) before marking anything retired, and a logger failure left the
|
|
1219
|
+
// fence half-built. The caller has already reset the old agent by then, so
|
|
1220
|
+
// the replacement's ready-flush would push the still-unretired answer and an
|
|
1221
|
+
// identical re-ask could recover it.
|
|
1222
|
+
//
|
|
1223
|
+
// PASS 1 is pure state: no logging, no callbacks, nothing that can throw. By
|
|
1224
|
+
// the end of it every answer on this tab is fenced.
|
|
1225
|
+
this.tabEpoch.set(key, (this.tabEpoch.get(key) ?? 0) + 1);
|
|
1226
|
+
const queued = [];
|
|
1227
|
+
for (const entry of this.entries.values()) {
|
|
1228
|
+
if (entry.key !== key)
|
|
1229
|
+
continue;
|
|
1230
|
+
if (entry.correlation.status === "matched") {
|
|
1231
|
+
entry.correlation = { status: "foreign", askId: entry.correlation.askId };
|
|
1232
|
+
}
|
|
1233
|
+
// Revoke the LICENCE to satisfy a re-ask, but keep the fingerprint: it is
|
|
1234
|
+
// also how a later ask of the SAME question notices this answer exists and
|
|
1235
|
+
// reports it. See AskEntry.recoverable.
|
|
1236
|
+
entry.recoverable = false;
|
|
1237
|
+
// …and mark it RETIRED, which is what every path that can reach a live turn
|
|
1238
|
+
// consults (see AskEntry.retired). Setting `delivery` alone is not enough:
|
|
1239
|
+
// a turn that was already carrying this answer releases it back to
|
|
1240
|
+
// `pending` afterwards, and the re-arm has no idea a boundary happened.
|
|
1241
|
+
entry.retired = true;
|
|
1242
|
+
// …and STOP PUSHING it. Its addressee is gone.
|
|
1243
|
+
//
|
|
1244
|
+
// This is where an ask answer parts company with a run completion (#468),
|
|
1245
|
+
// which is still delivered to the replacement conversation downgraded. A
|
|
1246
|
+
// completion's payload is independently useful — the images are on the
|
|
1247
|
+
// user's canvas either way. An answer to a question the replacement
|
|
1248
|
+
// conversation never asked is useful to nobody.
|
|
1249
|
+
//
|
|
1250
|
+
// NOT a silent discard: the entry stays journaled (so a later ask on this
|
|
1251
|
+
// tab that times out still reports its EXISTENCE) and the loss of the push
|
|
1252
|
+
// is logged in pass 2.
|
|
1253
|
+
if (entry.delivery === "pending")
|
|
1254
|
+
entry.delivery = "none";
|
|
1255
|
+
else if (entry.delivery === "handed_off")
|
|
1256
|
+
queued.push(entry);
|
|
1257
|
+
}
|
|
1258
|
+
// PASS 2 is fallible: pulling a queued copy back off an agent, and saying what
|
|
1259
|
+
// happened. Each is isolated, so one failure cannot skip the rest — and none
|
|
1260
|
+
// of it can undo pass 1, which has already made every answer here unusable.
|
|
1261
|
+
for (const entry of queued) {
|
|
1262
|
+
let recalled = false;
|
|
1263
|
+
try {
|
|
1264
|
+
// HANDED_OFF has already been materialised into the agent's queue with
|
|
1265
|
+
// wording that claims the reader put the card up, so it must be pulled
|
|
1266
|
+
// back; only once the carrying turn has STARTED is the text beyond
|
|
1267
|
+
// recall.
|
|
1268
|
+
recalled = this.revoke?.(entry.key, entry.token) === true;
|
|
1269
|
+
}
|
|
1270
|
+
catch (err) {
|
|
1271
|
+
// A CATCH BLOCK THAT CAN THROW IS NOT A GUARD. The try above exists so a
|
|
1272
|
+
// throwing revoker cannot abort the pass — but the report of that failure
|
|
1273
|
+
// is itself fallible, and an unguarded one hands the abort straight back:
|
|
1274
|
+
// a throwing revoker AND a throwing warn sink together would exit
|
|
1275
|
+
// closeAsks here, skipping the remaining recalls and, worse, pass 3. The
|
|
1276
|
+
// debt would then stay on the tab for a same-incarnation replacement to
|
|
1277
|
+
// read and settle as its own, and any entry-carried `disclose` would sit
|
|
1278
|
+
// on a retired, undeliverable entry holding hasOutstanding() — and so the
|
|
1279
|
+
// self-restart gate — open forever.
|
|
1280
|
+
//
|
|
1281
|
+
// Every step of this pass therefore has a TERMINAL error path: the report
|
|
1282
|
+
// of a failure must never become a new failure that skips the rest.
|
|
1283
|
+
try {
|
|
1284
|
+
logger.warn(`[ask-answers] could not recall a queued answer on tab ${key.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1285
|
+
}
|
|
1286
|
+
catch {
|
|
1287
|
+
// The fence is already up; a log sink that is down cannot undo it, and
|
|
1288
|
+
// must not stop the debt from being surfaced.
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (recalled)
|
|
1292
|
+
entry.delivery = "none";
|
|
1293
|
+
try {
|
|
1294
|
+
logger.warn(recalled
|
|
1295
|
+
? `[ask-answers] the conversation that asked "${preview(entry.question)}" on tab ${key.slice(0, 8)} was replaced — the user's answer ("${entry.answer}") will NOT be announced to the replacement conversation; it is kept only for disclosure`
|
|
1296
|
+
: `[ask-answers] tab ${key.slice(0, 8)}: the user's answer to "${preview(entry.question)}" was already being read when the conversation was replaced — it could not be recalled`);
|
|
1297
|
+
}
|
|
1298
|
+
catch {
|
|
1299
|
+
// The fence is already up; a log sink that is down cannot undo it.
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
// PASS 3, last because it is the one that DESTROYS. The eviction debt is owed
|
|
1303
|
+
// to the conversation being replaced, and scoping it by (tab, incarnation)
|
|
1304
|
+
// cannot fence this axis — New chat happens on the same tab, same
|
|
1305
|
+
// incarnation. Left alone, the replacement conversation's next ask would
|
|
1306
|
+
// report the old conversation's warning as its own and settle it with its own
|
|
1307
|
+
// ack. Surfaced and retired, exactly as a tab-gone does.
|
|
1308
|
+
this.surfaceAndClearDebt(key, this.incarnationOf?.(key), "its conversation was replaced");
|
|
1309
|
+
}
|
|
1310
|
+
/** Test/diagnostic helpers. */
|
|
1311
|
+
ticketFor(askId) {
|
|
1312
|
+
return this.tickets.get(askId);
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Is an ack from `from` allowed to settle something handed to `carrier`?
|
|
1316
|
+
*
|
|
1317
|
+
* A PUSHED entry has no carrier binding (its token was queued, not attached to
|
|
1318
|
+
* a running turn) and #468's own machinery already gates it — those pass. A
|
|
1319
|
+
* hand-off that never found a live turn (`carrier === null`) can never be
|
|
1320
|
+
* settled by anyone. Everything else must match EXACTLY.
|
|
1321
|
+
*
|
|
1322
|
+
* An ack that arrives with no identity at all is UNVERIFIABLE, and is refused
|
|
1323
|
+
* for a bound answer: "we could not tell who this came from" must never
|
|
1324
|
+
* collapse into "it came from the right place".
|
|
1325
|
+
*/
|
|
1326
|
+
carriedBy(carrier, from) {
|
|
1327
|
+
if (carrier === undefined)
|
|
1328
|
+
return true; // never bound to a turn (a push)
|
|
1329
|
+
if (carrier === null)
|
|
1330
|
+
return false; // handed back with no live turn to ride
|
|
1331
|
+
return from?.carrier === carrier;
|
|
1332
|
+
}
|
|
1333
|
+
/** Simulate the ticket map's bound biting, without running 1024 asks. */
|
|
1334
|
+
dropTicketForTest(askId) {
|
|
1335
|
+
this.tickets.delete(askId);
|
|
1336
|
+
}
|
|
1337
|
+
/** Strand an eviction debt on a tab without staging 48 evictions. */
|
|
1338
|
+
noteDroppedForTest(key, count, incarnation) {
|
|
1339
|
+
this.noteDropped(key, count, incarnation ?? this.incarnationOf?.(key));
|
|
1340
|
+
}
|
|
1341
|
+
/** Record + hand back in one step, returning the token. */
|
|
1342
|
+
markReturnedForTest(askId, reply, tabId) {
|
|
1343
|
+
const token = this.record(askId, reply, { tabId }).token;
|
|
1344
|
+
this.markReturned(token);
|
|
1345
|
+
return token;
|
|
1346
|
+
}
|
|
1347
|
+
entriesFor(key) {
|
|
1348
|
+
return [...this.entries.values()].filter((e) => e.key === key);
|
|
1349
|
+
}
|
|
1350
|
+
reset() {
|
|
1351
|
+
// NOTE: the flusher is deliberately NOT cleared — it belongs to the process's
|
|
1352
|
+
// orchestrator, not to any one test's fixture.
|
|
1353
|
+
this.tickets.clear();
|
|
1354
|
+
this.tabEpoch.clear();
|
|
1355
|
+
this.entries.clear();
|
|
1356
|
+
this.dropped.clear();
|
|
1357
|
+
this.disclosedUpTo.clear();
|
|
1358
|
+
this.debtTokens.clear();
|
|
1359
|
+
this.seq = 0;
|
|
1360
|
+
this.ticketSeq = 0;
|
|
1361
|
+
}
|
|
1362
|
+
trimTickets() {
|
|
1363
|
+
while (this.tickets.size > MAX_TICKETS) {
|
|
1364
|
+
// Prefer a ticket no handler is waiting on; otherwise the oldest. Losing a
|
|
1365
|
+
// ticket does not lose an answer — it makes a later answer for it read as
|
|
1366
|
+
// UNATTRIBUTED, which is the honest verdict once we have forgotten the
|
|
1367
|
+
// question — but say so, because it is a real degradation.
|
|
1368
|
+
let victim = null;
|
|
1369
|
+
for (const [id, t] of this.tickets) {
|
|
1370
|
+
if (!t.awaiting) {
|
|
1371
|
+
victim = id;
|
|
1372
|
+
break;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
if (!victim)
|
|
1376
|
+
victim = this.tickets.keys().next().value ?? null;
|
|
1377
|
+
if (!victim)
|
|
1378
|
+
return;
|
|
1379
|
+
logger.error(`[ask-answers] over ${MAX_TICKETS} question cards have been tracked — forgetting ask ${victim.slice(0, 12)}; a late answer for it will be reported as UNATTRIBUTED (its question can no longer be named)`);
|
|
1380
|
+
this.tickets.delete(victim);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Record that an answer for `key` was destroyed by an eviction, so the next
|
|
1385
|
+
* delivery to that tab can report it as UNDETERMINED. Stamped onto a surviving
|
|
1386
|
+
* PENDING entry when there is one — it then rides out on a real delivery and
|
|
1387
|
+
* cannot be discarded.
|
|
1388
|
+
*/
|
|
1389
|
+
noteDropped(key, count, incarnation) {
|
|
1390
|
+
if (count <= 0)
|
|
1391
|
+
return;
|
|
1392
|
+
// THE LOSS BELONGS TO THE OCCUPANT IT HAPPENED TO, and that is carried in,
|
|
1393
|
+
// never re-derived from the key at write time. The bucket is keyed by
|
|
1394
|
+
// incarnation, so deriving an owner here would file A's loss under whoever
|
|
1395
|
+
// holds the key NOW — B is then pushed A's `dropped_answers`, B's ack clears
|
|
1396
|
+
// it, and A's own lifecycle callback (which retires A's bucket) finds
|
|
1397
|
+
// nothing left to disclose. Keying the bucket and then guessing the key is
|
|
1398
|
+
// the same halfway pattern as scoping the clock but not the debt.
|
|
1399
|
+
const carrier = [...this.entries.values()].find((e) => e.key === key && e.delivery === "pending" && e.incarnation === incarnation);
|
|
1400
|
+
if (carrier) {
|
|
1401
|
+
carrier.disclose = (carrier.disclose ?? 0) + count;
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
const slot = tabIncarnationSlot(key, incarnation);
|
|
1405
|
+
this.dropped.set(slot, (this.dropped.get(slot) ?? 0) + count);
|
|
1406
|
+
// NO CEILING HERE, deliberately.
|
|
1407
|
+
//
|
|
1408
|
+
// This map is not payload — it is the PROMISE that a loss will be reported,
|
|
1409
|
+
// and it is the last record of answers whose text is already gone. Evicting
|
|
1410
|
+
// it on the same terms as data (as an earlier draft did, at 64 tabs) turns a
|
|
1411
|
+
// disclosed loss straight back into a silent one: the tab's next ask, the
|
|
1412
|
+
// restart gate and the fatal-exit report all lose their only trace, and the
|
|
1413
|
+
// log line that announced it said outright that the tab would never be told.
|
|
1414
|
+
// A bounded store must never be what decides whether a warning is owed.
|
|
1415
|
+
//
|
|
1416
|
+
// It cannot grow without limit anyway: one integer per PANEL TAB that has
|
|
1417
|
+
// stranded debt, removed by forget() when the tab goes away (which logs at
|
|
1418
|
+
// ERROR, the one place the promise genuinely cannot be kept) and merged by
|
|
1419
|
+
// moveKey() across a tab-id migration.
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Enforce the per-tab and global ceilings.
|
|
1423
|
+
*
|
|
1424
|
+
* EVICTION ORDER matters and is the same judgement #468 makes: an answer that
|
|
1425
|
+
* was already RETURNED to a tool result has reached a caller, so forgetting it
|
|
1426
|
+
* costs at most a re-ask; an ORPHANED one has reached nobody, so evicting it
|
|
1427
|
+
* is a real loss — those go last, are logged at ERROR, and are COUNTED so the
|
|
1428
|
+
* next delivery tells the agent those answers are UNDETERMINED.
|
|
1429
|
+
*/
|
|
1430
|
+
trimEntries(key) {
|
|
1431
|
+
const evict = (scope, limit, label) => {
|
|
1432
|
+
let mine = [...this.entries.values()].filter(scope);
|
|
1433
|
+
while (mine.length > limit) {
|
|
1434
|
+
// EVICTION ORDER, and the one decision that may forget an answer
|
|
1435
|
+
// QUIETLY. It keys on ACKED — the turn that carried the answer produced
|
|
1436
|
+
// its own marked result — never on `returned`, which says only that the
|
|
1437
|
+
// answer was written to a transport that may already have been
|
|
1438
|
+
// abandoned. That is the premise of #486, so it cannot also be the
|
|
1439
|
+
// licence to forget.
|
|
1440
|
+
//
|
|
1441
|
+
// Ordinary asks ARE acked (the model reads the tool result and finishes
|
|
1442
|
+
// its turn), so the common path stays silent and no warning cries wolf.
|
|
1443
|
+
// An answer whose turn never completed is precisely the #486 failure, so
|
|
1444
|
+
// it is counted and disclosed exactly like one that reached nobody.
|
|
1445
|
+
const victim = mine.find((e) => e.acked === true) ??
|
|
1446
|
+
mine.find((e) => e.returned) ??
|
|
1447
|
+
mine[0];
|
|
1448
|
+
this.entries.delete(victim.token);
|
|
1449
|
+
mine = mine.filter((e) => e !== victim);
|
|
1450
|
+
if (victim.acked === true) {
|
|
1451
|
+
// ANY DISCLOSURE IT WAS CARRYING is re-homed, not dropped with it: this
|
|
1452
|
+
// answer's own recoverability is what may be forgotten, never the debt
|
|
1453
|
+
// owed for EARLIER answers that reached nobody and merely happened to
|
|
1454
|
+
// be stamped on this entry.
|
|
1455
|
+
if (victim.disclose)
|
|
1456
|
+
this.noteDropped(victim.key, victim.disclose, victim.incarnation);
|
|
1457
|
+
logger.debug(`[ask-answers] ${label} — forgetting an answer to "${preview(victim.question)}" that the agent provably read`);
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
this.noteDropped(victim.key, 1 + (victim.disclose ?? 0), victim.incarnation);
|
|
1461
|
+
logger.error(`[ask-answers] ${label} — dropped a VALIDATED answer to "${preview(victim.question)}"${victim.returned ? " that went into a tool call whose receipt was never confirmed" : " that had reached nobody"}; the next delivery will report it as undetermined`);
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
if (key) {
|
|
1465
|
+
evict((e) => e.key === key, MAX_ENTRIES_PER_KEY, `journal for tab ${key.slice(0, 8)} exceeded ${MAX_ENTRIES_PER_KEY} answers`);
|
|
1466
|
+
}
|
|
1467
|
+
evict(() => true, MAX_ENTRIES_TOTAL, `journal exceeded ${MAX_ENTRIES_TOTAL} answers overall`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
/** Short, log-safe rendering of a question. */
|
|
1471
|
+
export function preview(question) {
|
|
1472
|
+
if (!question)
|
|
1473
|
+
return "(an unattributed question)";
|
|
1474
|
+
const one = question.replace(/\s+/g, " ").trim();
|
|
1475
|
+
return one.length > 60 ? `${one.slice(0, 57)}…` : one;
|
|
1476
|
+
}
|
|
1477
|
+
/** How long a recovered answer may be presented as the result of a re-ask. */
|
|
1478
|
+
export const ASK_RECOVER_MAX_AGE_MS = RECOVER_MAX_AGE_MS;
|
|
1479
|
+
/** Process-wide journal (mirrors the RunCompletions singleton): the panel_ask
|
|
1480
|
+
* tool opens tickets and consumes recoveries from the tool layer, while the
|
|
1481
|
+
* orchestrator's bridge sink records late answers and pushes the orphans, with
|
|
1482
|
+
* no ctx plumbing between them. */
|
|
1483
|
+
export const AskAnswers = new AskAnswerJournalImpl();
|
|
1484
|
+
//# sourceMappingURL=ask-answer-journal.js.map
|