@cotal-ai/runtime 0.0.1 → 0.24.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/fork.d.ts +144 -0
- package/dist/fork.d.ts.map +1 -0
- package/dist/fork.js +310 -0
- package/dist/fork.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/journal-store.d.ts +54 -0
- package/dist/journal-store.d.ts.map +1 -0
- package/dist/journal-store.js +60 -0
- package/dist/journal-store.js.map +1 -0
- package/dist/mesh-handler.d.ts +310 -0
- package/dist/mesh-handler.d.ts.map +1 -0
- package/dist/mesh-handler.js +731 -0
- package/dist/mesh-handler.js.map +1 -0
- package/dist/migrate.d.ts +147 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +256 -0
- package/dist/migrate.js.map +1 -0
- package/dist/resolve-checkpoint.d.ts +75 -0
- package/dist/resolve-checkpoint.d.ts.map +1 -0
- package/dist/resolve-checkpoint.js +108 -0
- package/dist/resolve-checkpoint.js.map +1 -0
- package/dist/run-context.d.ts +42 -0
- package/dist/run-context.d.ts.map +1 -0
- package/dist/run-context.js +55 -0
- package/dist/run-context.js.map +1 -0
- package/dist/run-driver.d.ts +124 -0
- package/dist/run-driver.d.ts.map +1 -0
- package/dist/run-driver.js +228 -0
- package/dist/run-driver.js.map +1 -0
- package/package.json +31 -16
- package/README.md +0 -6
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mesh handler: `packages/lang`'s `EffectHandler` over the real planes.
|
|
3
|
+
*
|
|
4
|
+
* The language performs effects through an interface and knows nothing about NATS. The simulator
|
|
5
|
+
* implements that interface with scripted answers, which is what lets a program be tested without a
|
|
6
|
+
* broker. This is the other implementation — the one where an effect actually happens — and it is
|
|
7
|
+
* deliberately thin: everything hard about durability already lives in the planes it calls.
|
|
8
|
+
*
|
|
9
|
+
* **The request id IS the durable token.** `ctx.requestId` is `base64url(sha256(runId, stepKey,
|
|
10
|
+
* inputHash, attempt))`, written onto the pending journal entry BEFORE the handler is called, and
|
|
11
|
+
* it is a valid `<token>` by construction — same alphabet, 43 characters. So a crashed run that
|
|
12
|
+
* resumes re-derives the same token and ATTACHES to the pause the crashed attempt recorded instead
|
|
13
|
+
* of opening a second one. Nothing here remembers anything across a crash, because the identity was
|
|
14
|
+
* recorded before the work started and the pause itself holds the rest: `mintCheckpoint` is
|
|
15
|
+
* idempotent only if the ENTIRE spec is identical, and the deadline in it cannot be recomputed from
|
|
16
|
+
* a clock that has moved, so `arm()` reads the recorded spec rather than doing the arithmetic
|
|
17
|
+
* again.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { mintCheckpoint, heartbeatCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointAnswer, readCheckpointStatus, readCheckpointSpec, reconcileCheckpointSchedule, handleCheckpointFire, checkpointSettleSubject, epfStreamName, eptStreamName, eptSubject, chatStream, chatSubject, isConcreteChannel, assertSafePattern, runNoticeId, writeRunNotice, } from "@cotal-ai/core";
|
|
21
|
+
import { parseDuration, journalEntryKeyString, stepKeyString, } from "@cotal-ai/lang";
|
|
22
|
+
/**
|
|
23
|
+
* A checkpoint resumed with no answer to read.
|
|
24
|
+
*
|
|
25
|
+
* The settle fact is the arbiter and it NAMES the answer it accepted, so a `resumed` settlement
|
|
26
|
+
* with no id — or with an id no record answers to — means the token was presented by something
|
|
27
|
+
* other than the run driver's own `resolveCheckpoint`. There is no honest value to return for it:
|
|
28
|
+
* the program asked a question, something released the pause, and what was answered is not
|
|
29
|
+
* recoverable. Returning `resolved` with an empty value would invent one.
|
|
30
|
+
*/
|
|
31
|
+
export class CheckpointAnswerMissing extends Error {
|
|
32
|
+
token;
|
|
33
|
+
answerId;
|
|
34
|
+
constructor(token, answerId) {
|
|
35
|
+
super(`checkpoint "${token}" settled as resumed but ${answerId === undefined
|
|
36
|
+
? "its settle fact names no answer"
|
|
37
|
+
: `no answer record exists under the id ${answerId} it names`}; ` +
|
|
38
|
+
`a workflow checkpoint is answered through the run driver's resolveCheckpoint, which writes the answer BEFORE presenting the token`);
|
|
39
|
+
this.token = token;
|
|
40
|
+
this.answerId = answerId;
|
|
41
|
+
this.name = "CheckpointAnswerMissing";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The one subject the whole seam is gated by: every refused effect addresses an agent handle, and
|
|
46
|
+
* only `spawn` produces one. Named once so the five refusals cannot drift into five reasons.
|
|
47
|
+
*/
|
|
48
|
+
const ACTION_MACHINERY = "the durable-action machinery an agent handle comes from";
|
|
49
|
+
export class MeshHandler {
|
|
50
|
+
kv;
|
|
51
|
+
js;
|
|
52
|
+
jsm;
|
|
53
|
+
binding;
|
|
54
|
+
watcher;
|
|
55
|
+
clock;
|
|
56
|
+
constructor(kv, js, jsm, binding, watcher, clock = () => Date.now()) {
|
|
57
|
+
this.kv = kv;
|
|
58
|
+
this.js = js;
|
|
59
|
+
this.jsm = jsm;
|
|
60
|
+
this.binding = binding;
|
|
61
|
+
this.watcher = watcher;
|
|
62
|
+
this.clock = clock;
|
|
63
|
+
}
|
|
64
|
+
now() {
|
|
65
|
+
return this.clock();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* WHAT to repair when this process takes a run over. The driver decides WHEN and calls this.
|
|
69
|
+
*
|
|
70
|
+
* It is a method rather than an optional hook the host wires: an adopted run whose timers stay
|
|
71
|
+
* armed at the predecessor's coordinates fires where nobody listens, and the object that knows how
|
|
72
|
+
* to re-arm is the one the driver already holds.
|
|
73
|
+
*
|
|
74
|
+
* Failures are raised, not swallowed: a driver holding a run whose pauses it could not re-arm
|
|
75
|
+
* cannot advance it.
|
|
76
|
+
*/
|
|
77
|
+
async adopted(entries) {
|
|
78
|
+
return await rearmOutstandingPauses({ kv: this.kv, js: this.js, jsm: this.jsm }, this.binding, entries);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `sleep` is a checkpoint nobody answers.
|
|
82
|
+
*
|
|
83
|
+
* There is no separate timer plane and there should not be one: a durable pause with a deadline,
|
|
84
|
+
* a token that survives a crash, and a one-use settle is exactly what the checkpoint plane is,
|
|
85
|
+
* and a second mechanism would be a second thing to get wrong. The difference is only that no
|
|
86
|
+
* `resolveCheckpoint` will ever arrive for this token, so the timer's expiry is the whole story
|
|
87
|
+
* and `null` is the whole answer.
|
|
88
|
+
*/
|
|
89
|
+
async sleep(req, ctx) {
|
|
90
|
+
const ref = { endpoint: this.binding.endpoint, token: ctx.requestId };
|
|
91
|
+
const now = this.now();
|
|
92
|
+
const deadline = now + parseDuration(req.duration);
|
|
93
|
+
// The record is durable BEFORE the timer exists, and the MINT is what asks for the timer —
|
|
94
|
+
// this handler never emits its own schedule request. That is not tidiness: mint re-emits at
|
|
95
|
+
// the status's CURRENT authoritative generation, so a replay repairs the crash-before-arm
|
|
96
|
+
// window without rolling a heartbeat-advanced deadline back to the one this caller computed.
|
|
97
|
+
// A request emitted from here would carry the caller's coordinates and could arm a stale
|
|
98
|
+
// generation the writer would then have to be trusted to ignore.
|
|
99
|
+
await this.arm(ref, deadline);
|
|
100
|
+
await this.settle(ref);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* `checkpoint` is the same durable pause with somebody expected to answer it.
|
|
105
|
+
*
|
|
106
|
+
* The mint is identical to `sleep`'s — one token, one deadline, one settle fact — and everything
|
|
107
|
+
* that differs is on the ANSWER side, which is deliberately not this handler's to arrange: the
|
|
108
|
+
* run driver's `resolveCheckpoint` writes the answer record and presents the token, because the
|
|
109
|
+
* checkpoint's holder is the driver and a resume is holder-bound. So this waits, and then reads
|
|
110
|
+
* what the arbiter says was accepted.
|
|
111
|
+
*
|
|
112
|
+
* It returns the RAW outcome and never the program's result. Whether an expiry throws or returns
|
|
113
|
+
* is `onExpiry`, which is computed from today's source on the live path and the replay path
|
|
114
|
+
* alike; deciding it here would bake one answer into the journal.
|
|
115
|
+
*/
|
|
116
|
+
async checkpoint(req, ctx) {
|
|
117
|
+
const ref = { endpoint: this.binding.endpoint, token: ctx.requestId };
|
|
118
|
+
const now = this.now();
|
|
119
|
+
const deadline = now + parseDuration(req.timeout ?? this.binding.defaultCheckpointTimeout);
|
|
120
|
+
await this.arm(ref, deadline);
|
|
121
|
+
const settled = await this.settle(ref);
|
|
122
|
+
if (settled.settle === "expired")
|
|
123
|
+
return { outcome: "expired", at: settled.ts };
|
|
124
|
+
// The settle NAMES its answer, and the record is read under that name rather than by looking
|
|
125
|
+
// for "the answer to this token": two resolvers can have filed answers and only one of them
|
|
126
|
+
// was accepted, so an answer found by token alone could be the loser's.
|
|
127
|
+
const answer = settled.answerId === undefined
|
|
128
|
+
? undefined
|
|
129
|
+
: await readCheckpointAnswer(this.kv, this.binding.endpoint, ref.token, settled.answerId);
|
|
130
|
+
if (answer === undefined)
|
|
131
|
+
throw new CheckpointAnswerMissing(ref.token, settled.answerId);
|
|
132
|
+
return {
|
|
133
|
+
outcome: "resolved",
|
|
134
|
+
...(answer.value !== undefined ? { value: answer.value } : {}),
|
|
135
|
+
...(answer.artifact !== undefined ? { artifact: answer.artifact } : {}),
|
|
136
|
+
by: answer.by,
|
|
137
|
+
answerId: answer.answerId,
|
|
138
|
+
at: settled.ts,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* `wait` — an event await that survives the process waiting for it.
|
|
143
|
+
*
|
|
144
|
+
* A DURABLE consumer named from `ctx.requestId` holds the run's position on the channel, so an
|
|
145
|
+
* event published while the host was down is still there when the run re-attaches under the same
|
|
146
|
+
* derived name. An ephemeral consumer, or one created on resume, starts from "now" and the event
|
|
147
|
+
* never happened.
|
|
148
|
+
*
|
|
149
|
+
* The TIMEOUT rides the checkpoint plane and is minted once with an absolute deadline, so a
|
|
150
|
+
* resumed 20-minute wait with 30 seconds left has 30 seconds left. A timeout resolves `null` and
|
|
151
|
+
* never throws: `??` is `otherwise`.
|
|
152
|
+
*
|
|
153
|
+
* `replied(agent)` and `down(agent)` are not here. They address an agent handle, which only
|
|
154
|
+
* `spawn` produces, so they refuse through the same named seam as the durable actions.
|
|
155
|
+
*/
|
|
156
|
+
async wait(req, ctx) {
|
|
157
|
+
const ev = req.event;
|
|
158
|
+
if (ev.event === "replied" || ev.event === "down") {
|
|
159
|
+
throw new NotYetDurable(`wait(${ev.event}(…))`, ACTION_MACHINERY);
|
|
160
|
+
}
|
|
161
|
+
if (!isConcreteChannel(ev.channel)) {
|
|
162
|
+
throw new Error(`wait() cannot await a wildcard channel ("${ev.channel}"); an await names one channel`);
|
|
163
|
+
}
|
|
164
|
+
// A recorded seq is a previous attempt's MATCH, taken before the crash. Return that message
|
|
165
|
+
// rather than looking again: the consumer has already acked it, so looking again would wait for
|
|
166
|
+
// a second event the program never asked for.
|
|
167
|
+
const bound = ctx.resume?.chatSeq;
|
|
168
|
+
if (typeof bound === "number")
|
|
169
|
+
return await this.messageAt(bound);
|
|
170
|
+
const timeoutAt = req.timeout === undefined ? undefined : this.now() + parseDuration(req.timeout);
|
|
171
|
+
const idleFor = ev.event === "idle" ? parseDuration(ev.duration) : undefined;
|
|
172
|
+
const matcher = ev.event === "message" && ev.matches !== undefined ? compileMatch(ev.matches) : undefined;
|
|
173
|
+
const from = ev.event === "message" ? ev.from : undefined;
|
|
174
|
+
// ONE token per deadline, both derived, so a resume re-derives them instead of remembering.
|
|
175
|
+
// The step's own id is the deadline that DEFINES the wait — the idle window where there is one,
|
|
176
|
+
// the timeout otherwise — and a second, derived id carries an idle wait's outer timeout.
|
|
177
|
+
const primary = idleFor !== undefined || timeoutAt !== undefined
|
|
178
|
+
? { endpoint: this.binding.endpoint, token: ctx.requestId }
|
|
179
|
+
: undefined;
|
|
180
|
+
const outer = idleFor !== undefined && timeoutAt !== undefined
|
|
181
|
+
? { endpoint: this.binding.endpoint, token: derivedToken(ctx.requestId, "wait-timeout") }
|
|
182
|
+
: undefined;
|
|
183
|
+
if (primary !== undefined) {
|
|
184
|
+
await this.arm(primary, idleFor !== undefined ? this.now() + idleFor : timeoutAt);
|
|
185
|
+
}
|
|
186
|
+
if (outer !== undefined)
|
|
187
|
+
await this.arm(outer, timeoutAt);
|
|
188
|
+
const durable = waitConsumerName(ctx.requestId);
|
|
189
|
+
const stream = chatStream(this.binding.space);
|
|
190
|
+
await this.jsm.consumers.add(stream, waitConsumerConfig(this.binding.space, ctx.requestId, ev.channel));
|
|
191
|
+
const consumer = await this.js.consumers.get(stream, durable);
|
|
192
|
+
// Set on each of the three paths that END the wait, and read by the cleanup below. A THROW is
|
|
193
|
+
// not one of them — see the note there.
|
|
194
|
+
let over = false;
|
|
195
|
+
try {
|
|
196
|
+
for (;;) {
|
|
197
|
+
// The deadline is durable and authoritative — a checkpoint's settle fact — and this is only
|
|
198
|
+
// the OBSERVATION of it, so the cost of polling is lateness bounded by one poll rather than
|
|
199
|
+
// a wait that outlives its deadline.
|
|
200
|
+
const ended = await this.expired(outer ?? (idleFor === undefined ? primary : undefined));
|
|
201
|
+
if (ended !== undefined) {
|
|
202
|
+
over = true;
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (idleFor !== undefined && (await this.expired(primary)) !== undefined) {
|
|
206
|
+
over = true;
|
|
207
|
+
return { channel: ev.channel, at: this.now() };
|
|
208
|
+
}
|
|
209
|
+
for await (const m of await consumer.fetch({ max_messages: 16, expires: WAIT_POLL_MS })) {
|
|
210
|
+
const msg = decodeMessage(m.data);
|
|
211
|
+
if (idleFor !== undefined) {
|
|
212
|
+
// ANY traffic resets an idle window, matched or not: "idle" is a fact about the
|
|
213
|
+
// channel, not about the messages this program finds interesting.
|
|
214
|
+
m.ack();
|
|
215
|
+
await this.push(primary, this.now() + idleFor);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (msg === undefined || !matchesEvent(msg, from, matcher)) {
|
|
219
|
+
m.ack();
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// BIND BEFORE ACK. The bind is durable; the ack is what makes the message unrecoverable.
|
|
223
|
+
// In this order a crash in between redelivers it, and a crash after it is answered from
|
|
224
|
+
// the recorded sequence — in the other order the match is simply lost.
|
|
225
|
+
await ctx.bind({ chatSeq: m.seq });
|
|
226
|
+
m.ack();
|
|
227
|
+
if (primary !== undefined)
|
|
228
|
+
await this.cancelTimer(primary);
|
|
229
|
+
if (outer !== undefined)
|
|
230
|
+
await this.cancelTimer(outer);
|
|
231
|
+
over = true;
|
|
232
|
+
return msg;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
// A THROW IS NOT AN ENDING. The three returns above are the wait being over and its position
|
|
238
|
+
// worthless; a throw leaves the step pending, and the consumer's position is the only record
|
|
239
|
+
// of where this run reached on the channel. `ctx.bind` is a journal append and a journal can
|
|
240
|
+
// refuse one (L5010, RunSuperseded), so a throw here is ordinary operation and not only a bug.
|
|
241
|
+
// Keeping the consumer costs one durable on an abandoned run, which is what a host crash
|
|
242
|
+
// already costs; reaping on inactivity instead could delete a live wait's position while its
|
|
243
|
+
// host was down.
|
|
244
|
+
if (over) {
|
|
245
|
+
try {
|
|
246
|
+
await this.jsm.consumers.delete(stream, durable);
|
|
247
|
+
}
|
|
248
|
+
catch { /* already gone, or never created — either way there is nothing to hold */ }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* `notify` writes one bounded decision record per addressee, onto the run.
|
|
254
|
+
*
|
|
255
|
+
* NOT a channel post, and that is the whole point of the primitive: a post would put the program
|
|
256
|
+
* into the conversation as a participant, where conversation is the data plane and
|
|
257
|
+
* the program is the control plane. A notice is data filed on the run and rendered ahead of the
|
|
258
|
+
* addressee's next turn.
|
|
259
|
+
*
|
|
260
|
+
* **One call to N agents is N records, and a retry lands on its own.** The id is derived from the
|
|
261
|
+
* step's request id and the addressee, so a crash between the second and third write is repaired
|
|
262
|
+
* by re-running the call: the first two creates find their own bytes and return, the third
|
|
263
|
+
* happens. Nothing is written twice and nothing needs a memo of how far it got.
|
|
264
|
+
*
|
|
265
|
+
* The fact's bound is the language's and is enforced BEFORE this is reached (L3043 at the effect
|
|
266
|
+
* boundary), so a fact that could not be rendered as one table row cannot arrive here.
|
|
267
|
+
*/
|
|
268
|
+
async notify(req, ctx) {
|
|
269
|
+
const at = this.now();
|
|
270
|
+
const step = stepKeyString(ctx.key);
|
|
271
|
+
for (const agent of req.agents) {
|
|
272
|
+
const noticeId = runNoticeId(ctx.requestId, agent.agent);
|
|
273
|
+
await writeRunNotice(this.kv, this.binding.endpoint, noticeId, {
|
|
274
|
+
v: 1,
|
|
275
|
+
run: this.binding.runId,
|
|
276
|
+
step,
|
|
277
|
+
addressee: agent.agent,
|
|
278
|
+
fact: req.fact,
|
|
279
|
+
at,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
// ── The Lane-A seam ────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
//
|
|
286
|
+
// Every effect below addresses an AGENT HANDLE, and only `spawn` produces one. So the whole group
|
|
287
|
+
// is gated by a single subject — the durable-action machinery `spawn` rides — rather than by five
|
|
288
|
+
// separate absences, which is why they refuse through one class with one reason.
|
|
289
|
+
//
|
|
290
|
+
// THEY ARE HERE RATHER THAN ABSENT, and that is the point of the slice. A handler that simply
|
|
291
|
+
// lacks the method fails as a TypeError from inside the interpreter: a fault about JavaScript
|
|
292
|
+
// rather than about the run, at a call site that says nothing about what is missing or when it
|
|
293
|
+
// arrives. The refusal is the honest two-exit — the simulator performs all five, so a program
|
|
294
|
+
// using them can be written, validated and dry-run today, and a DURABLE run declines rather than
|
|
295
|
+
// performing an effect it could not recover after a crash.
|
|
296
|
+
//
|
|
297
|
+
// THE REFUSAL IS TERMINAL FOR THE RUN THAT HITS IT, and the mechanism is worth stating exactly
|
|
298
|
+
// rather than assumed. The interpreter settles the entry `failed` with the code the handler
|
|
299
|
+
// raised, so the step is recorded as attempted-and-failed and a resume replays that failure — the
|
|
300
|
+
// run does not heal the day the durable-action surface lands. It is recorded as L5016 rather than
|
|
301
|
+
// a generic handler fault so the journal at least says which of the two happened.
|
|
302
|
+
//
|
|
303
|
+
// Whether it SHOULD be terminal is a live question and not this file's to settle: an effect a
|
|
304
|
+
// host cannot perform is closer to a release than to a failure, and leaving the entry pending
|
|
305
|
+
// would let a later driver perform it. That changes the interpreter's fault contract and a
|
|
306
|
+
// property other lanes have been told about, so it is referred up rather than taken here.
|
|
307
|
+
async spawn(_req, _ctx) {
|
|
308
|
+
throw new NotYetDurable("spawn(…)", ACTION_MACHINERY);
|
|
309
|
+
}
|
|
310
|
+
async turn(_req, _ctx) {
|
|
311
|
+
throw new NotYetDurable("turn(…)", ACTION_MACHINERY);
|
|
312
|
+
}
|
|
313
|
+
async ask(_req, _ctx) {
|
|
314
|
+
throw new NotYetDurable("ask(…)", ACTION_MACHINERY);
|
|
315
|
+
}
|
|
316
|
+
async monitor(_req, _ctx) {
|
|
317
|
+
throw new NotYetDurable("monitor(…)", ACTION_MACHINERY);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* A conclave, refused with the rest — and this one is an OVER-refusal, stated rather than hidden.
|
|
321
|
+
*
|
|
322
|
+
* A conclave's members are agent handles, so the ordinary case is gated exactly like the others.
|
|
323
|
+
* `conclave([], …)` is not: a sub-team with nobody in it is a channel, and the channel plane is
|
|
324
|
+
* here. It is refused anyway, because shipping the empty case alone would put half a primitive on
|
|
325
|
+
* the durable plane — a program that works with no members and refuses with one is a worse thing
|
|
326
|
+
* to explain than a primitive that is not here yet.
|
|
327
|
+
*/
|
|
328
|
+
async openConclave(_req, _ctx) {
|
|
329
|
+
throw new NotYetDurable("conclave(…)", ACTION_MACHINERY);
|
|
330
|
+
}
|
|
331
|
+
async closeConclave(_req, _ctx) {
|
|
332
|
+
throw new NotYetDurable("conclave(…)", ACTION_MACHINERY);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Arm this pause, or ATTACH to the one already recorded under the same token.
|
|
336
|
+
*
|
|
337
|
+
* A mint is idempotent only if the whole spec is identical, so the recorded deadline is the
|
|
338
|
+
* authority and a resume may not recompute one: `now() + duration` is a different deadline a
|
|
339
|
+
* second later, and the plane reads a different deadline as a different intent. The pause holds
|
|
340
|
+
* it; a second copy anywhere else is a second thing to disagree.
|
|
341
|
+
*
|
|
342
|
+
* An already-passed deadline cannot be minted at all, correctly, because a due pause is not being
|
|
343
|
+
* armed. It needs its schedule re-emitted at the status's current generation, which is the
|
|
344
|
+
* reconciler's job. A spec with no status and an elapsed deadline is unrepairable from here, so it
|
|
345
|
+
* is raised rather than waited on.
|
|
346
|
+
*/
|
|
347
|
+
async arm(ref, deadline) {
|
|
348
|
+
// Over already: an expiry or an answer landed while this host was away. Nothing to arm, and the
|
|
349
|
+
// caller reads the fact next.
|
|
350
|
+
if ((await readCheckpointSettle(this.jsm, this.binding.space, ref)) !== undefined)
|
|
351
|
+
return;
|
|
352
|
+
const prior = await readCheckpointSpec(this.kv, ref);
|
|
353
|
+
const now = this.now();
|
|
354
|
+
const at = prior?.initialDeadline ?? deadline;
|
|
355
|
+
if (at > now) {
|
|
356
|
+
await mintCheckpoint(this.kv, this.js, this.binding.space, {
|
|
357
|
+
ref,
|
|
358
|
+
instanceId: this.binding.instanceId,
|
|
359
|
+
epoch: this.binding.epoch,
|
|
360
|
+
holder: this.binding.holder,
|
|
361
|
+
deadline: at,
|
|
362
|
+
now,
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const status = await readCheckpointStatus(this.kv, ref);
|
|
367
|
+
if (status === undefined) {
|
|
368
|
+
throw new Error(`checkpoint "${ref.token}" carries a spec with no status and its recorded deadline `
|
|
369
|
+
+ `(${at}) has passed; a mint repairs the missing status only while the deadline is still `
|
|
370
|
+
+ `ahead, so this pause has to be reconciled on the plane before the run can go on`);
|
|
371
|
+
}
|
|
372
|
+
await reconcileCheckpointSchedule(this.kv, this.js, this.jsm, this.binding.space, {
|
|
373
|
+
ref,
|
|
374
|
+
instanceId: this.binding.instanceId,
|
|
375
|
+
epoch: this.binding.epoch,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/** Push a live deadline out — the idle window restarting. The heartbeat CAS-advances the
|
|
379
|
+
* generation before replacing the timer, so a fire from the old one no-ops rather than racing. */
|
|
380
|
+
async push(ref, deadline) {
|
|
381
|
+
await heartbeatCheckpoint(this.kv, this.js, this.jsm, this.binding.space, {
|
|
382
|
+
ref,
|
|
383
|
+
instanceId: this.binding.instanceId,
|
|
384
|
+
epoch: this.binding.epoch,
|
|
385
|
+
deadline,
|
|
386
|
+
now: this.now(),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Take the broker's `.fire` for this deadline, if it has published one, and let the checkpoint
|
|
391
|
+
* plane judge it. Answers whether the fire SETTLED the pause.
|
|
392
|
+
*
|
|
393
|
+
* A fire is a MESSAGE and a settlement is a FACT; `handleCheckpointFire` is what turns one into
|
|
394
|
+
* the other, and a pause nobody answers ends no other way.
|
|
395
|
+
*
|
|
396
|
+
* READ BY SUBJECT rather than by a subscription, because the fire is a record of a deadline
|
|
397
|
+
* passing and callers here are already polling: `last_by_subj` gives the current one whether it
|
|
398
|
+
* arrived a moment ago or while this host was down, and a resume lands in the second case.
|
|
399
|
+
*
|
|
400
|
+
* IDEMPOTENT BY THE PLANE rather than by bookkeeping here: a fire handed over twice finds the
|
|
401
|
+
* pause settled and declines. The one repeated verdict with a side effect is `re-armed` under
|
|
402
|
+
* owner-behind clock skew, and over-emission is idempotent at the timer writer.
|
|
403
|
+
*/
|
|
404
|
+
async takeFire(ref) {
|
|
405
|
+
const subject = eptSubject(this.binding.space, ref.endpoint, this.binding.instanceId, this.binding.epoch, ref.token, "fire");
|
|
406
|
+
const fired = await this.jsm.streams
|
|
407
|
+
.getMessage(eptStreamName(this.binding.space), { last_by_subj: subject })
|
|
408
|
+
.catch(() => null);
|
|
409
|
+
if (fired === null || fired === undefined)
|
|
410
|
+
return false;
|
|
411
|
+
const verdict = await handleCheckpointFire(this.kv, this.js, this.jsm, this.binding.space, {
|
|
412
|
+
ref,
|
|
413
|
+
instanceId: this.binding.instanceId,
|
|
414
|
+
epoch: this.binding.epoch,
|
|
415
|
+
msg: { subject, ...(fired.header !== undefined ? { headers: fired.header } : {}), data: fired.data },
|
|
416
|
+
now: this.now(),
|
|
417
|
+
});
|
|
418
|
+
return verdict.acted;
|
|
419
|
+
}
|
|
420
|
+
/** Has this deadline settled? `undefined` for "no deadline" and for "not yet".
|
|
421
|
+
*
|
|
422
|
+
* A `wait` observes its deadlines by polling this, so the fire is taken on the same poll: the
|
|
423
|
+
* settle fact a `wait` is reading for is one this process has to produce, and reading for it
|
|
424
|
+
* without ever producing it is how a `wait` with a timeout waited past its timeout forever. */
|
|
425
|
+
async expired(ref) {
|
|
426
|
+
if (ref === undefined)
|
|
427
|
+
return undefined;
|
|
428
|
+
const settled = await readCheckpointSettle(this.jsm, this.binding.space, ref);
|
|
429
|
+
if (settled !== undefined)
|
|
430
|
+
return settled;
|
|
431
|
+
if (!(await this.takeFire(ref)))
|
|
432
|
+
return undefined;
|
|
433
|
+
return await readCheckpointSettle(this.jsm, this.binding.space, ref);
|
|
434
|
+
}
|
|
435
|
+
/** End a deadline that is no longer waited on, by claiming its one-use settlement with no answer.
|
|
436
|
+
* A timer left armed would fire into a run that has moved on; claiming it is how the plane says
|
|
437
|
+
* "this pause is over" without a second mechanism for cancellation. */
|
|
438
|
+
async cancelTimer(ref) {
|
|
439
|
+
const st = await readCheckpointStatus(this.kv, ref);
|
|
440
|
+
if (st?.value.state !== "waiting")
|
|
441
|
+
return;
|
|
442
|
+
try {
|
|
443
|
+
await resumeCheckpoint(this.kv, this.js, this.jsm, this.binding.space, {
|
|
444
|
+
ref,
|
|
445
|
+
presenter: this.binding.holder,
|
|
446
|
+
now: this.now(),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
// It settled underneath us — the deadline won a race it was already allowed to win. The
|
|
451
|
+
// caller has its answer either way, and a cancelled timer is not a fact anyone reads.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** The message at a recorded stream sequence — the re-bind path after a crash. */
|
|
455
|
+
async messageAt(seq) {
|
|
456
|
+
const m = await this.jsm.streams.getMessage(chatStream(this.binding.space), { seq });
|
|
457
|
+
if (m === null || m === undefined) {
|
|
458
|
+
throw new Error(`the message this wait matched (sequence ${seq}) is no longer on the channel's stream; a recorded match cannot be re-read`);
|
|
459
|
+
}
|
|
460
|
+
const msg = decodeMessage(m.data);
|
|
461
|
+
if (msg === undefined)
|
|
462
|
+
throw new Error(`the message at sequence ${seq} did not decode; a recorded match cannot be re-read`);
|
|
463
|
+
return msg;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Wait for this token's one-use settlement.
|
|
467
|
+
*
|
|
468
|
+
* A settle that ALREADY landed is the ordinary case on a resume: the run crashed while paused and
|
|
469
|
+
* the timer fired, or somebody answered, without it. Reading before waiting is not an
|
|
470
|
+
* optimization, it is the difference between resuming and waiting forever for an event that is
|
|
471
|
+
* already in the past.
|
|
472
|
+
*/
|
|
473
|
+
async settle(ref) {
|
|
474
|
+
const already = await readCheckpointSettle(this.jsm, this.binding.space, ref);
|
|
475
|
+
if (already !== undefined)
|
|
476
|
+
return already;
|
|
477
|
+
// The watcher waits for the FACT. For a pause with an answer the fact arrives because somebody
|
|
478
|
+
// answered; for a pause nobody answers - a `sleep`, or a `checkpoint` whose timeout wins - it
|
|
479
|
+
// arrives only because this process took the fire and the plane wrote it. So the two run
|
|
480
|
+
// together for the life of one wait, and the pump ends when the wait does.
|
|
481
|
+
const wait = { over: false };
|
|
482
|
+
const pump = this.pumpFires(ref, wait);
|
|
483
|
+
try {
|
|
484
|
+
return await Promise.race([
|
|
485
|
+
this.watcher.awaitSettle(ref),
|
|
486
|
+
// A pump that ENDED is not an answer, only one that FAILED is: a failure means this process
|
|
487
|
+
// cannot expire the pause, so it is raised rather than absorbed and the step stays pending.
|
|
488
|
+
pump.then(() => new Promise(() => { })),
|
|
489
|
+
]);
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
wait.over = true;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/** Take this deadline's fire for as long as somebody is waiting on it. */
|
|
496
|
+
async pumpFires(ref, wait) {
|
|
497
|
+
while (!wait.over) {
|
|
498
|
+
await this.takeFire(ref);
|
|
499
|
+
// Unrefed: the loop is ended by the flag, not by this timer, and a wait that is already over
|
|
500
|
+
// must not hold the process open for one more poll on its way out.
|
|
501
|
+
await new Promise((r) => setTimeout(r, FIRE_POLL_MS).unref());
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/** The durable that holds one wait's position on a channel. Derived from the step's own request id,
|
|
506
|
+
* which is why a resumed run finds the consumer its earlier attempt created rather than starting
|
|
507
|
+
* again from "now" — and why nothing about the wait has to be remembered across a crash. */
|
|
508
|
+
export function waitConsumerName(requestId) {
|
|
509
|
+
return `wfw_${requestId}`;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* The one definition of a wait's consumer, shared by the handler and by anything that has to
|
|
513
|
+
* recreate it — so the name a resume looks for and the name a wait creates cannot drift apart.
|
|
514
|
+
*
|
|
515
|
+
* `deliver_policy: "new"` applies only to the FIRST create: an existing durable keeps its own
|
|
516
|
+
* position, which is exactly what a resume needs, and events from before the program asked are not
|
|
517
|
+
* this wait's to see.
|
|
518
|
+
*/
|
|
519
|
+
export function waitConsumerConfig(space, requestId, channel) {
|
|
520
|
+
return {
|
|
521
|
+
durable_name: waitConsumerName(requestId),
|
|
522
|
+
filter_subject: chatSubject(space, "*", "*", channel),
|
|
523
|
+
ack_policy: "explicit",
|
|
524
|
+
deliver_policy: "new",
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/** How long one poll of a wait's consumer blocks. The deadline itself is durable; this is only how
|
|
528
|
+
* late its observation can be, and a shorter poll buys latency at the cost of fetch traffic. */
|
|
529
|
+
const WAIT_POLL_MS = 2_000;
|
|
530
|
+
/** How often a pause that nobody will answer looks for the broker's fire. Same argument as
|
|
531
|
+
* `WAIT_POLL_MS`: the deadline is durable and this is only how late its observation can be. */
|
|
532
|
+
const FIRE_POLL_MS = 2_000;
|
|
533
|
+
/** A second deadline for one step, derived so a resume re-derives it instead of remembering it.
|
|
534
|
+
* Same shape and alphabet as a request id, so it is a valid `<token>` by construction. */
|
|
535
|
+
function derivedToken(requestId, purpose) {
|
|
536
|
+
return createHash("sha256").update(`${requestId}:${purpose}`, "utf8").digest("base64url").slice(0, 43);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* A `matches` pattern, admitted through the repo's bounded-regex subset before it is compiled.
|
|
540
|
+
*
|
|
541
|
+
* A workflow is other people's text and a channel can be busy, so an exponential pattern here is a
|
|
542
|
+
* run that stalls with nothing to show for it. The subset is the same one the schema profile
|
|
543
|
+
* admits, which means the same rule applies: a pattern is ANCHORED (`^…`), and an author who wants
|
|
544
|
+
* "somewhere in the message" writes `^.*…` themselves. Wrapping it for them was the alternative and
|
|
545
|
+
* it is worse — the wrapper turns patterns that are safe as written into refusals about a `.*` the
|
|
546
|
+
* author never typed.
|
|
547
|
+
*/
|
|
548
|
+
function compileMatch(pattern) {
|
|
549
|
+
try {
|
|
550
|
+
assertSafePattern(pattern, 256);
|
|
551
|
+
}
|
|
552
|
+
catch (e) {
|
|
553
|
+
throw new Error(`wait()'s \`matches\` is a bounded regular expression, anchored like every pattern in this repo: ${e.message}`);
|
|
554
|
+
}
|
|
555
|
+
return new RegExp(pattern);
|
|
556
|
+
}
|
|
557
|
+
function decodeMessage(data) {
|
|
558
|
+
try {
|
|
559
|
+
const v = JSON.parse(new TextDecoder().decode(data));
|
|
560
|
+
return v !== null && typeof v === "object" ? v : undefined;
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
// Someone else's malformed publish is not this run's failure. It does not match, and the wait
|
|
564
|
+
// goes on waiting — which is what would happen if the message had never been sent.
|
|
565
|
+
return undefined;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/** Does this message answer the await? `from` is matched on the SENDER'S NAME as the mesh records
|
|
569
|
+
* it, never on the subject: the subject carries a principal, and a program names an agent. */
|
|
570
|
+
function matchesEvent(msg, from, matcher) {
|
|
571
|
+
if (from !== undefined && msg.from?.name?.toLowerCase() !== from.toLowerCase())
|
|
572
|
+
return false;
|
|
573
|
+
if (matcher === undefined)
|
|
574
|
+
return true;
|
|
575
|
+
const text = (msg.parts ?? [])
|
|
576
|
+
.filter((p) => p.kind === "text" && typeof p.text === "string")
|
|
577
|
+
.map((p) => p.text)
|
|
578
|
+
.join("\n");
|
|
579
|
+
return matcher.test(text);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* An effect whose durable substrate has not landed on this host.
|
|
583
|
+
*
|
|
584
|
+
* An honest two-exit, and deliberately not a fake success: the simulator implements these, so a
|
|
585
|
+
* program that uses them can be written, validated and dry-run today — but a DURABLE run refuses
|
|
586
|
+
* rather than performing them on a plane that could not recover them. A run that "succeeded" at an
|
|
587
|
+
* effect nothing can replay would be a lie the journal then carries forever.
|
|
588
|
+
*/
|
|
589
|
+
export class NotYetDurable extends Error {
|
|
590
|
+
effect;
|
|
591
|
+
needs;
|
|
592
|
+
/**
|
|
593
|
+
* L5016, and it is load-bearing rather than decorative.
|
|
594
|
+
*
|
|
595
|
+
* The interpreter wraps a handler's fault into an `EffectError` and SETTLES the entry with it, so
|
|
596
|
+
* the class does not survive the boundary — a caller of `run()` sees an `EffectError`, and the
|
|
597
|
+
* journal keeps whatever code was on it. Without this the entry would read `L4000 handler-fault`:
|
|
598
|
+
* "the handler broke", written durably about a step nothing ever attempted. The interpreter
|
|
599
|
+
* honours an L-code a handler raises, so the recorded fact says what actually happened.
|
|
600
|
+
*/
|
|
601
|
+
code = "L5016";
|
|
602
|
+
constructor(effect, needs) {
|
|
603
|
+
super(`${effect} is not durable on this host yet: it rides ${needs}, which has not landed. ` +
|
|
604
|
+
`The simulator performs it, so the program can be tested and dry-run; a durable run refuses ` +
|
|
605
|
+
`rather than performing an effect it could not recover after a crash.`);
|
|
606
|
+
this.effect = effect;
|
|
607
|
+
this.needs = needs;
|
|
608
|
+
this.name = "NotYetDurable";
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* RE-ARM the timers of every pause this run is still holding, under THIS driver's coordinates.
|
|
613
|
+
*
|
|
614
|
+
* A checkpoint's armed schedule fires onto `ept.<space>.<e>.<instanceId>.<epoch>.<token>.fire`,
|
|
615
|
+
* derived from the coordinates of the instance that armed it. A run adopted by another host — or by
|
|
616
|
+
* the same host at a new epoch — therefore has live timers firing at a subject nobody is reading,
|
|
617
|
+
* and its pauses would sit until something else swept them. Nothing about that is repaired by
|
|
618
|
+
* resuming the program: the effect is a replayed pending step that goes straight back to waiting.
|
|
619
|
+
*
|
|
620
|
+
* So the driver re-emits a schedule request for each outstanding token at the CURRENT generation,
|
|
621
|
+
* which the timer writer arms onto this instance's own fire subject. Over-emission is harmless by
|
|
622
|
+
* construction (same `(timerId, generation)` re-derives the same `.armed`, a no-op replacement), so
|
|
623
|
+
* this is safe to run on every takeover and needs no record of what it did last time.
|
|
624
|
+
*
|
|
625
|
+
* Called AFTER the barrier activated, never before: arming timers for a run this process turned out
|
|
626
|
+
* not to hold would point another driver's fires at this one.
|
|
627
|
+
*/
|
|
628
|
+
export async function rearmOutstandingPauses(deps, binding, entries) {
|
|
629
|
+
const rearmed = [];
|
|
630
|
+
for (const token of outstandingPauseTokens(entries)) {
|
|
631
|
+
const r = await reconcileCheckpointSchedule(deps.kv, deps.js, deps.jsm, binding.space, {
|
|
632
|
+
ref: { endpoint: binding.endpoint, token },
|
|
633
|
+
instanceId: binding.instanceId,
|
|
634
|
+
epoch: binding.epoch,
|
|
635
|
+
});
|
|
636
|
+
if (r.reEmitted)
|
|
637
|
+
rearmed.push(token);
|
|
638
|
+
}
|
|
639
|
+
return rearmed;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* The checkpoint tokens a replayed prefix leaves open.
|
|
643
|
+
*
|
|
644
|
+
* An entry is open when its LAST record is `pending`, so the map is built in append order and the
|
|
645
|
+
* later record wins — a step that settled has a settled entry after its pending one, and reading
|
|
646
|
+
* only the first would re-arm timers for pauses that are already over.
|
|
647
|
+
*
|
|
648
|
+
* THE KINDS ARE THE THREE THAT ARM A TIMER, and `wait` is one of them. It mints no pause of its own
|
|
649
|
+
* so it does not look like one, but its idle window and its timeout are mediated deadlines exactly
|
|
650
|
+
* as `sleep`'s is, and a `wait` adopted at a new epoch would otherwise wait on a deadline no live
|
|
651
|
+
* epoch fires.
|
|
652
|
+
*
|
|
653
|
+
* An idle wait with a timeout arms TWO, and the second is DERIVED rather than recorded, so it is
|
|
654
|
+
* re-derived here for the same reason the live path derives it: a resume that had to remember it
|
|
655
|
+
* would be carrying state the key already determines. Emitting it for a wait that never minted one
|
|
656
|
+
* is harmless by construction — the reconciler reads the checkpoint's status first and re-emits
|
|
657
|
+
* nothing when there is none — and the alternative, reading the request shape back out of the
|
|
658
|
+
* entry to decide, would make the repair depend on a field a replay is not guaranteed to carry.
|
|
659
|
+
*/
|
|
660
|
+
export function outstandingPauseTokens(entries) {
|
|
661
|
+
const last = new Map();
|
|
662
|
+
for (const e of entries)
|
|
663
|
+
last.set(journalEntryKeyString(e), e);
|
|
664
|
+
const tokens = [];
|
|
665
|
+
for (const e of last.values()) {
|
|
666
|
+
if (e.state !== "pending" || e.requestId === undefined)
|
|
667
|
+
continue;
|
|
668
|
+
if (e.kind === "sleep" || e.kind === "checkpoint")
|
|
669
|
+
tokens.push(e.requestId);
|
|
670
|
+
else if (e.kind === "wait")
|
|
671
|
+
tokens.push(e.requestId, derivedToken(e.requestId, "wait-timeout"));
|
|
672
|
+
}
|
|
673
|
+
return tokens;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* The settle watcher over EPF, which is where the one-use settle fact lives.
|
|
677
|
+
*
|
|
678
|
+
* An EPHEMERAL consumer filtered to this token's settle subject, created for one wait and removed
|
|
679
|
+
* after it: the fact is written once and read once, so a durable would be a name to collide on and
|
|
680
|
+
* a thing to clean up, for a subscription that outlives nothing. `deliver_policy: all` because the
|
|
681
|
+
* fact may already be there — a settle is a record, not a notification, and a watcher that only saw
|
|
682
|
+
* new messages would wait forever for one that already happened.
|
|
683
|
+
*/
|
|
684
|
+
export class EpfSettleWatcher {
|
|
685
|
+
js;
|
|
686
|
+
jsm;
|
|
687
|
+
space;
|
|
688
|
+
pollMs;
|
|
689
|
+
constructor(js, jsm, space, pollMs = 30_000) {
|
|
690
|
+
this.js = js;
|
|
691
|
+
this.jsm = jsm;
|
|
692
|
+
this.space = space;
|
|
693
|
+
this.pollMs = pollMs;
|
|
694
|
+
}
|
|
695
|
+
async awaitSettle(ref) {
|
|
696
|
+
const stream = epfStreamName(this.space);
|
|
697
|
+
const filter = checkpointSettleSubject(this.space, ref);
|
|
698
|
+
const created = await this.jsm.consumers.add(stream, {
|
|
699
|
+
filter_subject: filter,
|
|
700
|
+
ack_policy: "explicit",
|
|
701
|
+
deliver_policy: "all",
|
|
702
|
+
inactive_threshold: 300_000 * 1_000_000,
|
|
703
|
+
});
|
|
704
|
+
const name = created.name;
|
|
705
|
+
try {
|
|
706
|
+
const consumer = await this.js.consumers.get(stream, name);
|
|
707
|
+
for (;;) {
|
|
708
|
+
const batch = await consumer.fetch({ max_messages: 1, expires: this.pollMs });
|
|
709
|
+
for await (const m of batch) {
|
|
710
|
+
m.ack();
|
|
711
|
+
const settled = await readCheckpointSettle(this.jsm, this.space, ref);
|
|
712
|
+
// Read the fact back through the plane's own parser rather than trusting these bytes: the
|
|
713
|
+
// subject is one-use, so whatever is on it IS the answer, and the parser is what says the
|
|
714
|
+
// answer is well formed.
|
|
715
|
+
if (settled !== undefined)
|
|
716
|
+
return settled;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
try {
|
|
722
|
+
await this.jsm.consumers.delete(stream, name);
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
// The consumer carries its own inactivity threshold, so a failed delete is reaped rather
|
|
726
|
+
// than leaked — the case `run-journal` had to learn the hard way.
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
//# sourceMappingURL=mesh-handler.js.map
|