@sagentlab/navarch-runtime 0.1.112 → 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 +56 -18
- 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/cli.cjs +4 -1
- package/dist/config.cjs +9 -1
- package/dist/conversation/capability.cjs +1 -1
- package/dist/conversation/loop.cjs +16 -0
- package/dist/conversation/mirror.cjs +225 -0
- package/dist/conversation/workspace.cjs +11 -4
- package/dist/exit-conditions.cjs +1 -1
- package/dist/muse-mcp-bridge.cjs +167 -0
- package/dist/session.cjs +30 -8
- package/dist/worktree-guard.cjs +137 -3
- 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,654 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Session` — the SS7.1 facade's composition of the transport-less core
|
|
3
|
+
* (spec 14990 FR-018 / T030, FM-002 / T030b).
|
|
4
|
+
*
|
|
5
|
+
* It owns a `SessionFold` and a `PendingCommandSet`, routes folded view events
|
|
6
|
+
* to per-turn handles, and discharges the ephemeral host-death obligation.
|
|
7
|
+
* Like everything else in this SDK it holds no durable state (spec Lifecycle &
|
|
8
|
+
* Composition): every fact it reports came from a server event or from the two
|
|
9
|
+
* stores it composes.
|
|
10
|
+
*
|
|
11
|
+
* The SS4.8 splice-fill (FR-020, T032) is here too, in `gap-fill.ts`: this
|
|
12
|
+
* layer owns it because the recipe needs client→server I/O (`view/page`) and
|
|
13
|
+
* because the buffer it splices is the same routing path the iterators read.
|
|
14
|
+
*
|
|
15
|
+
* Everything else FR-018/FR-019 name IS here as of #23980: `sendUserTurn` over
|
|
16
|
+
* the enrolled `turn/start` row, `onApproval`'s idempotent `approval/decide`
|
|
17
|
+
* submission, and the SS4.13 retirement drivers that need client->server I/O.
|
|
18
|
+
* The connection is an OPTIONAL seam: a fold-only `Session` — the transport-less
|
|
19
|
+
* composition every earlier arm builds — stays valid and refuses the submit
|
|
20
|
+
* verbs loudly rather than dropping a caller's input on the floor.
|
|
21
|
+
*/
|
|
22
|
+
import { SessionFold } from "../fold/session-fold.js";
|
|
23
|
+
import { PendingCommandSet } from "../pending/pending-command-set.js";
|
|
24
|
+
import { ApprovalRouter } from "./approval.js";
|
|
25
|
+
import { TurnSubmitter } from "./turn-submit.js";
|
|
26
|
+
import { GapFiller } from "./gap-fill.js";
|
|
27
|
+
import { isAbnormalHostDeath, isItemInProgress, MuseHostDiedError, survivesHostDeath, } from "./host-death.js";
|
|
28
|
+
import { MuseForeignSessionError } from "../errors.js";
|
|
29
|
+
import { TurnHandle } from "./turn-handle.js";
|
|
30
|
+
/** Bound to the generated vocabulary so a misspelling fails `tsc`. */
|
|
31
|
+
const USER_MESSAGE = "userMessage";
|
|
32
|
+
/**
|
|
33
|
+
* The shared already-settled `io` for an event that triggered no I/O.
|
|
34
|
+
*
|
|
35
|
+
* A fresh `Promise.resolve([])` per `apply` would allocate on the hot
|
|
36
|
+
* notification path for the overwhelming majority of frames, which carry
|
|
37
|
+
* neither a pending command nor an approval.
|
|
38
|
+
*/
|
|
39
|
+
const NO_IO = Promise.resolve([]);
|
|
40
|
+
/**
|
|
41
|
+
* One conversation with the agent.
|
|
42
|
+
*
|
|
43
|
+
* A session folds every server event into readable state — items, turns,
|
|
44
|
+
* session facts, pending approvals — and submits your side of the exchange.
|
|
45
|
+
* Get one from `MuseClient.startSession` or `resumeSession`, then
|
|
46
|
+
* `sendUserTurn` to talk, `turn` to follow a turn's items to its outcome,
|
|
47
|
+
* and `onApproval` to answer permission requests.
|
|
48
|
+
*/
|
|
49
|
+
export class Session {
|
|
50
|
+
sessionId;
|
|
51
|
+
/** See {@link SessionOpening}. Absent when the caller built this directly. */
|
|
52
|
+
opening;
|
|
53
|
+
#pending;
|
|
54
|
+
#discarded;
|
|
55
|
+
/** `turn/start` submission and same-`commandId` replay (`turn-submit.ts`). */
|
|
56
|
+
#submit;
|
|
57
|
+
/** The FR-019 round trip (`approval.ts`). */
|
|
58
|
+
#approvals;
|
|
59
|
+
/** The FR-020 splice-fill (`gap-fill.ts`). */
|
|
60
|
+
#gaps;
|
|
61
|
+
#fold = new SessionFold();
|
|
62
|
+
#durability;
|
|
63
|
+
/** The first discharge, replayed verbatim on every later call. */
|
|
64
|
+
#discharge;
|
|
65
|
+
/** Set on a durable abnormal death, so late handles inherit the rejection. */
|
|
66
|
+
#deathError;
|
|
67
|
+
#turns = new Map();
|
|
68
|
+
/**
|
|
69
|
+
* `item/delta` frames whose item the fold does not hold yet. The store
|
|
70
|
+
* already buffers the delta TEXT against the item's arrival (SS4.7.3, spec
|
|
71
|
+
* Edge Cases); a delta's TURN is equally unknowable until then, so the
|
|
72
|
+
* frames wait here and are attributed when the item lands. Dropping them
|
|
73
|
+
* instead would put a silent hole in the very iterators FR-020 forbids one
|
|
74
|
+
* in. Bounded by the same fact that bounds the store's own buffer: the
|
|
75
|
+
* durable `item/completed` always lands the item.
|
|
76
|
+
*/
|
|
77
|
+
#unattributedDeltas = new Map();
|
|
78
|
+
constructor(options) {
|
|
79
|
+
this.sessionId = options.sessionId;
|
|
80
|
+
this.#durability = options.durability;
|
|
81
|
+
this.#discarded = options.discarded;
|
|
82
|
+
this.opening = options.opening;
|
|
83
|
+
this.#pending = new PendingCommandSet(options.discarded === undefined
|
|
84
|
+
? undefined
|
|
85
|
+
: { discardedCommandIds: options.discarded.commandIds });
|
|
86
|
+
this.#submit = new TurnSubmitter(options.sessionId, options.connection, this.#pending);
|
|
87
|
+
this.#approvals = new ApprovalRouter(options.sessionId, options.connection);
|
|
88
|
+
this.#gaps = new GapFiller({
|
|
89
|
+
apply: (frame, into) => this.#foldAndRoute(frame, into),
|
|
90
|
+
connection: options.connection,
|
|
91
|
+
discarded: () => this.#discharge?.kind === "discharged",
|
|
92
|
+
fold: this.#fold,
|
|
93
|
+
sessionId: options.sessionId,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
get durability() {
|
|
97
|
+
return this.#durability;
|
|
98
|
+
}
|
|
99
|
+
/** Read-only; events enter through `apply` so turn routing cannot be skipped. */
|
|
100
|
+
get fold() {
|
|
101
|
+
return this.#fold;
|
|
102
|
+
}
|
|
103
|
+
/** The SS4.13 set, minus the three mutators `Session` drives itself. */
|
|
104
|
+
get pending() {
|
|
105
|
+
return this.#pending;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* How many turns this session has minted a handle for.
|
|
109
|
+
*
|
|
110
|
+
* Distinct from `fold.turns()`, which counts turns the WIRE named: a handle
|
|
111
|
+
* is also minted by `turn()` and by item routing, so this is the number that
|
|
112
|
+
* reveals a phantom turn minted from a bad key (a `userShell` item's null
|
|
113
|
+
* `turnId`, say). Read-only and observation-only.
|
|
114
|
+
*
|
|
115
|
+
* @internal Test observability, stripped from the published declarations
|
|
116
|
+
* (`stripInternal`). `Session` is barrel-exported, so without this the getter
|
|
117
|
+
* would be frozen API the moment #211 adopts the package — the same call
|
|
118
|
+
* `liveStreamCount` got by staying off the public `Turn` (Constitution XI).
|
|
119
|
+
*/
|
|
120
|
+
get knownTurnCount() {
|
|
121
|
+
return this.#turns.size;
|
|
122
|
+
}
|
|
123
|
+
apply(event) {
|
|
124
|
+
// Only enforce when the frame actually NAMES a session. `ViewEvent`'s
|
|
125
|
+
// members all declare `sessionId`, but a newer host's unknown notification
|
|
126
|
+
// reaches this method at runtime and SS1.5.4 says it is tolerated
|
|
127
|
+
// losslessly — an unconditional read threw `MuseForeignSessionError`
|
|
128
|
+
// ("belongs to session undefined") on it, or a raw `TypeError` when the
|
|
129
|
+
// frame carried no `params` at all.
|
|
130
|
+
const named = event.params?.sessionId;
|
|
131
|
+
if (typeof named === "string" && named !== this.sessionId) {
|
|
132
|
+
throw new MuseForeignSessionError(`event ${event.method} belongs to session ${named}, not ${this.sessionId}`);
|
|
133
|
+
}
|
|
134
|
+
// AFTER the foreign check, deliberately. A frame naming a DIFFERENT session
|
|
135
|
+
// is an embedder routing bug, never this session's trailing drain frame, so
|
|
136
|
+
// the pump-safety rationale below does not cover it — refusing it here
|
|
137
|
+
// would relabel a routing bug as our own drain refusal and hide it exactly
|
|
138
|
+
// when a multiplexed embedder needs to see it.
|
|
139
|
+
//
|
|
140
|
+
// REFUSED, not thrown. A discarded session must still not fold — ItemStore's
|
|
141
|
+
// own guard covers only ITEM events, so a post-discharge `turn/completed`
|
|
142
|
+
// once settled a fresh turn "completed" on a session the client was told to
|
|
143
|
+
// discard. But throwing killed the consumer's pump on a single trailing
|
|
144
|
+
// drain frame, the exact failure this method already refuses for missing
|
|
145
|
+
// params and unknown methods. SS2.13.3b requires that nothing fold; it does
|
|
146
|
+
// not require an exception.
|
|
147
|
+
if (this.#discharge?.kind === "discharged") {
|
|
148
|
+
return { fold: { kind: "refusedSessionDiscarded" }, io: NO_IO, retirements: [] };
|
|
149
|
+
}
|
|
150
|
+
// An SS4.8 fill is in flight, so the live tail waits for the paged prefix
|
|
151
|
+
// (tdd SS4.8's "buffer live events … splice the buffer after the paged
|
|
152
|
+
// prefix"). Folding it now would run the fold backwards when the older
|
|
153
|
+
// paged events land, and would hand the iterators the tail before the
|
|
154
|
+
// hole they follow. The MARKER itself is exempt: a second overflow has to
|
|
155
|
+
// reach the fold to extend the outstanding hole, or the walk stops short
|
|
156
|
+
// of it and reports current with a hole still open.
|
|
157
|
+
if (this.#gaps.filling && event.method !== "view/gap") {
|
|
158
|
+
this.#gaps.hold(event);
|
|
159
|
+
return {
|
|
160
|
+
fold: { kind: "bufferedDuringGap", method: event.method },
|
|
161
|
+
io: NO_IO,
|
|
162
|
+
retirements: [],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// The overlap's late half: a frame a completed fill already applied from a
|
|
166
|
+
// page, whose live twin the wire delivered afterwards. Reached only when NO
|
|
167
|
+
// fill is running — during one the buffer takes the frame first, and the
|
|
168
|
+
// drain refuses it there against both its own paged cursors and the
|
|
169
|
+
// persistent set.
|
|
170
|
+
const twin = this.#gaps.claimServedTwin(event);
|
|
171
|
+
if (twin !== undefined) {
|
|
172
|
+
return {
|
|
173
|
+
fold: { kind: "ignoredGapOverlap", method: event.method, viewCursor: twin },
|
|
174
|
+
io: NO_IO,
|
|
175
|
+
retirements: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const sink = { retirements: [], tasks: [] };
|
|
179
|
+
const outcome = this.#foldAndRoute(event, sink);
|
|
180
|
+
// FR-020: recover before this fold may call itself current again. Started
|
|
181
|
+
// HERE rather than inside the routing switch because it is the one arm
|
|
182
|
+
// that authors client→server I/O off a notification, and `io` is the
|
|
183
|
+
// channel that carries it.
|
|
184
|
+
if (outcome.kind === "deliveryGap")
|
|
185
|
+
sink.tasks.push(this.#gaps.start());
|
|
186
|
+
return {
|
|
187
|
+
fold: outcome,
|
|
188
|
+
io: Session.#settleIo(sink.tasks),
|
|
189
|
+
retirements: sink.retirements,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Observe SS4.8 fills that did not complete. See `MuseGapFillError`. */
|
|
193
|
+
onGapError(handler) {
|
|
194
|
+
this.#gaps.onError(handler);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Fold one frame and fan it out to the turn handles that want it.
|
|
198
|
+
*
|
|
199
|
+
* The shared path: `apply` uses it for a live frame, and `GapFiller` uses it
|
|
200
|
+
* to re-feed the paged prefix and the buffered tail. One path is what makes
|
|
201
|
+
* a spliced frame indistinguishable from a live one to everything
|
|
202
|
+
* downstream — the fold, the iterators, and the SS4.13 retirements.
|
|
203
|
+
*/
|
|
204
|
+
#foldAndRoute(event, sink) {
|
|
205
|
+
const retirements = [];
|
|
206
|
+
const tasks = sink.tasks;
|
|
207
|
+
const outcome = this.#fold.apply(event);
|
|
208
|
+
// The fold drops a frame with no `params` as `ignoredMissingParams`
|
|
209
|
+
// (SS1.5.4: drop, never throw). The switch below reads `event.params` on
|
|
210
|
+
// the turn and delta arms, so without this early return a single
|
|
211
|
+
// `{method:"turn/completed"}` frame throws a raw TypeError out of the ONLY
|
|
212
|
+
// event entry point and kills the consumer's notification pump.
|
|
213
|
+
if (outcome.kind === "ignoredMissingParams")
|
|
214
|
+
return outcome;
|
|
215
|
+
const known = event;
|
|
216
|
+
switch (known.method) {
|
|
217
|
+
case "item/started":
|
|
218
|
+
case "item/updated":
|
|
219
|
+
case "item/completed":
|
|
220
|
+
// A stale re-emission changed nothing (INV-003), so it yields nothing.
|
|
221
|
+
if (outcome.kind === "item" && outcome.outcome.kind !== "ignoredStaleRevision") {
|
|
222
|
+
this.#itemFolded(known.params.item, retirements);
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case "item/delta":
|
|
226
|
+
this.#deltaFolded(known.params);
|
|
227
|
+
break;
|
|
228
|
+
case "turn/started":
|
|
229
|
+
this.#handle(known.params.turnId).markStarted();
|
|
230
|
+
this.#queueMoved(known.params.turnId, tasks);
|
|
231
|
+
break;
|
|
232
|
+
case "turn/completed":
|
|
233
|
+
this.#handle(known.params.turnId).settleCompleted(known.params);
|
|
234
|
+
this.#queueMoved(known.params.turnId, tasks);
|
|
235
|
+
break;
|
|
236
|
+
case "turn/unqueued": {
|
|
237
|
+
this.#handle(known.params.turnId).settleUnqueued(known.params);
|
|
238
|
+
// SS4.13 "Reclaimed": the reclaim this client just observed retires
|
|
239
|
+
// the entry NOW, with its input restored to the composer. Leaving it
|
|
240
|
+
// in the set is the durable-looking echo SS4.13 forbids — and it would
|
|
241
|
+
// later be retired `terminalUnknown` by a discharge, which would be a
|
|
242
|
+
// fabricated annotation about a command whose fate is known.
|
|
243
|
+
const reclaimed = this.#pending.observedReclaim(known.params.commandId);
|
|
244
|
+
if (reclaimed !== undefined)
|
|
245
|
+
retirements.push(reclaimed);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "approval/requested":
|
|
249
|
+
// FR-019's outbound half. Gated on the fold's own verdict: a request
|
|
250
|
+
// the fold IGNORED (a redelivery for an approval already durably
|
|
251
|
+
// resolved) must author no decision — no second resolution is coming,
|
|
252
|
+
// so a decide against it can only bounce.
|
|
253
|
+
if (outcome.kind === "approvalPending")
|
|
254
|
+
this.#approvalRequested(known.params, tasks);
|
|
255
|
+
break;
|
|
256
|
+
case "approval/updated":
|
|
257
|
+
// FR-019's outbound half too (#36949): a stage advance is delivered by
|
|
258
|
+
// this frame alone on the notification plane, so it must drive the
|
|
259
|
+
// handler — the why and the request merge live on
|
|
260
|
+
// `ApprovalRouter.updated`. The same fold verdict gates it: an update
|
|
261
|
+
// the fold IGNORED (unknown or already-resolved approval) authors
|
|
262
|
+
// nothing, and the verdict itself carries the retained request the
|
|
263
|
+
// merge needs.
|
|
264
|
+
if (outcome.kind === "approvalPending") {
|
|
265
|
+
this.#approvalUpdated(outcome.requested, known.params, tasks);
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
// Folded, deliberately NOT routed to a turn handle. Listed rather than
|
|
269
|
+
// defaulted so this switch is exhaustive over `ViewEvent`: the fold's
|
|
270
|
+
// `AssertNever` forces each new #206 view notification into that union,
|
|
271
|
+
// and a `default` arm here would swallow the addition silently.
|
|
272
|
+
//
|
|
273
|
+
// `view/gap` is here because it routes to no turn: it names a hole in
|
|
274
|
+
// DELIVERY, so what it moves is the fold's currency, and the recovery
|
|
275
|
+
// it triggers is started by `apply` off the `deliveryGap` verdict rather
|
|
276
|
+
// than from this switch (FR-020 / T032).
|
|
277
|
+
case "view/gap":
|
|
278
|
+
case "turn/retracted": // the turn still reaches its own terminal
|
|
279
|
+
case "turn/retryScheduled": // non-terminal by contract (SS4.5.1)
|
|
280
|
+
case "turn/foregroundCompleted": // non-terminal by contract (SS4.5.12)
|
|
281
|
+
case "approval/resolved":
|
|
282
|
+
case "userInput/requested":
|
|
283
|
+
case "userInput/settled":
|
|
284
|
+
case "session/modelChanged":
|
|
285
|
+
case "session/reasoningEffortChanged":
|
|
286
|
+
case "session/goalChanged":
|
|
287
|
+
case "session/todoListChanged":
|
|
288
|
+
case "session/branchChanged":
|
|
289
|
+
case "session/tokenUsage":
|
|
290
|
+
case "session/contextUsage":
|
|
291
|
+
case "session/approvalModeChanged":
|
|
292
|
+
case "session/modelRouteUnserved":
|
|
293
|
+
case "session/nameChanged":
|
|
294
|
+
break;
|
|
295
|
+
default: {
|
|
296
|
+
// Compile-time exhaustiveness. Runtime tolerance of a newer host's
|
|
297
|
+
// unknown method is unaffected — `SessionFold.apply` already returns
|
|
298
|
+
// `ignoredUnrecognizedMethod` for one (SS1.5.4).
|
|
299
|
+
const unrouted = known;
|
|
300
|
+
void unrouted;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// The retired entries are gone from the pending set, so their replay
|
|
305
|
+
// memory goes with them — `TurnSubmitter.#memory`'s contract is "pruned on
|
|
306
|
+
// retirement, so it tracks the pending set". This covers the two
|
|
307
|
+
// retirements `apply` produces synchronously (the materialized userMessage
|
|
308
|
+
// and the `turn/unqueued` reclaim); the consumer-driven retirement verbs
|
|
309
|
+
// (`stopRetrying`, `replayAnswered`) prune through their own `Session`
|
|
310
|
+
// wrappers.
|
|
311
|
+
if (retirements.length > 0) {
|
|
312
|
+
this.#submit.forgetRetired(retirements);
|
|
313
|
+
sink.retirements.push(...retirements);
|
|
314
|
+
}
|
|
315
|
+
return outcome;
|
|
316
|
+
}
|
|
317
|
+
/** Flatten the event's I/O tasks, or hand back the shared empty. */
|
|
318
|
+
static #settleIo(tasks) {
|
|
319
|
+
if (tasks.length === 0)
|
|
320
|
+
return NO_IO;
|
|
321
|
+
return Promise.all(tasks).then((batches) => batches.flat());
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The handle for a turn, created on first mention from either side. A turn
|
|
325
|
+
* the fold has already settled returns its settled handle, so a wait
|
|
326
|
+
* registered late resolves instead of hanging on an event that has passed.
|
|
327
|
+
*/
|
|
328
|
+
turn(turnId) {
|
|
329
|
+
const held = this.#turns.get(turnId);
|
|
330
|
+
if (held !== undefined)
|
|
331
|
+
return held;
|
|
332
|
+
const fresh = this.#mint(turnId);
|
|
333
|
+
// A CONSUMER asking about a turn this session has no news of, after the
|
|
334
|
+
// host died, must not hang: no answer is coming on this connection. That
|
|
335
|
+
// is the SS3.1.4 trap SS2.13.3b's "stop waiting for a terminal" forbids.
|
|
336
|
+
// Server-minted handles deliberately skip this — see `#handle`.
|
|
337
|
+
if (this.#discharge?.kind === "discharged")
|
|
338
|
+
fresh.settleTerminalUnknown();
|
|
339
|
+
else if (this.#deathError !== undefined)
|
|
340
|
+
fresh.fail(this.#deathError);
|
|
341
|
+
return fresh;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* A handle minted because a SERVER FRAME named this turn. Never pre-failed,
|
|
345
|
+
* and that is what makes the post-death behaviour ORDER-INDEPENDENT.
|
|
346
|
+
*
|
|
347
|
+
* A durable death does not stop the fold (FM-001), so frames can still
|
|
348
|
+
* arrive for a turn with no handle yet. Pre-failing here made the outcome
|
|
349
|
+
* depend on which frame arrived first: terminal-first settled, started-first
|
|
350
|
+
* pre-failed the handle and first-settlement-wins then dropped the real
|
|
351
|
+
* terminal one frame later — the same server fact, two different answers.
|
|
352
|
+
* That asymmetry is measured against synthetic frames; which order a real
|
|
353
|
+
* host emits after a crash is NOT something this lane has observed, and the
|
|
354
|
+
* rule does not depend on it. A frame arriving IS the connection still speaking,
|
|
355
|
+
* so a handle it mints is not a waiter with no answer coming; `turn()` keeps
|
|
356
|
+
* the latch for the case that genuinely is.
|
|
357
|
+
*/
|
|
358
|
+
#handle(turnId) {
|
|
359
|
+
return this.#turns.get(turnId) ?? this.#mint(turnId);
|
|
360
|
+
}
|
|
361
|
+
#mint(turnId) {
|
|
362
|
+
const fresh = new TurnHandle(turnId, () => this.#itemsOfTurn(turnId));
|
|
363
|
+
this.#turns.set(turnId, fresh);
|
|
364
|
+
return fresh;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* A host process exited. Classify it, and discharge SS2.13.3b if the profile
|
|
368
|
+
* demands it (tasks.md T030b, FM-002).
|
|
369
|
+
*
|
|
370
|
+
* REPORT EVERY NOTIFICATION OF THE DEATH — the exit promise AND transport
|
|
371
|
+
* EOF. A repeat report of an already-latched death is not a no-op: it marks
|
|
372
|
+
* END OF DRAIN and settles waits the drain minted after the first report.
|
|
373
|
+
* A consumer that dedupes exit notifications and reports the death once
|
|
374
|
+
* leaves those waits pending forever (FM-001).
|
|
375
|
+
*
|
|
376
|
+
* Three outcomes, and the middle one is the one an implementation drops:
|
|
377
|
+
* - an ORDERLY exit (SS2.11 row 0) is not a death: nothing is discharged
|
|
378
|
+
* and the session stays usable;
|
|
379
|
+
* - a DURABLE session's abnormal death reconciles on resume (FM-001), so
|
|
380
|
+
* the stores are left exactly as observed and only the waiters are told;
|
|
381
|
+
* - an EPHEMERAL — or unrecognized (SS2.13.1) — session's abnormal death
|
|
382
|
+
* discharges the obligation in full.
|
|
383
|
+
*/
|
|
384
|
+
hostExited(exit) {
|
|
385
|
+
// A second notification of the SAME death (a client can learn of it from
|
|
386
|
+
// both the exit promise and a transport EOF) must report the same thing.
|
|
387
|
+
// The primitives are only side-effect-idempotent: `discardEphemeral`
|
|
388
|
+
// drains the set, so its RETURN is a one-shot delta and a client reading
|
|
389
|
+
// the later notification would silently lose the retired inputs.
|
|
390
|
+
if (this.#discharge !== undefined) {
|
|
391
|
+
// END-OF-DRAIN SWEEP. A durable death does not stop the fold (FM-001),
|
|
392
|
+
// so between the first notification and this one the drain can mint
|
|
393
|
+
// handles from `turn/started` frames whose terminals never arrive.
|
|
394
|
+
// Those are left pending by `#handle` on purpose — a delivered terminal
|
|
395
|
+
// must be able to win — but a SECOND notification means the drain is
|
|
396
|
+
// over and nothing more is coming, so anything still unsettled would
|
|
397
|
+
// hang forever: the SS3.1.4 trap INV-014 forbids.
|
|
398
|
+
//
|
|
399
|
+
// Safe precisely because `fail()`/`settleTerminalUnknown()` no-op on an
|
|
400
|
+
// already-settled handle: a terminal the drain DID deliver keeps its
|
|
401
|
+
// win, so this cannot undo the order-independence it sits beside.
|
|
402
|
+
this.#sweepUnsettledAfterDrain();
|
|
403
|
+
return this.#discharge;
|
|
404
|
+
}
|
|
405
|
+
const profile = this.#durability;
|
|
406
|
+
if (!isAbnormalHostDeath(exit))
|
|
407
|
+
return { exit, kind: "notADeath", profile };
|
|
408
|
+
if (survivesHostDeath(profile)) {
|
|
409
|
+
// FM-001: the stores are left exactly as observed — the terminals arrive
|
|
410
|
+
// on resume — so only the waiters are told.
|
|
411
|
+
const died = new MuseHostDiedError(exit);
|
|
412
|
+
this.#deathError = died;
|
|
413
|
+
for (const handle of this.#turns.values())
|
|
414
|
+
handle.fail(died);
|
|
415
|
+
this.#discharge = { exit, kind: "durableDeath", profile };
|
|
416
|
+
return this.#discharge;
|
|
417
|
+
}
|
|
418
|
+
const retiredCommands = this.#pending.discardEphemeral();
|
|
419
|
+
const terminalUnknownItems = this.#fold.markEphemeralHostDeath(isItemInProgress);
|
|
420
|
+
for (const handle of this.#turns.values())
|
|
421
|
+
handle.settleTerminalUnknown();
|
|
422
|
+
// SS2.13.3b "do not attempt to reattach". `discardEphemeral` already wrote
|
|
423
|
+
// the commandIds into the shared set (it holds it by reference); the
|
|
424
|
+
// sessionId is this layer's to record, because the pending set does not
|
|
425
|
+
// know one. `MuseClient.resumeSession` is the reader.
|
|
426
|
+
this.#discarded?.sessionIds.add(this.sessionId);
|
|
427
|
+
this.#submit.forgetRetired(retiredCommands);
|
|
428
|
+
this.#discharge = {
|
|
429
|
+
exit,
|
|
430
|
+
kind: "discharged",
|
|
431
|
+
profile,
|
|
432
|
+
retiredCommands,
|
|
433
|
+
terminalUnknownItems,
|
|
434
|
+
};
|
|
435
|
+
return this.#discharge;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Settle everything the drain left open. DURABLE ONLY, and `#deathError` is
|
|
439
|
+
* set iff a durable death latched, so the condition is the discriminator.
|
|
440
|
+
*
|
|
441
|
+
* There is deliberately no ephemeral arm: after a discharge the first
|
|
442
|
+
* notification settled every held handle, `apply()` refuses before a frame
|
|
443
|
+
* can mint one, and `turn()` settles a fresh mint on the spot — so no
|
|
444
|
+
* unsettled handle can exist for it to reach. An arm for that state would be
|
|
445
|
+
* untestable defense for something no producer reaches (Constitution XI) and
|
|
446
|
+
* a mutant that survives by construction.
|
|
447
|
+
*/
|
|
448
|
+
#sweepUnsettledAfterDrain() {
|
|
449
|
+
if (this.#deathError === undefined)
|
|
450
|
+
return;
|
|
451
|
+
for (const handle of this.#turns.values())
|
|
452
|
+
handle.fail(this.#deathError);
|
|
453
|
+
}
|
|
454
|
+
// ---- FR-018 submit verb (T030 obligation a) -----------------------------
|
|
455
|
+
/**
|
|
456
|
+
* Submit a user turn (tdd SS3.2) and hand back THIS session's handle for the
|
|
457
|
+
* turn the ack named.
|
|
458
|
+
*
|
|
459
|
+
* The returned handle is the one `apply` already routes events to — not a
|
|
460
|
+
* second one minted beside it, which would compare equal on every field and
|
|
461
|
+
* then never receive an item, a delta, or a terminal.
|
|
462
|
+
*
|
|
463
|
+
* The wire shaping, the optimistic SS4.13 entry, and the settle-on-rejection
|
|
464
|
+
* rule all live in `TurnSubmitter`; what is decided HERE is the one thing that
|
|
465
|
+
* needs the fold: the entry's insertion point, which is the last item this
|
|
466
|
+
* session holds at submission time.
|
|
467
|
+
*/
|
|
468
|
+
async sendUserTurn(options) {
|
|
469
|
+
const ack = await this.#submit.submit(options, this.#lastFoldedItemId());
|
|
470
|
+
return this.#handle(ack.turnId);
|
|
471
|
+
}
|
|
472
|
+
// ---- FR-019 approval round trip (T031) ----------------------------------
|
|
473
|
+
/** Answer approvals with `handler` (FR-019). See `ApprovalRouter`. */
|
|
474
|
+
onApproval(handler) {
|
|
475
|
+
this.#approvals.onApproval(handler);
|
|
476
|
+
}
|
|
477
|
+
/** Observe round trips that did not complete. See `ApprovalFailure`. */
|
|
478
|
+
onApprovalError(handler) {
|
|
479
|
+
this.#approvals.onApprovalError(handler);
|
|
480
|
+
}
|
|
481
|
+
// ---- consumer-driven SS4.13 retirement verbs ----------------------------
|
|
482
|
+
/**
|
|
483
|
+
* Stop the SS3.1.1 retry loop for one entry and take its input back
|
|
484
|
+
* (SS4.13 "Abandoned").
|
|
485
|
+
*
|
|
486
|
+
* A `Session` method rather than a `pending` view member: the retirement
|
|
487
|
+
* must also prune the submitter's replay memory, or the abandoned command's
|
|
488
|
+
* `turn/start` params live for the session's lifetime — unbounded over the
|
|
489
|
+
* sanctioned submit-then-abandon flow.
|
|
490
|
+
*/
|
|
491
|
+
stopRetrying(commandId) {
|
|
492
|
+
const retired = this.#pending.stopRetrying(commandId);
|
|
493
|
+
if (retired !== undefined)
|
|
494
|
+
this.#submit.forgetRetired([retired]);
|
|
495
|
+
return retired;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Feed a consumer-driven replay's answer through the live SS4.13 settlement
|
|
499
|
+
* rules. Wrapped for the same replay-memory bookkeeping as `stopRetrying`;
|
|
500
|
+
* `"held"` means the answer settled nothing and the entry stays.
|
|
501
|
+
*/
|
|
502
|
+
replayAnswered(commandId, answer) {
|
|
503
|
+
const settled = this.#pending.replayAnswered(commandId, answer);
|
|
504
|
+
if (settled !== "held")
|
|
505
|
+
this.#submit.forgetRetired([settled]);
|
|
506
|
+
return settled;
|
|
507
|
+
}
|
|
508
|
+
// ---- SS4.13 drivers that need client->server I/O (T030 obligation d) ----
|
|
509
|
+
/**
|
|
510
|
+
* Resolve a snapshot join by performing the I/O its plan demands (SS4.13,
|
|
511
|
+
* SS4.9): same-`commandId` resubmits FIRST, then the replays, then the
|
|
512
|
+
* reclaimed-signature verdict.
|
|
513
|
+
*
|
|
514
|
+
* The order is normative, not incidental: SS4.13 requires an unacked entry be
|
|
515
|
+
* resubmitted "before any retire-to-composer", because a join miss does not
|
|
516
|
+
* prove the intake was never written.
|
|
517
|
+
*
|
|
518
|
+
* `PendingCommandSet.joinSnapshot` stays available on `pending` for a
|
|
519
|
+
* fold-only consumer that performs its own I/O; this verb is for the wired
|
|
520
|
+
* case and therefore requires a connection even when the plan turns out empty.
|
|
521
|
+
*/
|
|
522
|
+
async resolveSnapshotJoin(facts) {
|
|
523
|
+
this.#submit.requireConnection("resolveSnapshotJoin");
|
|
524
|
+
const plan = this.#pending.joinSnapshot(facts);
|
|
525
|
+
const out = [...plan.retirements];
|
|
526
|
+
for (const commandId of plan.mustResubmit)
|
|
527
|
+
await this.#submit.driveReplay(commandId, out);
|
|
528
|
+
const queuedCommandIds = facts.queuedTurns.map((queued) => queued.commandId);
|
|
529
|
+
for (const commandId of plan.mustReplay) {
|
|
530
|
+
const answer = await this.#submit.replay(commandId);
|
|
531
|
+
if (answer === undefined)
|
|
532
|
+
continue;
|
|
533
|
+
// The snapshot is what makes the reclaimed signature decidable, so this
|
|
534
|
+
// path uses `resolveReplayAtJoin` rather than the live `replayAnswered`.
|
|
535
|
+
const settled = this.#pending.resolveReplayAtJoin(commandId, answer, queuedCommandIds);
|
|
536
|
+
if (settled !== "held")
|
|
537
|
+
out.push(settled);
|
|
538
|
+
}
|
|
539
|
+
this.#submit.forgetRetired(out);
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Resolve a reconnect with NO snapshot (`history.mode: "none"`, the default
|
|
544
|
+
* cursor-resume path). Reconnect itself is the trigger: replay each acked
|
|
545
|
+
* entry once, resubmit each unacked entry's SAME `commandId` once.
|
|
546
|
+
*/
|
|
547
|
+
async resolveReconnect() {
|
|
548
|
+
this.#submit.requireConnection("resolveReconnect");
|
|
549
|
+
const plan = this.#pending.reconnectedWithoutSnapshot();
|
|
550
|
+
const out = [];
|
|
551
|
+
for (const commandId of plan.mustResubmit)
|
|
552
|
+
await this.#submit.driveReplay(commandId, out);
|
|
553
|
+
for (const commandId of plan.mustReplay)
|
|
554
|
+
await this.#submit.driveReplay(commandId, out);
|
|
555
|
+
this.#submit.forgetRetired(out);
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
// ---- event-driven I/O ---------------------------------------------------
|
|
559
|
+
#lastFoldedItemId() {
|
|
560
|
+
const items = this.#fold.items.list();
|
|
561
|
+
return items.length === 0 ? null : (items[items.length - 1]?.itemId ?? null);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* SS4.13 "Queue movement": a `turn/started` or `turn/completed` for a turn
|
|
565
|
+
* that is not an entry's own decides that entry's fate at a launch boundary,
|
|
566
|
+
* and SS4.3 carries no view event for a command-intake settlement — so the
|
|
567
|
+
* only way to learn it is to replay.
|
|
568
|
+
*/
|
|
569
|
+
#queueMoved(turnId, tasks) {
|
|
570
|
+
if (!this.#submit.wired)
|
|
571
|
+
return;
|
|
572
|
+
const commandIds = this.#pending.observedQueueMovement(turnId);
|
|
573
|
+
if (commandIds.length === 0)
|
|
574
|
+
return;
|
|
575
|
+
tasks.push(this.#driveQueueReplays(commandIds));
|
|
576
|
+
}
|
|
577
|
+
async #driveQueueReplays(commandIds) {
|
|
578
|
+
const out = [];
|
|
579
|
+
for (const commandId of commandIds) {
|
|
580
|
+
try {
|
|
581
|
+
await this.#submit.driveReplay(commandId, out);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// Swallowed on purpose, and narrowly: `replay` already converts a
|
|
585
|
+
// server-authored MSP error into an answer, so reaching here means the
|
|
586
|
+
// TRANSPORT failed. That proves nothing about the intake, so the entry
|
|
587
|
+
// holds — and the authoritative report is the host-death notification
|
|
588
|
+
// already on its way. Rethrowing would reject an `io` most consumers
|
|
589
|
+
// never await, i.e. crash the embedder for a fact it is about to be
|
|
590
|
+
// told properly.
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
this.#submit.forgetRetired(out);
|
|
594
|
+
return out;
|
|
595
|
+
}
|
|
596
|
+
#approvalRequested(params, tasks) {
|
|
597
|
+
const decided = this.#approvals.requested(params);
|
|
598
|
+
// An approval decision retires no pending command — it is not an SS4.13
|
|
599
|
+
// entry — but it still has to be awaitable through the same `io`, or a
|
|
600
|
+
// consumer has no barrier for the round trip it just triggered.
|
|
601
|
+
if (decided !== undefined)
|
|
602
|
+
tasks.push(decided.then(() => []));
|
|
603
|
+
}
|
|
604
|
+
/** The #36949 stage-advance re-route; the merge lives on `ApprovalRouter.updated`. */
|
|
605
|
+
#approvalUpdated(requested, params, tasks) {
|
|
606
|
+
const decided = this.#approvals.updated(requested, params);
|
|
607
|
+
// Awaitable through `io`, same as `#approvalRequested` above.
|
|
608
|
+
if (decided !== undefined)
|
|
609
|
+
tasks.push(decided.then(() => []));
|
|
610
|
+
}
|
|
611
|
+
#itemsOfTurn(turnId) {
|
|
612
|
+
return this.#fold.items.list().filter((item) => item.turnId === turnId);
|
|
613
|
+
}
|
|
614
|
+
#itemFolded(item, retirements) {
|
|
615
|
+
// SS4.13 "Materialized": the `commandId`-bearing `userMessage` landed, so
|
|
616
|
+
// the optimistic entry is replaced by the real item at the item's own
|
|
617
|
+
// position. `itemId` is the ITEM's id — item ids and command ids are
|
|
618
|
+
// separate namespaces on the wire.
|
|
619
|
+
if (item.kind === USER_MESSAGE && item.commandId !== undefined) {
|
|
620
|
+
const materialized = this.#pending.observedUserMessage(item.commandId, item.itemId);
|
|
621
|
+
if (materialized !== undefined)
|
|
622
|
+
retirements.push(materialized);
|
|
623
|
+
}
|
|
624
|
+
// `!= null`, not `!== undefined`: `userShell` items carry `turnId: null`
|
|
625
|
+
// on the wire (spec Edge Cases; tdd SS4.5.6 example). A `!== undefined`
|
|
626
|
+
// guard let null through and minted a phantom turn keyed `null`,
|
|
627
|
+
// attributing the userShell item and its deltas to it (#23557).
|
|
628
|
+
const turnId = item.turnId;
|
|
629
|
+
if (turnId != null)
|
|
630
|
+
this.#handle(turnId).pushItem(item);
|
|
631
|
+
const buffered = this.#unattributedDeltas.get(item.itemId);
|
|
632
|
+
if (buffered === undefined)
|
|
633
|
+
return;
|
|
634
|
+
this.#unattributedDeltas.delete(item.itemId);
|
|
635
|
+
if (turnId == null)
|
|
636
|
+
return; // `userShell` is not turn-scoped
|
|
637
|
+
const handle = this.#handle(turnId);
|
|
638
|
+
for (const params of buffered)
|
|
639
|
+
handle.pushDelta(params);
|
|
640
|
+
}
|
|
641
|
+
#deltaFolded(params) {
|
|
642
|
+
const held = this.#fold.items.get(params.itemId);
|
|
643
|
+
if (held === undefined) {
|
|
644
|
+
const buffered = this.#unattributedDeltas.get(params.itemId);
|
|
645
|
+
if (buffered === undefined)
|
|
646
|
+
this.#unattributedDeltas.set(params.itemId, [params]);
|
|
647
|
+
else
|
|
648
|
+
buffered.push(params);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (held.turnId != null)
|
|
652
|
+
this.#handle(held.turnId).pushDelta(params);
|
|
653
|
+
}
|
|
654
|
+
}
|