@cotal-ai/connector-core 0.68.0 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +3 -3
- package/dist/agent.js.map +1 -1
- package/dist/agui-emitter.d.ts +318 -0
- package/dist/agui-emitter.d.ts.map +1 -0
- package/dist/agui-emitter.js +706 -0
- package/dist/agui-emitter.js.map +1 -0
- package/dist/agui-holder.d.ts +2 -1
- package/dist/agui-holder.d.ts.map +1 -1
- package/dist/agui-holder.js.map +1 -1
- package/dist/agui.d.ts +19 -318
- package/dist/agui.d.ts.map +1 -1
- package/dist/agui.js +15 -707
- package/dist/agui.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -7
- package/dist/config.js.map +1 -1
- package/dist/docs-bundle.generated.d.ts +1 -1
- package/dist/docs-bundle.generated.js +20 -20
- package/dist/docs-bundle.generated.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tool-specs.d.ts.map +1 -1
- package/dist/tool-specs.js +6 -26
- package/dist/tool-specs.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AG-UI event emitter, the one part of the event plane that TALKS: it reads a durable source
|
|
3
|
+
* forward from the WAL's cursor, maps records to events, packs them into frames that provably fit,
|
|
4
|
+
* and appends them to the principal's event channel under an optimistic-concurrency expectation
|
|
5
|
+
* with a frozen dedup id. The vocabulary it speaks is `agui.ts`, which does no I/O and does not
|
|
6
|
+
* import this module.
|
|
7
|
+
*
|
|
8
|
+
* The emitter owns no policy about WHAT an event means. That belongs to the per-connector mapping,
|
|
9
|
+
* and it arrives here as an injected function. What it owns is the property none of the pieces hold
|
|
10
|
+
* alone: that a frame is either on the wire and folded into the frontier, or not on the wire and
|
|
11
|
+
* not folded, and that no third state is ever reported as success.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
import { isAguiFramePart, isCasLoss, principalKey } from "@cotal-ai/core";
|
|
16
|
+
import { AguiBrackets, AguiVocabularyError, aguiFrame, applyAguiEgressPolicy, extraPropertyPath, frozenBodyEgressVerdict, RUN_ERROR_EGRESS_MESSAGE, runError, runFinished, takeCodePoints, } from "./agui.js";
|
|
17
|
+
import { eventChannelForSession } from "./launch.js";
|
|
18
|
+
/**
|
|
19
|
+
* A bracket violation that is OURS, not the writer's: the machine that tracks open runs and messages
|
|
20
|
+
* was lost across a process restart.
|
|
21
|
+
*
|
|
22
|
+
* **This exists because two halts that both say "unbalanced" prove nothing about which produced
|
|
23
|
+
* one.** The WAL persists `epoch`, `frontier` and the pending frame, and NOT the set of open
|
|
24
|
+
* runs and messages, so a process that dies mid-run restarts with an empty {@link AguiBrackets},
|
|
25
|
+
* resumes from `sourceCursor` at events whose `RUN_STARTED` was already published, and refuses the
|
|
26
|
+
* first of them. Without this class the operator sees "nothing may be emitted outside an open run"
|
|
27
|
+
* and files a bug against a writer that did nothing wrong.
|
|
28
|
+
*
|
|
29
|
+
* It is deliberately a SUBCLASS: every existing catch of {@link AguiVocabularyError} still catches
|
|
30
|
+
* it, and only code that wants to tell the two apart has to know it exists.
|
|
31
|
+
*/
|
|
32
|
+
export class AguiBracketStateLost extends AguiVocabularyError {
|
|
33
|
+
cause;
|
|
34
|
+
constructor(message, cause) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.cause = cause;
|
|
37
|
+
this.name = "AguiBracketStateLost";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The emitter has stopped and will not publish again without operator action.
|
|
42
|
+
*
|
|
43
|
+
* Halting is a SUCCESS of this design, not a failure of it: every halt below is a case where the
|
|
44
|
+
* alternative is to report success for a message that was not stored, or to fold an ack for a body
|
|
45
|
+
* we did not write. A halt is loud, bounded and recoverable by a human; the alternative is silent
|
|
46
|
+
* and permanent.
|
|
47
|
+
*/
|
|
48
|
+
export class AguiEmitterHalted extends Error {
|
|
49
|
+
reason;
|
|
50
|
+
constructor(reason, message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.reason = reason;
|
|
53
|
+
this.name = "AguiEmitterHalted";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The id used only for SIZING, and it is the longest one `assertIdToken` admits.
|
|
58
|
+
*
|
|
59
|
+
* Sizing must never report a smaller number than publishing will produce, and the id is not known
|
|
60
|
+
* when a frame is measured — it is minted per publish attempt. Measuring at the maximum admissible
|
|
61
|
+
* length makes the measurement an UPPER BOUND over every id the emitter could mint, which costs a
|
|
62
|
+
* few bytes of packing density and removes an entire class of near-ceiling defect. The alternative,
|
|
63
|
+
* measuring with the id we intend to use, requires minting before packing and freezing an id for a
|
|
64
|
+
* frame that may never be built.
|
|
65
|
+
*/
|
|
66
|
+
const SIZING_ID = "S".repeat(64);
|
|
67
|
+
/**
|
|
68
|
+
* Likewise for the expectation, and this one CANNOT be known at pack time even in principle.
|
|
69
|
+
*
|
|
70
|
+
* `expectedLastSubjectSeq` for frame k+1 is the sequence the broker assigns frame k, and the stream
|
|
71
|
+
* sequence advances with every message in the space, not only ours. Its decimal length is therefore
|
|
72
|
+
* unknowable while packing. `MAX_SAFE_INTEGER` is the widest value the publish path will accept, so
|
|
73
|
+
* measuring at it bounds every expectation the emitter can ever send.
|
|
74
|
+
*/
|
|
75
|
+
const SIZING_EXPECTATION = Number.MAX_SAFE_INTEGER;
|
|
76
|
+
/**
|
|
77
|
+
* Pack units into frames, splitting ONLY at unit boundaries and never inside one.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately NOT `splitFrames`, and the difference is the durable plane's one-unit-one-frame
|
|
80
|
+
* rule. `splitFrames` splits at EVENT boundaries, which is the right answer for a frame considered
|
|
81
|
+
* on its own, but a frame that ends mid-record has no cursor it can honestly store: the only value
|
|
82
|
+
* available says the whole record was consumed, and folding that after a crash skips the rest of the
|
|
83
|
+
* record's events with no `seq` gap for a consumer to notice.
|
|
84
|
+
*
|
|
85
|
+
* **So the event-boundary split and the one-unit-one-frame rule are in tension, and this resolves it
|
|
86
|
+
* in the direction the durable plane requires: a single unit that does not fit FAILS LOUD rather
|
|
87
|
+
* than being truncated at a frame boundary.** That leaves `splitFrames`'s truncation path with no
|
|
88
|
+
* caller on the durable plane, which is reported as a design conflict rather than decided here.
|
|
89
|
+
*
|
|
90
|
+
* @throws {AguiVocabularyError} when one unit cannot fit in a frame alone.
|
|
91
|
+
*/
|
|
92
|
+
export function packUnits(opts) {
|
|
93
|
+
const { threadId, epoch, measure, limit } = opts;
|
|
94
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
95
|
+
throw new AguiVocabularyError(`pack limit must be a positive safe integer, got ${JSON.stringify(limit)}`);
|
|
96
|
+
const out = [];
|
|
97
|
+
let seq = opts.firstSeq;
|
|
98
|
+
let batch = [];
|
|
99
|
+
let batchRun;
|
|
100
|
+
let batchCursor;
|
|
101
|
+
const flush = () => {
|
|
102
|
+
if (batch.length === 0)
|
|
103
|
+
return;
|
|
104
|
+
out.push({ frame: aguiFrame({ threadId, runId: batchRun, epoch, seq, events: batch }), cursor: batchCursor });
|
|
105
|
+
seq += 1;
|
|
106
|
+
batch = [];
|
|
107
|
+
batchRun = undefined;
|
|
108
|
+
batchCursor = undefined;
|
|
109
|
+
};
|
|
110
|
+
for (const unit of opts.units) {
|
|
111
|
+
if (unit.events.length === 0)
|
|
112
|
+
throw new AguiVocabularyError("packUnits was handed an empty unit; a record that maps to nothing advances the cursor and never becomes a frame");
|
|
113
|
+
// A frame names ONE run. A unit from a different run cannot join the open batch even if it
|
|
114
|
+
// would fit, so the run change is a flush and not a size decision.
|
|
115
|
+
if (batchRun !== undefined && unit.runId !== batchRun)
|
|
116
|
+
flush();
|
|
117
|
+
const candidate = [...batch, ...unit.events];
|
|
118
|
+
const fits = measure(aguiFrame({ threadId, runId: batchRun ?? unit.runId, epoch, seq, events: candidate })) <= limit;
|
|
119
|
+
if (fits) {
|
|
120
|
+
batch = candidate;
|
|
121
|
+
batchRun = batchRun ?? unit.runId;
|
|
122
|
+
batchCursor = unit.cursor;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// It did not fit WITH the open batch. Flush and try it alone before concluding anything about
|
|
126
|
+
// the unit itself: an ordinary unit that happens to arrive behind a nearly-full frame is not an
|
|
127
|
+
// oversized unit, and treating it as one would halt the emitter on a packing accident.
|
|
128
|
+
flush();
|
|
129
|
+
const alone = aguiFrame({ threadId, runId: unit.runId, epoch, seq, events: unit.events });
|
|
130
|
+
const aloneBytes = measure(alone);
|
|
131
|
+
if (aloneBytes > limit)
|
|
132
|
+
throw new AguiVocabularyError(`a single source observation does not fit in one frame (${aloneBytes} > ${limit} bytes, ` +
|
|
133
|
+
`${unit.events.length} event(s), run ${unit.runId}). One source observation is one frame, ` +
|
|
134
|
+
`and that rule requires this to fail loud rather ` +
|
|
135
|
+
`than be truncated at a frame boundary: a frame that ends mid-record has no cursor it can ` +
|
|
136
|
+
`honestly store, and a dropped boundary with no gap marker is worse than a halt.`);
|
|
137
|
+
batch = [...unit.events];
|
|
138
|
+
batchRun = unit.runId;
|
|
139
|
+
batchCursor = unit.cursor;
|
|
140
|
+
}
|
|
141
|
+
flush();
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The notice a bounded `RUN_ERROR` carries so a reader cannot mistake a shortened or omitted
|
|
146
|
+
* upstream detail for the original. The close path is the one place this string is composed.
|
|
147
|
+
*/
|
|
148
|
+
const RUN_ERROR_DETAIL_BOUND_NOTICE = "original detail omitted or shortened because it exceeded the frame bound";
|
|
149
|
+
/**
|
|
150
|
+
* Rebuild a `RUN_ERROR` so the closing frame fits the live payload ceiling.
|
|
151
|
+
*
|
|
152
|
+
* **This exists because the close is a terminal a reader waits on, and the message is untrusted
|
|
153
|
+
* upstream free text.** `packUnits` is right to refuse an oversized source observation — a unit
|
|
154
|
+
* that does not fit has no honest cursor. A close unit is not a source observation: we author it,
|
|
155
|
+
* it consumes no record, and refusing it leaves the run with no terminal, no pending WAL recovery,
|
|
156
|
+
* and a dead holder. So the close rebuilds the one event until the SAME `measure` `packUnits` will
|
|
157
|
+
* use says it fits, and says in the message that the original detail was omitted or shortened. A
|
|
158
|
+
* short message that already fits is returned unchanged. Since #1431 the close passes only
|
|
159
|
+
* {@link RUN_ERROR_EGRESS_MESSAGE}, which is shorter than the notice, so this either returns it
|
|
160
|
+
* unchanged or throws.
|
|
161
|
+
*
|
|
162
|
+
* It does not live in `packUnits` and it does not call `splitFrames`. Those are a different
|
|
163
|
+
* contract (source-record packing, and the preview plane). Putting the bound on this close is the
|
|
164
|
+
* one place every connector already goes through.
|
|
165
|
+
*/
|
|
166
|
+
function boundRunErrorForFrame(opts) {
|
|
167
|
+
const build = (message) => runError({
|
|
168
|
+
message,
|
|
169
|
+
timestamp: opts.timestamp,
|
|
170
|
+
...(opts.code ? { code: opts.code } : {}),
|
|
171
|
+
...(opts.cotal ? { cotal: opts.cotal } : {}),
|
|
172
|
+
});
|
|
173
|
+
const frameOf = (event) => aguiFrame({
|
|
174
|
+
threadId: opts.threadId,
|
|
175
|
+
runId: opts.runId,
|
|
176
|
+
epoch: opts.epoch,
|
|
177
|
+
seq: opts.seq,
|
|
178
|
+
events: [event],
|
|
179
|
+
});
|
|
180
|
+
const original = build(opts.message);
|
|
181
|
+
if (opts.measure(frameOf(original)) <= opts.limit)
|
|
182
|
+
return original;
|
|
183
|
+
const labelled = (kept) => kept.length === 0 ? RUN_ERROR_DETAIL_BOUND_NOTICE : `${RUN_ERROR_DETAIL_BOUND_NOTICE}: ${kept}`;
|
|
184
|
+
const at = (codePoints) => build(labelled(takeCodePoints(opts.message, codePoints)));
|
|
185
|
+
const emptied = at(0);
|
|
186
|
+
if (opts.measure(frameOf(emptied)) > opts.limit)
|
|
187
|
+
throw new AguiVocabularyError(`a RUN_ERROR close does not fit in ${opts.limit} bytes even with the failure detail emptied ` +
|
|
188
|
+
`and replaced by a bound notice — the envelope, the code and the headers alone are ` +
|
|
189
|
+
`${opts.measure(frameOf(emptied))} bytes. No bound on the detail can help, so this fails ` +
|
|
190
|
+
`loudly rather than leaving the run without a terminal.`);
|
|
191
|
+
// Binary-search the largest well-formed prefix that still fits, re-measuring the whole frame
|
|
192
|
+
// each step: JSON escaping and UTF-8 length are nonlinear, so cutting by the overage is wrong.
|
|
193
|
+
let lo = 0;
|
|
194
|
+
let hi = Array.from(opts.message).length + 1;
|
|
195
|
+
while (hi - lo > 1) {
|
|
196
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
197
|
+
if (opts.measure(frameOf(at(mid))) <= opts.limit)
|
|
198
|
+
lo = mid;
|
|
199
|
+
else
|
|
200
|
+
hi = mid;
|
|
201
|
+
}
|
|
202
|
+
return at(lo);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The event emitter: one per principal, one thread at a time.
|
|
206
|
+
*
|
|
207
|
+
* **BRACKET STATE SURVIVES A RESTART, AND THIS PARAGRAPH USED TO SAY THE OPPOSITE.** It described a
|
|
208
|
+
* declared gap — an emitter coming back with an empty machine, resuming at events whose
|
|
209
|
+
* `RUN_STARTED` had already been published, and refusing the first of them — long after the WAL
|
|
210
|
+
* started persisting the machine. The words were true when they were written and stayed on the page
|
|
211
|
+
* through the change that falsified them, which is the failure mode a class header is worst at
|
|
212
|
+
* showing: it is the first thing a cutover author reads about recovery, and it was telling them to
|
|
213
|
+
* expect a halt the code no longer produces.
|
|
214
|
+
*
|
|
215
|
+
* What actually happens: {@link AguiBrackets} is a property of the WRITER'S STREAM across frames, so
|
|
216
|
+
* the WAL freezes the machine's state WITH each pending frame and promotes it on fold. A restart
|
|
217
|
+
* therefore reopens knowing exactly which run, messages and tool calls were open at the last FOLDED
|
|
218
|
+
* position, and {@link AguiBrackets.restore} continues from there rather than from empty.
|
|
219
|
+
*
|
|
220
|
+
* **The lost-state path still exists, and it is now the narrow case it should always have been:** a
|
|
221
|
+
* document that CANNOT SAY what was open. That is a WAL migrated from v1, which recorded no bracket
|
|
222
|
+
* state at all, and it loads as `null` rather than as an empty machine precisely so the difference
|
|
223
|
+
* stays visible. Only there does the emitter start empty, resume into an already-open run, and
|
|
224
|
+
* refuse the first event with {@link AguiBracketStateLost} — a halt rather than a loss, which is the
|
|
225
|
+
* safe direction, and diagnosed by name rather than surfacing as an anonymous protocol violation.
|
|
226
|
+
*/
|
|
227
|
+
export class AguiEmitter {
|
|
228
|
+
ep;
|
|
229
|
+
wal;
|
|
230
|
+
source;
|
|
231
|
+
map;
|
|
232
|
+
channel;
|
|
233
|
+
threadId;
|
|
234
|
+
/**
|
|
235
|
+
* The bracket machine AT THE FOLDED POSITION — deliberately not "wherever validation got to".
|
|
236
|
+
*
|
|
237
|
+
* It advances one frame at a time, immediately before that frame's `beginSend`, so the state
|
|
238
|
+
* frozen with a pending frame is the state that belongs to it. A machine advanced by the whole
|
|
239
|
+
* batch up front would freeze a state describing events that had not been sent.
|
|
240
|
+
*/
|
|
241
|
+
brackets;
|
|
242
|
+
halted;
|
|
243
|
+
/** True once THIS process has fed an event through the bracket machine. It is the half of the
|
|
244
|
+
* restart diagnosis that keeps a genuine mid-stream violation from being blamed on a restart. */
|
|
245
|
+
fedAnyEvent = false;
|
|
246
|
+
constructor(ep, wal, source, map,
|
|
247
|
+
/** Derived from the endpoint's OWN principal, never from a config name or the launch env. */
|
|
248
|
+
channel, threadId) {
|
|
249
|
+
this.ep = ep;
|
|
250
|
+
this.wal = wal;
|
|
251
|
+
this.source = source;
|
|
252
|
+
this.map = map;
|
|
253
|
+
this.channel = channel;
|
|
254
|
+
this.threadId = threadId;
|
|
255
|
+
// RESTORED FROM THE WAL, which is the whole point of the v2 migration: a process that died
|
|
256
|
+
// mid-run comes back knowing which runs and messages are open, instead of refusing the first
|
|
257
|
+
// event it re-reads. `null` means the document cannot say (migrated from v1) — an empty machine
|
|
258
|
+
// is the honest starting point there, and `diagnoseBracket` is what keeps the resulting refusal
|
|
259
|
+
// from being blamed on the writer.
|
|
260
|
+
this.brackets = wal.brackets ? AguiBrackets.restore(wal.brackets) : new AguiBrackets();
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Start an emitter: resolve the channel, run the single-replica preflight, and settle any pending
|
|
264
|
+
* frame.
|
|
265
|
+
*
|
|
266
|
+
* **THIS IS THAT PREFLIGHT'S PRODUCTION CALL SITE, AND UNTIL THIS FUNCTION EXISTED THERE WAS
|
|
267
|
+
* NONE.** `CotalEndpoint.assertExpectationSemantics()` had zero production callers: it was a
|
|
268
|
+
* check that shipped, was covered by its own suite, and never ran outside one. That is why it is
|
|
269
|
+
* called HERE, before recovery and therefore before any publish — a serialized append on an
|
|
270
|
+
* unverified stream is the exact case it exists to prevent, and doing it after recovery would
|
|
271
|
+
* leave the one publish that matters most, the re-publish of a frozen frame, outside the guard.
|
|
272
|
+
*/
|
|
273
|
+
static async start(opts) {
|
|
274
|
+
const { endpoint, wal } = opts;
|
|
275
|
+
const channel = eventChannelForSession(endpoint);
|
|
276
|
+
// The WAL must be THIS principal's. `EventWal.open` refuses a document whose stored principal
|
|
277
|
+
// disagrees with what it was asked for, but that protects the file against being mistaken for
|
|
278
|
+
// another; it cannot notice an emitter handed the wrong WAL object. Publishing under one
|
|
279
|
+
// principal's identity while recovering another's frozen `E` is a fabricated frontier.
|
|
280
|
+
const live = principalKey(endpoint.principal.owner, endpoint.principal.actor).key;
|
|
281
|
+
if (wal.principal !== live)
|
|
282
|
+
throw new Error(`event WAL belongs to principal ${wal.principal}, but this endpoint is ${live} — refusing to ` +
|
|
283
|
+
`publish under one identity from another's write-ahead log`);
|
|
284
|
+
// THE SINGLE-REPLICA PREFLIGHT: before recovery, before any publish.
|
|
285
|
+
await endpoint.assertExpectationSemantics();
|
|
286
|
+
// REQUIRED AT RUNTIME, NOT ONLY IN THE TYPE. Smoke files in this repo are not typechecked, so a
|
|
287
|
+
// caller that omits this would bind `undefined`, fall back to the per-thread number, and pass
|
|
288
|
+
// every existing cell while shipping the exact defect this parameter exists to remove. A type
|
|
289
|
+
// that only the compiler enforces is not a guard for the callers the compiler never sees.
|
|
290
|
+
if (!opts.subjectFrontier || typeof opts.subjectFrontier.advance !== "function")
|
|
291
|
+
throw new Error(`event emitter for ${channel}: a subject frontier is required — the subject is shared by every ` +
|
|
292
|
+
`thread of this principal, so the publish expectation cannot come from one thread's log`);
|
|
293
|
+
// BOUND BEFORE RECOVERY, and the order matters for the same reason the preflight's does:
|
|
294
|
+
// recovery can republish a frozen frame, and a WAL whose expectation still came from its own
|
|
295
|
+
// thread would republish against the wrong tip.
|
|
296
|
+
await wal.bindSubjectFrontier(opts.subjectFrontier);
|
|
297
|
+
const em = new AguiEmitter(endpoint, wal, opts.source, opts.map, channel, wal.threadId);
|
|
298
|
+
await em.recover();
|
|
299
|
+
return em;
|
|
300
|
+
}
|
|
301
|
+
/** True once the emitter has stopped for good. */
|
|
302
|
+
get stopped() {
|
|
303
|
+
return this.halted !== undefined;
|
|
304
|
+
}
|
|
305
|
+
/** The run {@link closeRun} would close now, or `undefined` at a stopping point. */
|
|
306
|
+
get openRunId() {
|
|
307
|
+
return this.brackets.runId;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Boot recovery, branching on the WAL's tag.
|
|
311
|
+
*
|
|
312
|
+
* `acked` NEVER republishes: the frame landed and we know it, so the only remaining work is to
|
|
313
|
+
* fold. `sent_unacked` is the genuinely uncertain case and republishes with the SAME frozen `id`
|
|
314
|
+
* and `E` — never the current tip, because re-deriving either is what turns an uncertain publish
|
|
315
|
+
* into a second, different message.
|
|
316
|
+
*/
|
|
317
|
+
async recover() {
|
|
318
|
+
const p = this.wal.pending;
|
|
319
|
+
if (!p)
|
|
320
|
+
return;
|
|
321
|
+
if (p.state === "acked") {
|
|
322
|
+
await this.wal.fold();
|
|
323
|
+
this.brackets = AguiBrackets.restore(p.brackets);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// The retried frame was accepted by a PREVIOUS process and is not fed through this instance's
|
|
327
|
+
// machine again. Folding promotes its bracket snapshot on disk; promote the same snapshot here
|
|
328
|
+
// or this emitter continues from the pre-pending state and rejects the next legal event.
|
|
329
|
+
await this.attempt({ id: p.id, E: p.E, body: p.body, retry: true });
|
|
330
|
+
this.brackets = AguiBrackets.restore(p.brackets);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Read forward, map, pack, and publish. Returns what it did, so a caller can distinguish "nothing
|
|
334
|
+
* to do" from "did work" without inspecting the WAL.
|
|
335
|
+
*/
|
|
336
|
+
async pump() {
|
|
337
|
+
if (this.halted)
|
|
338
|
+
throw this.halted;
|
|
339
|
+
if (this.wal.pending)
|
|
340
|
+
throw new Error(`event emitter for ${this.channel}: a frame is still pending; recovery must settle it before a new read`);
|
|
341
|
+
const read = await this.source.read(this.wal.frontier.sourceCursor);
|
|
342
|
+
// Units the mapper dropped are not errors and not frames. Their cursor folds FORWARD into the
|
|
343
|
+
// preceding unit, so consuming that frame consumes them too and they are never re-read. A drop
|
|
344
|
+
// stays apart from a mapper error, which reaches the caller with the cursor unmoved.
|
|
345
|
+
const units = [];
|
|
346
|
+
for (const rec of read.records) {
|
|
347
|
+
const mapped = this.map(rec.value);
|
|
348
|
+
if (mapped === null || mapped.events.length === 0) {
|
|
349
|
+
const last = units[units.length - 1];
|
|
350
|
+
if (last)
|
|
351
|
+
last.cursor = rec.cursor;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
// WRITE-PATH POLICY, before packing and therefore before beginSend. Disk and wire then agree.
|
|
355
|
+
// A record that mapped only to forbidden kinds becomes a drop: its cursor folds forward like
|
|
356
|
+
// any other drop, and packUnits never sees an empty unit.
|
|
357
|
+
const events = applyAguiEgressPolicy(mapped.events);
|
|
358
|
+
if (events.length === 0) {
|
|
359
|
+
const last = units[units.length - 1];
|
|
360
|
+
if (last)
|
|
361
|
+
last.cursor = rec.cursor;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
units.push({ runId: mapped.runId, events, cursor: rec.cursor });
|
|
365
|
+
}
|
|
366
|
+
// A bounded range that mapped to nothing advances the cursor atomically and ALONE.
|
|
367
|
+
//
|
|
368
|
+
// THIS IS THE COMMON PATH, NOT AN EDGE CASE, and the number is here so nobody reads it as one.
|
|
369
|
+
// Measured on a real Claude session of 5938 records: only 2694 (45%) carry a `message` at all —
|
|
370
|
+
// the rest are attachments, queue operations, mode changes, prompt markers and system entries
|
|
371
|
+
// the mapping deliberately drops. So the MAJORITY of a real session maps to nothing. An emitter
|
|
372
|
+
// advanced the cursor only through an acked frame would re-read the same 55% forever, and no
|
|
373
|
+
// fixture would ever show it, because a fixture author writes records that mean something.
|
|
374
|
+
//
|
|
375
|
+
// It must also advance on an ADOPT, which reads zero records by design: without that the position
|
|
376
|
+
// is never persisted and the next read adopts a LATER end, silently skipping everything appended
|
|
377
|
+
// in between.
|
|
378
|
+
if (units.length === 0) {
|
|
379
|
+
if (read.cursor !== this.wal.frontier.sourceCursor)
|
|
380
|
+
await this.wal.advanceCursorOnly(read.cursor);
|
|
381
|
+
return { frames: 0, events: 0 };
|
|
382
|
+
}
|
|
383
|
+
// Validate the WHOLE batch before publishing any of it. A vocabulary violation discovered
|
|
384
|
+
// halfway through would leave a valid prefix on the wire and the rest refused, and the refusal
|
|
385
|
+
// is supposed to mean "this stream never carried that", not "it carried some of it".
|
|
386
|
+
// Validated on a CLONE, so the machine that is in step with the disk does not advance for a
|
|
387
|
+
// batch that may never be sent. The clone starts from the folded state, so it sees exactly what
|
|
388
|
+
// the real machine will see, in the same order.
|
|
389
|
+
const probe = this.brackets.clone();
|
|
390
|
+
for (const u of units)
|
|
391
|
+
for (const e of u.events) {
|
|
392
|
+
try {
|
|
393
|
+
probe.accept(e);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
throw this.diagnoseBracket(err);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Set only after the WHOLE batch validated. Setting it per event would make a batch that failed
|
|
400
|
+
// on its FIRST event count as "this process has fed something", which is the precise input that
|
|
401
|
+
// turns the restart diagnosis off.
|
|
402
|
+
this.fedAnyEvent = true;
|
|
403
|
+
const frames = packUnits({
|
|
404
|
+
threadId: this.threadId,
|
|
405
|
+
epoch: this.wal.epoch,
|
|
406
|
+
firstSeq: this.wal.frontier.seq + 1,
|
|
407
|
+
units,
|
|
408
|
+
measure: (f) => this.measure(f),
|
|
409
|
+
limit: this.ep.maxPayload,
|
|
410
|
+
});
|
|
411
|
+
let events = 0;
|
|
412
|
+
for (const { frame, cursor } of frames) {
|
|
413
|
+
await this.publish(frame, cursor);
|
|
414
|
+
events += frame.events.length;
|
|
415
|
+
}
|
|
416
|
+
// Records the mapper dropped AFTER the last unit have no frame to ride on, so their cursor is
|
|
417
|
+
// advanced on its own: the same cursor-only rule, applied to the tail of the batch.
|
|
418
|
+
if (read.cursor !== this.wal.frontier.sourceCursor)
|
|
419
|
+
await this.wal.advanceCursorOnly(read.cursor);
|
|
420
|
+
return { frames: frames.length, events };
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Close the run this stream currently has open, at a boundary the RECORD STREAM CANNOT SEE.
|
|
424
|
+
*
|
|
425
|
+
* **This exists because the two halves of the mapping were specified against different inputs.**
|
|
426
|
+
* The plan sources `RUN_FINISHED` from a harness lifecycle hook, and the durable plane reads a
|
|
427
|
+
* FILE: a hook fires in another process and writes no record, so a hook-sourced terminal has no
|
|
428
|
+
* vehicle into a record-sourced stream. Deriving the terminal from records instead is possible but
|
|
429
|
+
* lies about time in two ways that matter to a live view: the finish lands only when the NEXT turn
|
|
430
|
+
* starts, so a finished agent renders as still running, and the last run of a session never closes
|
|
431
|
+
* at all, because there is no later record to close it on. This is that vehicle.
|
|
432
|
+
*
|
|
433
|
+
* It is a FRAME LIKE ANY OTHER: same epoch, same `seq` line, same write-ahead discipline, same
|
|
434
|
+
* halt rules. The single thing that differs is the cursor, which is republished UNCHANGED, because
|
|
435
|
+
* this frame consumes no source record. A frame that advanced the cursor here would mark records
|
|
436
|
+
* consumed that were never mapped.
|
|
437
|
+
*
|
|
438
|
+
* Idempotent by construction rather than by a flag: the bracket machine is the only state it
|
|
439
|
+
* reads, so once the run is closed there is nothing open to close and it answers `null`. That also
|
|
440
|
+
* makes it safe on a stream whose run was opened by a PREVIOUS process, since the machine is
|
|
441
|
+
* restored from the WAL.
|
|
442
|
+
*
|
|
443
|
+
* **AN `error` CLOSES THE SAME RUN WITH `RUN_ERROR` INSTEAD, and it is one method rather than two
|
|
444
|
+
* ON PURPOSE.** `RUN_ERROR` closes a run on its own, so a run that emitted one must never also
|
|
445
|
+
* emit a `RUN_FINISHED`. With a second method that invariant would be a rule someone has to
|
|
446
|
+
* remember; with one method and one branch it is a property of the shape: exactly one terminal is
|
|
447
|
+
* built, and the bracket machine has closed the run by the time anything could ask for another, so
|
|
448
|
+
* a following close answers `null` like any other close on a settled stream. Which harness signals
|
|
449
|
+
* mean a turn FAILED is a connector's decision and is stated at each connector's own mapping site;
|
|
450
|
+
* this file only carries the answer to the wire.
|
|
451
|
+
*
|
|
452
|
+
* **`error.message` AND `error.code` NEVER REACH THE WIRE.** Both are upstream values, so the close
|
|
453
|
+
* publishes {@link RUN_ERROR_EGRESS_MESSAGE} with no code, the same shape the write path's
|
|
454
|
+
* `egressRunError` in `agui.ts` produces (#1431). The frame bound still runs after that, so a close
|
|
455
|
+
* whose envelope cannot fit fails loud rather than leaving the run without a terminal.
|
|
456
|
+
*
|
|
457
|
+
* @returns the run that was closed, or `null` when the stream was already at a stopping point.
|
|
458
|
+
*/
|
|
459
|
+
async closeRun(o) {
|
|
460
|
+
if (this.halted)
|
|
461
|
+
throw this.halted;
|
|
462
|
+
if (this.wal.pending)
|
|
463
|
+
throw new Error(`event emitter for ${this.channel}: a frame is still pending; recovery must settle it before a run can be closed`);
|
|
464
|
+
const runId = this.brackets.runId;
|
|
465
|
+
if (runId === undefined)
|
|
466
|
+
return null;
|
|
467
|
+
const cursor = this.wal.frontier.sourceCursor;
|
|
468
|
+
if (cursor === undefined)
|
|
469
|
+
throw new Error(`event emitter for ${this.channel}: run "${runId}" is open on a frontier that carries no source ` +
|
|
470
|
+
`cursor. A run can only be open because a frame published it, and a frame that published ` +
|
|
471
|
+
`cannot leave the cursor unset, so this WAL disagrees with itself. Refusing to invent a ` +
|
|
472
|
+
`cursor for the closing frame.`);
|
|
473
|
+
// `RUN_ERROR` carries no `runId` and no `threadId` of its own — its schema is `message` plus an
|
|
474
|
+
// optional `code` — so the run it closes is named by the frame's unit below, not by the event.
|
|
475
|
+
// The bound is measured with the same function and ceiling `packUnits` will use, at the `seq`
|
|
476
|
+
// the closing frame will actually carry.
|
|
477
|
+
const event = o.error
|
|
478
|
+
? boundRunErrorForFrame({
|
|
479
|
+
message: RUN_ERROR_EGRESS_MESSAGE,
|
|
480
|
+
timestamp: o.timestamp,
|
|
481
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
482
|
+
threadId: this.threadId,
|
|
483
|
+
runId,
|
|
484
|
+
epoch: this.wal.epoch,
|
|
485
|
+
seq: this.wal.frontier.seq + 1,
|
|
486
|
+
measure: (f) => this.measure(f),
|
|
487
|
+
limit: this.ep.maxPayload,
|
|
488
|
+
})
|
|
489
|
+
: runFinished({
|
|
490
|
+
threadId: this.threadId,
|
|
491
|
+
runId,
|
|
492
|
+
timestamp: o.timestamp,
|
|
493
|
+
...(o.cotal ? { cotal: o.cotal } : {}),
|
|
494
|
+
});
|
|
495
|
+
// Validated on a clone first, exactly as a mapped batch is. The refusal that matters here is a
|
|
496
|
+
// message or tool call still open under this run: either terminal while something it opened is
|
|
497
|
+
// unclosed is a protocol violation, and it must surface as one rather than be published.
|
|
498
|
+
const probe = this.brackets.clone();
|
|
499
|
+
try {
|
|
500
|
+
probe.accept(event);
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
throw this.diagnoseBracket(err);
|
|
504
|
+
}
|
|
505
|
+
this.fedAnyEvent = true;
|
|
506
|
+
const frames = packUnits({
|
|
507
|
+
threadId: this.threadId,
|
|
508
|
+
epoch: this.wal.epoch,
|
|
509
|
+
firstSeq: this.wal.frontier.seq + 1,
|
|
510
|
+
units: [{ runId, events: [event], cursor }],
|
|
511
|
+
measure: (f) => this.measure(f),
|
|
512
|
+
limit: this.ep.maxPayload,
|
|
513
|
+
});
|
|
514
|
+
for (const { frame, cursor: c } of frames)
|
|
515
|
+
await this.publish(frame, c);
|
|
516
|
+
return runId;
|
|
517
|
+
}
|
|
518
|
+
/** Measure a candidate frame EXACTLY as the wire will, at an upper bound over id and expectation. */
|
|
519
|
+
measure(frame) {
|
|
520
|
+
return this.ep.encodedSize({
|
|
521
|
+
channel: this.channel,
|
|
522
|
+
parts: [frame],
|
|
523
|
+
id: SIZING_ID,
|
|
524
|
+
expectedLastSubjectSeq: SIZING_EXPECTATION,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/** Transition 1 then the first network attempt. */
|
|
528
|
+
async publish(frame, cursor) {
|
|
529
|
+
// Advance the real machine by exactly this frame. It cannot throw: the identical sequence was
|
|
530
|
+
// already accepted by a clone starting from this same state, in this same order. It is not
|
|
531
|
+
// wrapped in a diagnosis for that reason — a throw here would be a bug in this file, not a
|
|
532
|
+
// stream problem, and dressing it as one would hide it.
|
|
533
|
+
for (const e of frame.events)
|
|
534
|
+
this.brackets.accept(e);
|
|
535
|
+
const brackets = this.brackets.snapshot();
|
|
536
|
+
const id = randomUUID();
|
|
537
|
+
// THE SUBJECT'S TIP, NOT THIS THREAD'S LAST ACK. The two were the same number until a second
|
|
538
|
+
// session of the same principal existed, and then they were not: the subject is per principal
|
|
539
|
+
// and the log is per thread, so a new thread's own `lastSubjectSeq` is 0 on a subject its
|
|
540
|
+
// predecessor already filled.
|
|
541
|
+
const E = this.wal.expectedTip;
|
|
542
|
+
const body = [frame];
|
|
543
|
+
// Durable BEFORE the wire. The order is the whole state machine: a crash between this line and
|
|
544
|
+
// the next is recoverable precisely because the id and `E` are already frozen on disk.
|
|
545
|
+
await this.wal.beginSend({ id, E, seq: frame.seq, sourceCursor: cursor, body, brackets });
|
|
546
|
+
await this.attempt({ id, E, body, retry: false });
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* One publish attempt — first or retry — with the FROZEN id and the FROZEN `E`. Never the tip.
|
|
550
|
+
*
|
|
551
|
+
* The three outcomes are not symmetric and the asymmetry is the design:
|
|
552
|
+
* - `!duplicate` → transition 2 then 3. Success becomes durable before the frontier moves.
|
|
553
|
+
* - `duplicate` → HALT. On a first attempt it means a body WE DID NOT WRITE holds our id, and
|
|
554
|
+
* folding its `ackSeq` would advance the frontier and the source cursor past events that were
|
|
555
|
+
* never published. On a retry it cannot happen on a single-replica stream at all, because such a
|
|
556
|
+
* stream evaluates the expectation before the dedup cache, so observing it proves the stream is
|
|
557
|
+
* not single-replica. Both are
|
|
558
|
+
* fail-loud, and neither is a case where guessing is better than stopping.
|
|
559
|
+
* - CAS loss → HALT. Someone else moved the tip on a subject only this principal may write, or
|
|
560
|
+
* the subject was purged. Uncertainty plus a moved tip is exactly what must not be guessed at.
|
|
561
|
+
*
|
|
562
|
+
* A NETWORK error is deliberately none of these: it leaves `pending` as `sent_unacked`, which is
|
|
563
|
+
* the state that means "we do not know", and the next boot retries the same frozen frame.
|
|
564
|
+
*/
|
|
565
|
+
async attempt(o) {
|
|
566
|
+
const egress = frozenBodyEgressVerdict(o.body);
|
|
567
|
+
if (egress === "forbidden-kind") {
|
|
568
|
+
throw this.halt("egress-policy", `event emitter for ${this.channel}: refusing to ${o.retry ? "republish a frozen" : "publish a"} ` +
|
|
569
|
+
`frame whose body carries TOOL_CALL_ARGS or TOOL_CALL_RESULT onto ${this.channel}. ` +
|
|
570
|
+
`That channel has a different read ACL from the one a mesh read tool ran on, and those ` +
|
|
571
|
+
`kinds republish tool inputs and outputs. The body is not rewritten: the WAL froze it at ` +
|
|
572
|
+
`beginSend, a retry must republish those bytes or not at all, and mutating them between ` +
|
|
573
|
+
`disk and wire would break the recovery machine. An upgrade across a pending pre-fix ` +
|
|
574
|
+
`frame therefore HALTS rather than leaks. Clear the pending frame only as an explicit ` +
|
|
575
|
+
`abandonment of this epoch.`);
|
|
576
|
+
}
|
|
577
|
+
if (egress === "unreadable") {
|
|
578
|
+
throw this.halt("egress-unreadable", `event emitter for ${this.channel}: refusing to ${o.retry ? "republish a frozen" : "publish a"} ` +
|
|
579
|
+
`frame whose event list this policy cannot read onto ${this.channel}. A frame-shaped body ` +
|
|
580
|
+
`whose \`events\` is absent, or is not an array, cannot be checked for TOOL_CALL_ARGS or ` +
|
|
581
|
+
`TOOL_CALL_RESULT, and a body whose \`events\` is a bare string carries its bytes exactly ` +
|
|
582
|
+
`where the check would have looked. The boundary is the wire and not what a renderer ` +
|
|
583
|
+
`folds, so an uninspectable body is withheld rather than published opaque onto a channel ` +
|
|
584
|
+
`with a different read ACL. \`aguiFrame\` enforces a non-empty events ARRAY at ` +
|
|
585
|
+
`construction, so no frame this version writes can land here; one that does was frozen by ` +
|
|
586
|
+
`something else. Clear the pending frame only as an explicit abandonment of this epoch.`);
|
|
587
|
+
}
|
|
588
|
+
if (egress === "run-error-content") {
|
|
589
|
+
throw this.halt("egress-run-error", `event emitter for ${this.channel}: refusing to ${o.retry ? "republish a frozen" : "publish a"} ` +
|
|
590
|
+
`frame whose RUN_ERROR carries upstream text onto ${this.channel}. Since #1431 a published ` +
|
|
591
|
+
`RUN_ERROR has the fixed message "${RUN_ERROR_EGRESS_MESSAGE}" and no code or rawEvent, because ` +
|
|
592
|
+
`the upstream error text can echo a prompt, a peer message or tool output and that channel ` +
|
|
593
|
+
`has a different read ACL. The body is not rewritten: the WAL froze it at beginSend, so an ` +
|
|
594
|
+
`upgrade across a pending pre-fix frame HALTS rather than leaks. Clear the pending frame ` +
|
|
595
|
+
`only as an explicit abandonment of this epoch.`);
|
|
596
|
+
}
|
|
597
|
+
if (egress === "extra-property") {
|
|
598
|
+
// Re-scan to name the path. This is the error path; the cost is negligible.
|
|
599
|
+
let path = "(unknown)";
|
|
600
|
+
for (const part of o.body) {
|
|
601
|
+
if (!isAguiFramePart(part))
|
|
602
|
+
continue;
|
|
603
|
+
const p = extraPropertyPath(part);
|
|
604
|
+
if (p !== undefined) {
|
|
605
|
+
path = p;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
throw this.halt("egress-extra-property", `event emitter for ${this.channel}: refusing to ${o.retry ? "republish a frozen" : "publish a"} ` +
|
|
610
|
+
`frame whose body carries an unknown property at \`${path}\` onto ${this.channel}. ` +
|
|
611
|
+
`The egress fence validates the whole envelope against a closed schema: known top-level ` +
|
|
612
|
+
`frame keys (kind, protocol, threadId, runId, epoch, seq, events) and known per-event ` +
|
|
613
|
+
`keys per type. A property outside that schema could carry tool output or other content ` +
|
|
614
|
+
`that bypasses the event-kind check. The body is not rewritten: the WAL froze it at ` +
|
|
615
|
+
`beginSend, and mutating it between disk and wire would break the recovery machine. ` +
|
|
616
|
+
`Clear the pending frame only as an explicit abandonment of this epoch.`);
|
|
617
|
+
}
|
|
618
|
+
let ack;
|
|
619
|
+
try {
|
|
620
|
+
({ ack } = await this.ep.multicastExpecting({
|
|
621
|
+
channel: this.channel,
|
|
622
|
+
parts: o.body,
|
|
623
|
+
id: o.id,
|
|
624
|
+
expectedLastSubjectSeq: o.E,
|
|
625
|
+
}));
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
if (isCasLoss(e))
|
|
629
|
+
throw this.halt("cas-loss", `event emitter for ${this.channel}: the subject tip is no longer ${o.E} (${e.message}). ` +
|
|
630
|
+
`The broker ACL confines this subject to one principal, so the tip moved for one of: a ` +
|
|
631
|
+
`CONCURRENT emitter under this same principal. The per-principal lock refuses a second ` +
|
|
632
|
+
`one, but the lock FILE lives under a workspace root, so an emitter started against a ` +
|
|
633
|
+
`DIFFERENT root, or by a path that never takes the lock, meets no lock at all. Another ` +
|
|
634
|
+
`host and a stale pid do not get past it; they refuse the start instead, loudly; a ` +
|
|
635
|
+
`subject frontier record that disagrees with the stream, ` +
|
|
636
|
+
`which is what an interrupted upgrade or a restored backup leaves behind; a RESTORED ` +
|
|
637
|
+
`stream; or a FILTERED PURGE, which returns the tip to 0 for every thread on the channel. ` +
|
|
638
|
+
`One more cause is not a second writer at all: this log's OWN last ack. The shared record ` +
|
|
639
|
+
`advances before the log records the ack, so a crash between those two writes leaves the ` +
|
|
640
|
+
`record ahead of the frozen expectation this frame carries, and the retry publishes a ` +
|
|
641
|
+
`sequence the subject has already passed. On disk it reads as a pending frame in state ` +
|
|
642
|
+
`sent_unacked whose E is BEHIND the record's tip, which a restored record can also look ` +
|
|
643
|
+
`like, so it narrows the search rather than ending it. ` +
|
|
644
|
+
`None of these is resolvable by re-reading the tip, which agent credentials cannot read in ` +
|
|
645
|
+
`any case. Clearing it is an explicit abandonment of epoch, seq, E, cursor and the shared ` +
|
|
646
|
+
`subject record together, and it is VALID ONLY ONCE THE SUBJECT IS ACTUALLY EMPTY, which ` +
|
|
647
|
+
`of the causes above is true of the FILTERED PURGE alone. On any other cause the tip is ` +
|
|
648
|
+
`still where it is, so removing this state does not clear the halt: the next session ` +
|
|
649
|
+
`opens virgin, expects 0, halts on the same tip, and the sibling logs a tip could have ` +
|
|
650
|
+
`been rebuilt from are gone. Purge the channel first, or find the second writer, or match the ` +
|
|
651
|
+
`signature above and stop looking for one. Once ` +
|
|
652
|
+
`the subject really is back to 0, no command performs the abandonment, so by hand it ` +
|
|
653
|
+
`means removing ${dirname(dirname(this.wal.path))} whole, and removing less than that ` +
|
|
654
|
+
`leaves a mixed state the next start refuses.`);
|
|
655
|
+
throw e;
|
|
656
|
+
}
|
|
657
|
+
if (ack.duplicate)
|
|
658
|
+
throw this.halt("duplicate-ack", `event emitter for ${this.channel}: the broker answered ${o.retry ? "a RETRY" : "a FIRST attempt"} ` +
|
|
659
|
+
`for id ${o.id} with duplicate:true. ` +
|
|
660
|
+
(o.retry
|
|
661
|
+
? `Under the SINGLE-REPLICA RETRY RULE this cannot happen on an R1 stream, which ` +
|
|
662
|
+
`evaluates the subject expectation ` +
|
|
663
|
+
`before the dedup cache — so either the stream is not R1 or a foreign body holds our ` +
|
|
664
|
+
`stream-wide id. `
|
|
665
|
+
: `We have never published this id, so a body we did not write holds it. `) +
|
|
666
|
+
`Folding this ack would advance the frontier and the source cursor past events that were ` +
|
|
667
|
+
`never published: silent loss of real events. The frontier and cursor are unchanged.`);
|
|
668
|
+
await this.wal.recordAck(ack.seq);
|
|
669
|
+
await this.wal.fold();
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Decide whether a bracket refusal is the WRITER's fault or OURS, and say which.
|
|
673
|
+
*
|
|
674
|
+
* Ours iff ALL THREE hold, and each is load-bearing:
|
|
675
|
+
* - this process has fed NO event through the machine yet, so the machine cannot have been put
|
|
676
|
+
* into a bad state by anything we did in this run; and
|
|
677
|
+
* - the frontier is non-virgin, so frames — and therefore possibly an open `RUN_STARTED` — were
|
|
678
|
+
* published by a PREVIOUS process; and
|
|
679
|
+
* - the WAL cannot say what was open. Since v2 the machine is PERSISTED, so an ordinary restart
|
|
680
|
+
* restores it and never reaches here at all; `null` means the document was migrated from v1 and
|
|
681
|
+
* genuinely never recorded the state. Without this condition the diagnosis would survive as a
|
|
682
|
+
* permanent excuse for a case the migration fixed.
|
|
683
|
+
*
|
|
684
|
+
* Drop the first condition and a genuine mid-stream violation by the writer gets blamed on a
|
|
685
|
+
* restart that happened an hour ago. Drop the second and a violation on a virgin thread, where
|
|
686
|
+
* nothing was ever published and nothing could have been lost, gets blamed on a restart that never
|
|
687
|
+
* happened. Each condition alone produces a confident, wrong diagnosis — which is worse than the
|
|
688
|
+
* undiagnosed error it replaced, because a named cause stops the search.
|
|
689
|
+
*/
|
|
690
|
+
diagnoseBracket(err) {
|
|
691
|
+
if (this.fedAnyEvent || this.wal.frontier.seq === 0 || this.wal.brackets !== null)
|
|
692
|
+
return err;
|
|
693
|
+
return new AguiBracketStateLost(`event emitter for ${this.channel}: bracket state was LOST ACROSS A RESTART — this is not a ` +
|
|
694
|
+
`protocol violation by the writer. This process has emitted nothing yet, but the WAL says ` +
|
|
695
|
+
`frame ${this.wal.frontier.seq} already went out, and the document records NO bracket state ` +
|
|
696
|
+
`(it was migrated from v1, which never stored one), so any run or message the previous ` +
|
|
697
|
+
`process left open is invisible to this one. Resuming from the source cursor therefore lands ` +
|
|
698
|
+
`mid-run and the first event is refused. A WAL written by this build persists the machine and ` +
|
|
699
|
+
`does not reach this path. The underlying refusal was: ${err.message}`, err);
|
|
700
|
+
}
|
|
701
|
+
halt(reason, message) {
|
|
702
|
+
this.halted = new AguiEmitterHalted(reason, message);
|
|
703
|
+
return this.halted;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
//# sourceMappingURL=agui-emitter.js.map
|