@sagentlab/navarch-runtime 0.1.116 → 0.1.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -17
- package/bin/muse-mcp-bridge.cjs +3 -0
- package/dist/adapters/index.cjs +6 -1
- package/dist/adapters/muse.cjs +433 -0
- package/dist/agent-auth.cjs +2 -1
- package/dist/config.cjs +8 -1
- package/dist/conversation/capability.cjs +1 -1
- package/dist/exit-conditions.cjs +1 -1
- package/dist/muse-mcp-bridge.cjs +167 -0
- package/dist/session.cjs +30 -8
- package/node_modules/@muse-code/sdk/LICENSE +21 -0
- package/node_modules/@muse-code/sdk/README.md +88 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.d.ts +149 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.js +581 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.d.ts +188 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.js +496 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.d.ts +71 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.js +76 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.d.ts +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.js +293 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.d.ts +171 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.js +442 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.d.ts +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.js +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.d.ts +80 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.js +388 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.d.ts +139 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.js +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.d.ts +62 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.js +81 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.d.ts +309 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.js +654 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.d.ts +156 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.js +271 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.d.ts +72 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.js +183 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.d.ts +37 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.js +54 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.d.ts +110 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.js +169 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.d.ts +536 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.js +427 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.d.ts +68 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.js +89 -0
- package/node_modules/@muse-code/sdk/dist/src/index.d.ts +65 -0
- package/node_modules/@muse-code/sdk/dist/src/index.js +57 -0
- package/node_modules/@muse-code/sdk/dist/src/msp.d.ts +3380 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.d.ts +361 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.js +468 -0
- package/node_modules/@muse-code/sdk/package.json +47 -0
- package/package.json +8 -4
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an ephemeral host death discarded, remembered ACROSS the sessions one
|
|
3
|
+
* client opened (spec 14990 T030 obligation (c); tdd SS2.13.3b).
|
|
4
|
+
*
|
|
5
|
+
* SS2.13.3b's five-clause MUST includes "do not attempt to reattach" and "do
|
|
6
|
+
* not replay the session's `commandId`s". Both are statements about what the
|
|
7
|
+
* CLIENT does next, and "next" outlives the session object that died:
|
|
8
|
+
*
|
|
9
|
+
* - `PendingCommandSet` already refuses a replay of an id IT discarded, but
|
|
10
|
+
* its memory is per-instance, so a FRESH `Session` — the natural thing to
|
|
11
|
+
* build after a host dies — knew nothing and would replay those ids at the
|
|
12
|
+
* new host. That is the exactly-once violation the clause exists to prevent,
|
|
13
|
+
* and it is why T030b's own arm could only name the gap;
|
|
14
|
+
* - nothing at all held the discarded `sessionId`, so `resumeSession` had no
|
|
15
|
+
* fact to withhold on.
|
|
16
|
+
*
|
|
17
|
+
* This is client state, never a wire shape and never durable (INV-007): it
|
|
18
|
+
* lives exactly as long as the `MuseClient` that owns it. It is deliberately
|
|
19
|
+
* NOT a module-global — two clients in one process are two independent trust
|
|
20
|
+
* boundaries, and a process-wide set would let one client's dead host silence
|
|
21
|
+
* another's live one.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The two sets, exposed directly rather than behind add/has pairs.
|
|
25
|
+
*
|
|
26
|
+
* `PendingCommandSet` takes the `commandIds` set BY REFERENCE and writes to it
|
|
27
|
+
* from `discardEphemeral()`, so a wrapper would have to re-expose a mutable
|
|
28
|
+
* seam anyway; a `Set<string>` is the honest shape for one, and it is already
|
|
29
|
+
* the shape that class used internally.
|
|
30
|
+
*/
|
|
31
|
+
export class DiscardedSessions {
|
|
32
|
+
/** `commandId`s an ephemeral discharge retired. Never replayed again. */
|
|
33
|
+
commandIds = new Set();
|
|
34
|
+
/** `sessionId`s whose ephemeral host died. Never reattached. */
|
|
35
|
+
sessionIds = new Set();
|
|
36
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SS4.8 gap recovery by splice-fill (spec 14990 FR-020 / T032, tdd SS4.8).
|
|
3
|
+
*
|
|
4
|
+
* Split out of `session.ts` for the same reason `approval.ts` and
|
|
5
|
+
* `turn-submit.ts` were: this is a delivery-plane concern with its own buffer,
|
|
6
|
+
* page walk, and failure vocabulary, and `Session` was already the package's
|
|
7
|
+
* largest module.
|
|
8
|
+
*
|
|
9
|
+
* THE RECIPE, verbatim from tdd SS4.8 and unchanged here: buffer live events
|
|
10
|
+
* at cursors at or after `next` as they arrive; page forward from `after` with
|
|
11
|
+
* `view/page` until the walk reaches `next`; discard the paged events the
|
|
12
|
+
* buffer already holds; splice the buffer after the paged prefix. D-030's
|
|
13
|
+
* second sanctioned path (drop state and re-anchor at the latest compaction
|
|
14
|
+
* snapshot) is NOT built here — it is the #208 lane's spec work, and building
|
|
15
|
+
* both would be a second concrete path for one current use.
|
|
16
|
+
*
|
|
17
|
+
* CURSORS STAY OPAQUE (tdd SS4.1). Nothing below orders two cursors: "at or
|
|
18
|
+
* after `next`" is delivery ORDER, not a comparison — every live frame that
|
|
19
|
+
* arrives after the gap marker is, by the server's own delivery contract, at
|
|
20
|
+
* or after `next`. The walk stops on cursor EQUALITY with the target or on the
|
|
21
|
+
* server's own end-of-view, and the overlap is discarded by set membership.
|
|
22
|
+
*/
|
|
23
|
+
import { MuseGapFillError } from "../errors.js";
|
|
24
|
+
import type { Connection } from "../connection/connection.js";
|
|
25
|
+
import type { SessionFold } from "../fold/session-fold.js";
|
|
26
|
+
import type { PendingRetirement } from "../pending/pending-command-set.js";
|
|
27
|
+
/** One wire frame, exactly as `Session.apply`'s wide overload accepts it. */
|
|
28
|
+
export interface WireFrame {
|
|
29
|
+
readonly method: string;
|
|
30
|
+
readonly params?: unknown;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Where a drained frame's side effects go. The filler re-feeds frames through
|
|
34
|
+
* `Session`'s own fold-and-route path, so the retirements and the client→server
|
|
35
|
+
* I/O they trigger belong to `Session`, not here.
|
|
36
|
+
*/
|
|
37
|
+
export interface DrainSink<I> {
|
|
38
|
+
readonly retirements: PendingRetirement<I>[];
|
|
39
|
+
readonly tasks: Promise<readonly PendingRetirement<I>[]>[];
|
|
40
|
+
}
|
|
41
|
+
export interface GapFillerOptions<I> {
|
|
42
|
+
readonly sessionId: string;
|
|
43
|
+
/** Absent on a fold-only `Session`: there is nothing to page through. */
|
|
44
|
+
readonly connection: Connection | undefined;
|
|
45
|
+
readonly fold: SessionFold;
|
|
46
|
+
/** Fold and route ONE frame, collecting what it produced. */
|
|
47
|
+
readonly apply: (frame: WireFrame, into: DrainSink<I>) => void;
|
|
48
|
+
/** SS2.13.3b: a discharged session folds nothing, fill or no fill. */
|
|
49
|
+
readonly discarded: () => boolean;
|
|
50
|
+
}
|
|
51
|
+
/** Reported, never thrown — see {@link MuseGapFillError}. */
|
|
52
|
+
export type GapFillFailureHandler = (failure: MuseGapFillError) => void;
|
|
53
|
+
export declare class GapFiller<I> {
|
|
54
|
+
#private;
|
|
55
|
+
constructor(options: GapFillerOptions<I>);
|
|
56
|
+
/** While true, every live frame but the gap marker itself is held. */
|
|
57
|
+
get filling(): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Was this frame already applied from a page, as part of the overlap the
|
|
60
|
+
* splice discards? Consuming: a twin is refused exactly once.
|
|
61
|
+
*
|
|
62
|
+
* Returns the matched CURSOR rather than a boolean so the caller can report
|
|
63
|
+
* which event it refused without re-reading (and re-defaulting) a field this
|
|
64
|
+
* method already proved is there.
|
|
65
|
+
*/
|
|
66
|
+
claimServedTwin(frame: WireFrame): string | undefined;
|
|
67
|
+
/** Observe fills that did not complete. See {@link MuseGapFillError}. */
|
|
68
|
+
onError(handler: GapFillFailureHandler): void;
|
|
69
|
+
/** Hold one live frame until the paged prefix is in. */
|
|
70
|
+
hold(frame: WireFrame): void;
|
|
71
|
+
/**
|
|
72
|
+
* Recover the fold's outstanding hole.
|
|
73
|
+
*
|
|
74
|
+
* Called on every `view/gap`, including one that lands mid-fill: the running
|
|
75
|
+
* walk reads its target from `fold.pendingGap` on each pass, so a coalesced
|
|
76
|
+
* hole extends the walk in flight rather than starting a second one racing
|
|
77
|
+
* it for the same cursor stream.
|
|
78
|
+
*/
|
|
79
|
+
start(): Promise<readonly PendingRetirement<I>[]>;
|
|
80
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SS4.8 gap recovery by splice-fill (spec 14990 FR-020 / T032, tdd SS4.8).
|
|
3
|
+
*
|
|
4
|
+
* Split out of `session.ts` for the same reason `approval.ts` and
|
|
5
|
+
* `turn-submit.ts` were: this is a delivery-plane concern with its own buffer,
|
|
6
|
+
* page walk, and failure vocabulary, and `Session` was already the package's
|
|
7
|
+
* largest module.
|
|
8
|
+
*
|
|
9
|
+
* THE RECIPE, verbatim from tdd SS4.8 and unchanged here: buffer live events
|
|
10
|
+
* at cursors at or after `next` as they arrive; page forward from `after` with
|
|
11
|
+
* `view/page` until the walk reaches `next`; discard the paged events the
|
|
12
|
+
* buffer already holds; splice the buffer after the paged prefix. D-030's
|
|
13
|
+
* second sanctioned path (drop state and re-anchor at the latest compaction
|
|
14
|
+
* snapshot) is NOT built here — it is the #208 lane's spec work, and building
|
|
15
|
+
* both would be a second concrete path for one current use.
|
|
16
|
+
*
|
|
17
|
+
* CURSORS STAY OPAQUE (tdd SS4.1). Nothing below orders two cursors: "at or
|
|
18
|
+
* after `next`" is delivery ORDER, not a comparison — every live frame that
|
|
19
|
+
* arrives after the gap marker is, by the server's own delivery contract, at
|
|
20
|
+
* or after `next`. The walk stops on cursor EQUALITY with the target or on the
|
|
21
|
+
* server's own end-of-view, and the overlap is discarded by set membership.
|
|
22
|
+
*/
|
|
23
|
+
var _a;
|
|
24
|
+
import { MuseForeignSessionError, MuseGapFillError } from "../errors.js";
|
|
25
|
+
/**
|
|
26
|
+
* How many events one `view/page` asks for.
|
|
27
|
+
*
|
|
28
|
+
* Any value in the published 1–1000 bound (`ViewPageParams.limit`) is correct
|
|
29
|
+
* — the server MAY serve fewer and the walk follows `nextCursor` either way —
|
|
30
|
+
* so this trades round trips against frame size and is deliberately not a
|
|
31
|
+
* knob: a per-session page size would be a public option with no current
|
|
32
|
+
* consumer asking for one (Constitution XI).
|
|
33
|
+
*/
|
|
34
|
+
const PAGE_LIMIT = 200;
|
|
35
|
+
/** The shared already-settled result for a fill that produced no retirement. */
|
|
36
|
+
const NO_RETIREMENTS = Promise.resolve([]);
|
|
37
|
+
export class GapFiller {
|
|
38
|
+
#options;
|
|
39
|
+
/** Live frames held while a fill is in flight, in arrival order. */
|
|
40
|
+
#buffer = [];
|
|
41
|
+
#filling = false;
|
|
42
|
+
#cursor = "";
|
|
43
|
+
#onFailure;
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.#options = options;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Cursors the page served at or after a target — the events whose LIVE twin
|
|
49
|
+
* the wire has yet to deliver.
|
|
50
|
+
*
|
|
51
|
+
* THE LATE HALF of the recipe's overlap discard, and only that half (PR
|
|
52
|
+
* #24930 review rounds 1 and 2). A twin that has already arrived is in the
|
|
53
|
+
* buffer, and `#drain` discards those against its own local set of EVERY
|
|
54
|
+
* paged cursor. This set covers the other window: the wire promises no order
|
|
55
|
+
* between the marker and the frame at `next`, so a twin can legally arrive
|
|
56
|
+
* after the fill completed, when the buffer is gone. Folding it then re-runs
|
|
57
|
+
* its routing — for a `turn/started`, a duplicate SS4.13 queue-movement
|
|
58
|
+
* replay on the wire.
|
|
59
|
+
*
|
|
60
|
+
* SELF-DRAINING: an entry is removed the moment its twin arrives, and only
|
|
61
|
+
* cursors at or after a target ever enter (the walk stops at the page that
|
|
62
|
+
* carries it, so at most one page's worth per target).
|
|
63
|
+
*
|
|
64
|
+
* RESIDUAL, stated rather than hidden: an entry whose twin the wire never
|
|
65
|
+
* delivers — because a LATER hole swallowed it — has no arrival to retire it
|
|
66
|
+
* and stays, a short string each, bounded by one page's worth per TARGET —
|
|
67
|
+
* and a coalesced fill has several, since each extension's reaching page adds
|
|
68
|
+
* its own batch.
|
|
69
|
+
*
|
|
70
|
+
* Two prunes were tried and both were defects, so neither is coming back
|
|
71
|
+
* without a proof neither had. Clearing per fill (round 2) drops the refusal
|
|
72
|
+
* for a twin that is still on its way. Retiring inside the WALK when it meets
|
|
73
|
+
* the cursor before its own target (round 3) looks like page-order proof that
|
|
74
|
+
* the twin was swallowed, but the walk cannot see the BUFFER — a coalescing
|
|
75
|
+
* gap moves the target past a cursor whose twin is sitting in it, and the
|
|
76
|
+
* entry is deleted out from under the drain (round 4). Deciding staleness any
|
|
77
|
+
* other way means ordering two opaque cursors, which tdd SS4.1 forbids.
|
|
78
|
+
*/
|
|
79
|
+
#servedTwins = new Set();
|
|
80
|
+
/** While true, every live frame but the gap marker itself is held. */
|
|
81
|
+
get filling() {
|
|
82
|
+
return this.#filling;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Was this frame already applied from a page, as part of the overlap the
|
|
86
|
+
* splice discards? Consuming: a twin is refused exactly once.
|
|
87
|
+
*
|
|
88
|
+
* Returns the matched CURSOR rather than a boolean so the caller can report
|
|
89
|
+
* which event it refused without re-reading (and re-defaulting) a field this
|
|
90
|
+
* method already proved is there.
|
|
91
|
+
*/
|
|
92
|
+
claimServedTwin(frame) {
|
|
93
|
+
const cursor = _a.#viewCursorOf(frame);
|
|
94
|
+
if (cursor === undefined || !this.#servedTwins.has(cursor))
|
|
95
|
+
return undefined;
|
|
96
|
+
this.#servedTwins.delete(cursor);
|
|
97
|
+
return cursor;
|
|
98
|
+
}
|
|
99
|
+
/** Observe fills that did not complete. See {@link MuseGapFillError}. */
|
|
100
|
+
onError(handler) {
|
|
101
|
+
this.#onFailure = handler;
|
|
102
|
+
}
|
|
103
|
+
/** Hold one live frame until the paged prefix is in. */
|
|
104
|
+
hold(frame) {
|
|
105
|
+
this.#buffer.push(frame);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Recover the fold's outstanding hole.
|
|
109
|
+
*
|
|
110
|
+
* Called on every `view/gap`, including one that lands mid-fill: the running
|
|
111
|
+
* walk reads its target from `fold.pendingGap` on each pass, so a coalesced
|
|
112
|
+
* hole extends the walk in flight rather than starting a second one racing
|
|
113
|
+
* it for the same cursor stream.
|
|
114
|
+
*/
|
|
115
|
+
start() {
|
|
116
|
+
const gap = this.#options.fold.pendingGap;
|
|
117
|
+
// Only reachable if a caller invokes this with nothing outstanding; the
|
|
118
|
+
// one production caller folds the marker first, which always sets it.
|
|
119
|
+
if (gap === undefined)
|
|
120
|
+
return NO_RETIREMENTS;
|
|
121
|
+
if (this.#filling)
|
|
122
|
+
return NO_RETIREMENTS;
|
|
123
|
+
const connection = this.#options.connection;
|
|
124
|
+
if (connection === undefined) {
|
|
125
|
+
// Nothing is buffered on this path: no fill is coming to release it, and
|
|
126
|
+
// holding the live tail forever would turn one reported hole into a
|
|
127
|
+
// silent second one.
|
|
128
|
+
this.#report(new MuseGapFillError("noConnection", gap.after, gap.next));
|
|
129
|
+
return NO_RETIREMENTS;
|
|
130
|
+
}
|
|
131
|
+
this.#filling = true;
|
|
132
|
+
this.#cursor = gap.after;
|
|
133
|
+
// DELIBERATELY NOT CLEARED (PR #24930 review round 2). An earlier draft
|
|
134
|
+
// cleared here, reasoning that a surviving entry's twin must lie inside the
|
|
135
|
+
// hole this gap names and so would never arrive. That conflated "not
|
|
136
|
+
// delivered yet" with "inside the new hole": a cursor the previous fill
|
|
137
|
+
// served can sit at or AFTER this gap's `next`, in which case its live twin
|
|
138
|
+
// is still coming and clearing loses the only refusal that stops it folding
|
|
139
|
+
// twice. Entries are one-shot and matched by cursor equality, and a cursor
|
|
140
|
+
// names a unique position (tdd SS4.1), so a retained entry can only ever
|
|
141
|
+
// refuse its own true twin.
|
|
142
|
+
return this.#run(connection);
|
|
143
|
+
}
|
|
144
|
+
async #run(connection) {
|
|
145
|
+
const paged = [];
|
|
146
|
+
let filled = false;
|
|
147
|
+
try {
|
|
148
|
+
await this.#walk(connection, paged);
|
|
149
|
+
filled = true;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
this.#reportFailure(error);
|
|
153
|
+
}
|
|
154
|
+
// ALWAYS drained, success or failure: the buffered tail is made of events
|
|
155
|
+
// this client really did receive, and swallowing them would add a second
|
|
156
|
+
// hole to the one just reported.
|
|
157
|
+
const drained = this.#drain(paged);
|
|
158
|
+
if (!filled)
|
|
159
|
+
return drained;
|
|
160
|
+
// THE CLEAR WINDOW (PR #24930 review round 1, P0). `#walk` clears the hole
|
|
161
|
+
// and returns, but `#drain` — which lowers `#filling` — runs a microtask
|
|
162
|
+
// later. A `view/gap` folding in between skips the buffer (markers must, or
|
|
163
|
+
// they could not extend a live walk), finds `#filling` still true, and
|
|
164
|
+
// `start()` returns early: no second `view/page`, no failure report, and
|
|
165
|
+
// `pendingGap` set forever. That is the silent hole FR-020 forbids, and it
|
|
166
|
+
// also costs the consumer its D-030 re-anchor cue.
|
|
167
|
+
//
|
|
168
|
+
// Re-checked HERE, in the same synchronous run that lowered the flag, so no
|
|
169
|
+
// frame can land inside the check. Success only: a fill that FAILED keeps
|
|
170
|
+
// its stated no-retry posture, and its hole is already reported.
|
|
171
|
+
if (this.#options.discarded())
|
|
172
|
+
return drained;
|
|
173
|
+
if (this.#options.fold.pendingGap === undefined)
|
|
174
|
+
return drained;
|
|
175
|
+
const restarted = this.start();
|
|
176
|
+
return Promise.all([drained, restarted]).then(([first, second]) => [...first, ...second]);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Page until the fold's outstanding hole is closed.
|
|
180
|
+
*
|
|
181
|
+
* The outer loop is what makes a mid-walk `view/gap` safe: `gapFilled`
|
|
182
|
+
* refuses to clear a target the fold has since extended, so the walk simply
|
|
183
|
+
* keeps going toward the newer `next` on the SAME cursor stream.
|
|
184
|
+
*/
|
|
185
|
+
async #walk(connection, paged) {
|
|
186
|
+
for (;;) {
|
|
187
|
+
// A session discharged mid-walk stops here, BEFORE `gapFilled`: there is
|
|
188
|
+
// nothing left to be current with, and clearing the hole would have the
|
|
189
|
+
// fold report a discarded transcript complete. The host is gone, so a
|
|
190
|
+
// further page would also wait on an answer that is never coming — which
|
|
191
|
+
// is a hang, not a fill.
|
|
192
|
+
if (this.#options.discarded())
|
|
193
|
+
return;
|
|
194
|
+
const gap = this.#options.fold.pendingGap;
|
|
195
|
+
if (gap === undefined)
|
|
196
|
+
return;
|
|
197
|
+
const target = gap.next;
|
|
198
|
+
await this.#walkTo(connection, target, gap.after, paged);
|
|
199
|
+
if (this.#options.discarded())
|
|
200
|
+
return;
|
|
201
|
+
if (this.#options.fold.gapFilled(target))
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async #walkTo(connection, target, after, paged) {
|
|
206
|
+
for (;;) {
|
|
207
|
+
if (this.#options.discarded())
|
|
208
|
+
return;
|
|
209
|
+
const result = await this.#page(connection, this.#cursor);
|
|
210
|
+
let reached = false;
|
|
211
|
+
// The last cursor this page served, skipped events included — where a
|
|
212
|
+
// walk toward an extended target resumes after an end-of-view page.
|
|
213
|
+
let lastServed;
|
|
214
|
+
for (const event of result.events) {
|
|
215
|
+
this.#requireOwnSession(event, after, target);
|
|
216
|
+
const cursor = event.params.viewCursor;
|
|
217
|
+
lastServed = cursor;
|
|
218
|
+
// An EARLIER fill already served — and applied — this durable event.
|
|
219
|
+
// A later walk may legally page through the same range, and re-applying
|
|
220
|
+
// it here is the same double fold the discard exists to prevent (PR
|
|
221
|
+
// #24930 review round 3).
|
|
222
|
+
const alreadyApplied = this.#servedTwins.has(cursor);
|
|
223
|
+
// Equality, never a relational compare: `next` is a cursor, and
|
|
224
|
+
// cursors are opaque (tdd SS4.1). Set BEFORE the skip below, or a
|
|
225
|
+
// target the walk skips is a target the walk never reaches, and it
|
|
226
|
+
// pages straight past its own stopping point.
|
|
227
|
+
if (cursor === target)
|
|
228
|
+
reached = true;
|
|
229
|
+
if (alreadyApplied) {
|
|
230
|
+
// SKIPPED AND NOTHING ELSE (PR #24930 review round 4). An earlier
|
|
231
|
+
// draft also retired the entry here when the walk met it before its
|
|
232
|
+
// target, reasoning that the twin must have been swallowed. The walk
|
|
233
|
+
// cannot see the BUFFER, so that reasoning missed the case where the
|
|
234
|
+
// twin is sitting in it: a coalescing gap moves the target past the
|
|
235
|
+
// cursor, the walk meets it early, and the entry is deleted out from
|
|
236
|
+
// under the drain, which then folds the buffered twin a second time.
|
|
237
|
+
// Leaving the entry alone is what keeps the drain able to refuse it.
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
paged.push(event);
|
|
241
|
+
// At or after `next` — by page ORDER, not by comparing two cursors.
|
|
242
|
+
// These are the events the live tail also delivers, so their twins are
|
|
243
|
+
// the overlap the splice discards.
|
|
244
|
+
if (reached)
|
|
245
|
+
this.#servedTwins.add(cursor);
|
|
246
|
+
}
|
|
247
|
+
// The server's own end of the view. `next` names a LIVE cursor and
|
|
248
|
+
// `view/page` serves durable-sourced events only (tdd SS4.7.3), so a
|
|
249
|
+
// hole whose tail was ephemeral ends here and never at `target`.
|
|
250
|
+
//
|
|
251
|
+
// The cursor still advances to the last event served (#50252, the Rust
|
|
252
|
+
// SDK's INV-002 rule from #49869): if a `view/gap` extended the target
|
|
253
|
+
// while this page was in flight, `#walk` comes back here toward the new
|
|
254
|
+
// one, and re-asking from where this walk started would re-serve — and
|
|
255
|
+
// fold a second time — every event this page just delivered.
|
|
256
|
+
if (result.nextCursor === null) {
|
|
257
|
+
if (lastServed !== undefined)
|
|
258
|
+
this.#cursor = lastServed;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// PROGRESS is the only bound on this walk, and it has to be, because a
|
|
262
|
+
// legitimate fill is unbounded in length: an attempt cap would silently
|
|
263
|
+
// truncate one. A page that carries no event, repeats the cursor it was
|
|
264
|
+
// given, or answers with no cursor at all has not advanced, and paging
|
|
265
|
+
// again would ask the identical question forever.
|
|
266
|
+
if (result.events.length === 0 ||
|
|
267
|
+
typeof result.nextCursor !== "string" ||
|
|
268
|
+
result.nextCursor === this.#cursor) {
|
|
269
|
+
throw new MuseGapFillError("pageStalled", after, target);
|
|
270
|
+
}
|
|
271
|
+
this.#cursor = result.nextCursor;
|
|
272
|
+
if (reached)
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async #page(connection, cursor) {
|
|
277
|
+
const params = {
|
|
278
|
+
cursor,
|
|
279
|
+
limit: PAGE_LIMIT,
|
|
280
|
+
sessionId: this.#options.sessionId,
|
|
281
|
+
};
|
|
282
|
+
const raw = await connection.request("view/page", params);
|
|
283
|
+
return raw;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* A page that serves another session's events aborts the fill.
|
|
287
|
+
*
|
|
288
|
+
* The same check `Session.apply` makes on a live frame, at the one other
|
|
289
|
+
* place a frame can enter the fold. Folding it would corrupt this
|
|
290
|
+
* transcript with another session's history, and the request that asked for
|
|
291
|
+
* it named this `sessionId`, so there is no reading under which the answer
|
|
292
|
+
* is right.
|
|
293
|
+
*/
|
|
294
|
+
#requireOwnSession(event, after, target) {
|
|
295
|
+
const named = event.params?.sessionId;
|
|
296
|
+
if (typeof named === "string" && named !== this.#options.sessionId) {
|
|
297
|
+
throw new MuseGapFillError("pageFailed", after, target, new MuseForeignSessionError(`view/page for ${this.#options.sessionId} served an event for session ${named}`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Splice: the paged prefix, then the buffered tail minus the overlap.
|
|
302
|
+
*
|
|
303
|
+
* Synchronous up to its return, deliberately. Clearing the buffer and the
|
|
304
|
+
* flag in the same run of code that re-feeds them is what makes the handover
|
|
305
|
+
* atomic — an `await` in the middle would let a live frame arrive after the
|
|
306
|
+
* flag dropped and fold ahead of the tail still waiting in the buffer.
|
|
307
|
+
*
|
|
308
|
+
* It is outside the walk's `try` because the buffered tail must be released
|
|
309
|
+
* on FAILURE too, and it needs no `try` of its own: every frame it re-feeds
|
|
310
|
+
* has already passed the one check `Session.apply` can throw on — the live
|
|
311
|
+
* half at buffer time, the paged half in the walk — so this cannot be the
|
|
312
|
+
* rejection `SessionApplyOutcome.io` promises never to be.
|
|
313
|
+
*/
|
|
314
|
+
#drain(paged) {
|
|
315
|
+
const buffered = this.#buffer.splice(0);
|
|
316
|
+
this.#filling = false;
|
|
317
|
+
// SS2.13.3b outranks the fill: a session discharged while the walk was in
|
|
318
|
+
// flight folds nothing more, and neither half of the splice is exempt.
|
|
319
|
+
if (this.#options.discarded())
|
|
320
|
+
return NO_RETIREMENTS;
|
|
321
|
+
const sink = { retirements: [], tasks: [] };
|
|
322
|
+
// EVERY paged cursor, not just the ones at or after a target (PR #24930
|
|
323
|
+
// review round 2, P0). A coalesced gap restarts the walk toward the NEW
|
|
324
|
+
// target with `reached` false, so paged events between the two targets
|
|
325
|
+
// never enter `#servedTwins` — yet their twins ARE in this buffer, because
|
|
326
|
+
// a cursor at or after the first target was delivered live before the
|
|
327
|
+
// second hole opened. Claiming on the persistent set alone folded those
|
|
328
|
+
// twice.
|
|
329
|
+
const served = new Set();
|
|
330
|
+
for (const frame of paged) {
|
|
331
|
+
const cursor = _a.#viewCursorOf(frame);
|
|
332
|
+
if (cursor !== undefined)
|
|
333
|
+
served.add(cursor);
|
|
334
|
+
this.#options.apply(frame, sink);
|
|
335
|
+
}
|
|
336
|
+
for (const frame of buffered) {
|
|
337
|
+
// The overlap the recipe discards: the page already served this cursor,
|
|
338
|
+
// so folding the buffered copy would route the same event twice.
|
|
339
|
+
const cursor = _a.#viewCursorOf(frame);
|
|
340
|
+
// BOTH sets (PR #24930 review round 3). `served` is what THIS fill
|
|
341
|
+
// applied; `#servedTwins` carries what EARLIER fills served, including
|
|
342
|
+
// the cursors this walk declined to re-apply — nothing deletes those
|
|
343
|
+
// inside the walk, precisely so this branch can still find them. A twin
|
|
344
|
+
// of either can be sitting in this buffer, because `Session.apply`
|
|
345
|
+
// buffers every live frame while a fill runs, before the late-window
|
|
346
|
+
// claim can see it, so this branch is the only thing left to refuse it.
|
|
347
|
+
if (cursor !== undefined && (served.has(cursor) || this.#servedTwins.has(cursor))) {
|
|
348
|
+
// Consume the persistent entry too, so a twin refused here is not
|
|
349
|
+
// refused a second time if the wire also delivers it later.
|
|
350
|
+
this.#servedTwins.delete(cursor);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
this.#options.apply(frame, sink);
|
|
354
|
+
}
|
|
355
|
+
if (sink.tasks.length === 0)
|
|
356
|
+
return Promise.resolve(sink.retirements);
|
|
357
|
+
return Promise.all(sink.tasks).then((batches) => [...sink.retirements, ...batches.flat()]);
|
|
358
|
+
}
|
|
359
|
+
static #viewCursorOf(frame) {
|
|
360
|
+
const cursor = frame.params?.viewCursor;
|
|
361
|
+
return typeof cursor === "string" ? cursor : undefined;
|
|
362
|
+
}
|
|
363
|
+
#reportFailure(error) {
|
|
364
|
+
if (error instanceof MuseGapFillError) {
|
|
365
|
+
this.#report(error);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// Anything the round trip itself threw — an `MspError` the host authored,
|
|
369
|
+
// a `ProtocolError`, a dead transport — is the same fact from a consumer's
|
|
370
|
+
// point of view: the hole is still there. The cause is carried rather than
|
|
371
|
+
// flattened so the repair stays diagnosable.
|
|
372
|
+
const gap = this.#options.fold.pendingGap;
|
|
373
|
+
this.#report(new MuseGapFillError("pageFailed", gap?.after ?? "", gap?.next ?? "", error));
|
|
374
|
+
}
|
|
375
|
+
#report(failure) {
|
|
376
|
+
try {
|
|
377
|
+
this.#onFailure?.(failure);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// The consumer's failure OBSERVER threw. Swallowed for the reason
|
|
381
|
+
// `ApprovalRouter.#report` states: this rides
|
|
382
|
+
// `SessionApplyOutcome.io`, whose contract is "IT NEVER REJECTS", and
|
|
383
|
+
// most consumers never await it — so a rejection here would surface as
|
|
384
|
+
// an unhandled rejection and kill the embedder.
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
_a = GapFiller;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ephemeral-profile host-death discard obligation, client side
|
|
3
|
+
* (spec 14990 FM-002 / Scenario 4.3, tasks.md T030b; tdd SS2.13, SS4.4.3).
|
|
4
|
+
*
|
|
5
|
+
* Slice 2b landed the two STORE-LEVEL primitives with no production callers:
|
|
6
|
+
* `PendingCommandSet.discardEphemeral()` and
|
|
7
|
+
* `ItemStore.markEphemeralHostDeath()`. This module holds the two facts the
|
|
8
|
+
* stores deliberately do not know — which durability profile the handshake
|
|
9
|
+
* declared, and whether a given process exit was abnormal — and `Session`
|
|
10
|
+
* composes them into the discharge.
|
|
11
|
+
*
|
|
12
|
+
* The stores stay wire-shape-blind (INV-001): the `Item` probe lives here,
|
|
13
|
+
* where it is checked against the GENERATED type.
|
|
14
|
+
*/
|
|
15
|
+
import type { InitializeResult, Item } from "../msp.js";
|
|
16
|
+
import type { ExitClassification } from "../connection/spawn.js";
|
|
17
|
+
import type { TerminalUnknownItemAnnotation } from "../fold/item-store.js";
|
|
18
|
+
import type { PendingRetirement } from "../pending/pending-command-set.js";
|
|
19
|
+
/**
|
|
20
|
+
* Which durability profile the host declared at the handshake (SS2.13.1).
|
|
21
|
+
*
|
|
22
|
+
* Absent and declared-`"durable"` collapse to ONE reading on purpose: nothing
|
|
23
|
+
* downstream needs to tell them apart, and a `source` discriminator would be a
|
|
24
|
+
* field carried for a future that has not arrived. Adding it later is
|
|
25
|
+
* additive; removing it from a barrel-exported type would not be.
|
|
26
|
+
*/
|
|
27
|
+
export type SessionDurabilityProfile = {
|
|
28
|
+
readonly kind: "durable";
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: "ephemeral";
|
|
31
|
+
}
|
|
32
|
+
/** An open-enum value this SDK predates. Guarantees nothing (SS2.13.1). */
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: "unrecognized";
|
|
35
|
+
readonly value: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Read `sessionDurability` off the handshake result (SS2.13.1).
|
|
39
|
+
*
|
|
40
|
+
* Two readings are easy to conflate and must not be:
|
|
41
|
+
*
|
|
42
|
+
* - ABSENT is `"durable"`, and that is decidable rather than fabricated: the
|
|
43
|
+
* member is optional only so the addition was additive (SS1.5.4), and no
|
|
44
|
+
* server that omits it has the ephemeral profile.
|
|
45
|
+
* - An UNRECOGNIZED value is NOT durable. SS2.13.1: a client that does not
|
|
46
|
+
* know the value "MUST NOT infer any durability guarantee from it, and MUST
|
|
47
|
+
* NOT fall through to the absent-means-durable rule — that rule keys on the
|
|
48
|
+
* member being missing, not on its value being unfamiliar." The enum is open
|
|
49
|
+
* for a reason (#14401's degraded state is a candidate third value), so the
|
|
50
|
+
* conservative read is "assume nothing survives this host".
|
|
51
|
+
*/
|
|
52
|
+
export declare function readSessionDurability(result: InitializeResult): SessionDurabilityProfile;
|
|
53
|
+
/** Does a session on this profile survive its host? Only a durable one does. */
|
|
54
|
+
export declare function survivesHostDeath(profile: SessionDurabilityProfile): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The transport reached EOF with no orderly SS2.1.2 close (T030 obligation b).
|
|
57
|
+
*
|
|
58
|
+
* SS2.13.3b names TWO notifications of a host death — "process exit **or
|
|
59
|
+
* transport EOF**" — and only the exit half was implementable while nothing in
|
|
60
|
+
* this SDK owned the close. A host that closes stdout while still hung emits no
|
|
61
|
+
* exit on the timescale that matters, so without this arm its session never
|
|
62
|
+
* discharged: every pending command sat in the set as the durable-looking echo
|
|
63
|
+
* SS4.13 forbids, and every turn wait hung.
|
|
64
|
+
*
|
|
65
|
+
* It carries no evidence fields, and that is the honest shape rather than a
|
|
66
|
+
* gap: EOF IS the whole observation. The stderr tail and the SS2.11 row belong
|
|
67
|
+
* to the process boundary, which has not reported yet — and when it does,
|
|
68
|
+
* `Session.hostExited` latches the FIRST discharge, so a later exit replays the
|
|
69
|
+
* EOF's report rather than overwriting it with a richer one. Whoever owns the
|
|
70
|
+
* process still has `MuseServeChild.stderrTail` and its `exit` promise.
|
|
71
|
+
*
|
|
72
|
+
* ORDERLINESS IS NOT DECIDABLE HERE. `Connection` is duplex-generic and a
|
|
73
|
+
* transport cannot tell a peer's hang-up from its own shutdown, so the layer
|
|
74
|
+
* that KNOWS whether the close was its own is the one that builds this:
|
|
75
|
+
* `MuseClient`, which owns both `close()` and the connection.
|
|
76
|
+
*/
|
|
77
|
+
export interface TransportEof {
|
|
78
|
+
readonly kind: "transportEof";
|
|
79
|
+
}
|
|
80
|
+
/** How a client learns its host is gone: the process exit, or transport EOF. */
|
|
81
|
+
export type HostDeathNotification = ExitClassification | TransportEof;
|
|
82
|
+
/**
|
|
83
|
+
* Was this notification an abnormal death (SS2.13.3b)?
|
|
84
|
+
*
|
|
85
|
+
* Exit 0 is the ONLY SS2.11 row where the run was cancelled, the drain
|
|
86
|
+
* completed, and the durable `SessionEnd` records were written. Every other
|
|
87
|
+
* row — including the ones with a tidy-looking name like `configError` —
|
|
88
|
+
* left no `SessionEnd`, which is exactly what "abnormal" means here. A
|
|
89
|
+
* transport EOF the client did not cause left no `SessionEnd` either, so it
|
|
90
|
+
* joins the same predicate rather than getting a parallel one that could drift.
|
|
91
|
+
*/
|
|
92
|
+
export declare function isAbnormalHostDeath(notification: HostDeathNotification): notification is AbnormalExit;
|
|
93
|
+
/**
|
|
94
|
+
* The in-progress probe the store asks its caller for. `Item.status` is an
|
|
95
|
+
* open enum whose terminal rule is "anything other than `inProgress`", so this
|
|
96
|
+
* is a positive test on the one non-terminal value rather than a negative test
|
|
97
|
+
* against a list that a schema advance would silently widen.
|
|
98
|
+
*/
|
|
99
|
+
export declare function isItemInProgress(item: Item): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Every death notification except the orderly exit row. An orderly close is
|
|
102
|
+
* not a death, and neither is the EOF that follows one — which is why
|
|
103
|
+
* `TransportEof` is only ever constructed by the layer that can tell.
|
|
104
|
+
*/
|
|
105
|
+
export type AbnormalExit = Exclude<HostDeathNotification, {
|
|
106
|
+
kind: "cleanShutdown";
|
|
107
|
+
}>;
|
|
108
|
+
/** A durable host died abnormally: its terminals arrive on resume (FM-001). */
|
|
109
|
+
export declare class MuseHostDiedError extends Error {
|
|
110
|
+
#private;
|
|
111
|
+
readonly exit: AbnormalExit;
|
|
112
|
+
constructor(exit: AbnormalExit);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* What one `Session.hostExited` call did.
|
|
116
|
+
*
|
|
117
|
+
* Discriminated because the three outcomes leave the session in three
|
|
118
|
+
* different states, and a bare `discharged: boolean` collapsed the two false
|
|
119
|
+
* arms into one: after `notADeath` the session is still usable, after
|
|
120
|
+
* `durableDeath` it is dead until resume with every live wait already
|
|
121
|
+
* rejected. An embedder should not have to re-derive that from the profile
|
|
122
|
+
* and the exit row the method already read.
|
|
123
|
+
*/
|
|
124
|
+
export type HostDeathDischarge<I> = {
|
|
125
|
+
readonly kind: "notADeath";
|
|
126
|
+
readonly profile: SessionDurabilityProfile;
|
|
127
|
+
readonly exit: HostDeathNotification;
|
|
128
|
+
} | {
|
|
129
|
+
readonly kind: "durableDeath";
|
|
130
|
+
readonly profile: SessionDurabilityProfile;
|
|
131
|
+
readonly exit: AbnormalExit;
|
|
132
|
+
} | {
|
|
133
|
+
readonly kind: "discharged";
|
|
134
|
+
readonly profile: SessionDurabilityProfile;
|
|
135
|
+
readonly exit: AbnormalExit;
|
|
136
|
+
/** Items still `inProgress` at death, annotated — never a synthesized event. */
|
|
137
|
+
readonly terminalUnknownItems: readonly TerminalUnknownItemAnnotation[];
|
|
138
|
+
readonly retiredCommands: readonly PendingRetirement<I>[];
|
|
139
|
+
};
|