muse-code-sdk 1.3.0__py3-none-any.whl
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.
- muse_code/__init__.py +129 -0
- muse_code/connection/__init__.py +49 -0
- muse_code/connection/connection.py +905 -0
- muse_code/connection/discovery.py +55 -0
- muse_code/connection/spawn.py +1067 -0
- muse_code/connection/validation.py +149 -0
- muse_code/errors.py +199 -0
- muse_code/facade/__init__.py +89 -0
- muse_code/facade/approval.py +279 -0
- muse_code/facade/client.py +444 -0
- muse_code/facade/discarded.py +45 -0
- muse_code/facade/gap_fill.py +483 -0
- muse_code/facade/host_death.py +254 -0
- muse_code/facade/session.py +977 -0
- muse_code/facade/turn_handle.py +440 -0
- muse_code/facade/turn_submit.py +289 -0
- muse_code/fold/__init__.py +82 -0
- muse_code/fold/item_store.py +268 -0
- muse_code/fold/session_fold.py +785 -0
- muse_code/fold/state_store.py +141 -0
- muse_code/pending/__init__.py +53 -0
- muse_code/pending/pending_command_set.py +842 -0
- muse_code/py.typed +0 -0
- muse_code/replay.py +144 -0
- muse_code/sync_facade.py +493 -0
- muse_code_sdk-1.3.0.dist-info/METADATA +47 -0
- muse_code_sdk-1.3.0.dist-info/RECORD +30 -0
- muse_code_sdk-1.3.0.dist-info/WHEEL +5 -0
- muse_code_sdk-1.3.0.dist-info/licenses/LICENSE +21 -0
- muse_code_sdk-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
"""``Session`` — the SS7.1 facade's composition of the transport-less core
|
|
2
|
+
(spec 638 FR-638-019a / T030, FR-638-019d / T033). Port of the session half of
|
|
3
|
+
``clients/sdk-ts/src/facade/session.ts``.
|
|
4
|
+
|
|
5
|
+
It owns a :class:`~muse_code.fold.SessionFold` and a
|
|
6
|
+
:class:`~muse_code.pending.PendingCommandSet`, routes folded view events to
|
|
7
|
+
per-turn handles, and discharges the ephemeral host-death obligation. Like
|
|
8
|
+
everything else in this SDK it holds no durable state (INV-638-05): every fact
|
|
9
|
+
it reports came from a server event or from the two stores it composes.
|
|
10
|
+
|
|
11
|
+
The FR-638-019b approval round trip lives in ``approval.py`` (T031) and the
|
|
12
|
+
FR-638-019c SS4.8 splice-fill in ``gap_fill.py`` (T032); this layer composes
|
|
13
|
+
both because the round trips need client->server I/O and the buffer the fill
|
|
14
|
+
splices is the same routing path the iterators read.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import (
|
|
22
|
+
Any,
|
|
23
|
+
Awaitable,
|
|
24
|
+
Generic,
|
|
25
|
+
Iterable,
|
|
26
|
+
List,
|
|
27
|
+
Literal,
|
|
28
|
+
Mapping,
|
|
29
|
+
Protocol,
|
|
30
|
+
TypeVar,
|
|
31
|
+
Union,
|
|
32
|
+
cast,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from muse_code_msp import (
|
|
36
|
+
ITEM_KIND_KNOWN_VALUES,
|
|
37
|
+
ApprovalRequestParams,
|
|
38
|
+
Item,
|
|
39
|
+
ItemDeltaParams,
|
|
40
|
+
TurnCompletedParams,
|
|
41
|
+
TurnUnqueuedParams,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
from ..connection.connection import Connection
|
|
45
|
+
from ..fold.item_store import IgnoredStaleRevision
|
|
46
|
+
from ..fold.session_fold import (
|
|
47
|
+
ApprovalPending,
|
|
48
|
+
DeliveryGap,
|
|
49
|
+
FoldItems,
|
|
50
|
+
FoldOutcome,
|
|
51
|
+
FoldSessionState,
|
|
52
|
+
ItemFold,
|
|
53
|
+
Params,
|
|
54
|
+
PendingApproval,
|
|
55
|
+
SessionFold,
|
|
56
|
+
TurnEntry,
|
|
57
|
+
)
|
|
58
|
+
from ..pending.pending_command_set import (
|
|
59
|
+
CommandErrorResponse,
|
|
60
|
+
Held,
|
|
61
|
+
PendingCommandAck,
|
|
62
|
+
PendingCommandEntry,
|
|
63
|
+
PendingCommandSet,
|
|
64
|
+
PendingJoinPlan,
|
|
65
|
+
PendingRetirement,
|
|
66
|
+
ReplayAnswer,
|
|
67
|
+
SnapshotJoinFacts,
|
|
68
|
+
)
|
|
69
|
+
from .approval import (
|
|
70
|
+
ApprovalFailureHandler,
|
|
71
|
+
ApprovalHandler,
|
|
72
|
+
ApprovalRouter,
|
|
73
|
+
)
|
|
74
|
+
from .discarded import DiscardedSessions
|
|
75
|
+
from .gap_fill import (
|
|
76
|
+
DrainSink,
|
|
77
|
+
GapFillFailureHandler,
|
|
78
|
+
GapFiller,
|
|
79
|
+
GapFillerOptions,
|
|
80
|
+
ResolvedIo,
|
|
81
|
+
WireFrame,
|
|
82
|
+
)
|
|
83
|
+
from .host_death import (
|
|
84
|
+
HostDeathDischarge,
|
|
85
|
+
HostDeathNotification,
|
|
86
|
+
MuseHostDiedError,
|
|
87
|
+
SessionDurabilityProfile,
|
|
88
|
+
_Discharged,
|
|
89
|
+
_DurableDeath,
|
|
90
|
+
_NotADeath,
|
|
91
|
+
is_abnormal_host_death,
|
|
92
|
+
is_item_in_progress,
|
|
93
|
+
survives_host_death,
|
|
94
|
+
)
|
|
95
|
+
from .turn_handle import Turn, TurnHandle
|
|
96
|
+
from .turn_submit import SendUserTurnOptions, TurnSubmitter
|
|
97
|
+
|
|
98
|
+
_I = TypeVar("_I")
|
|
99
|
+
|
|
100
|
+
_USER_MESSAGE = "userMessage"
|
|
101
|
+
assert _USER_MESSAGE in ITEM_KIND_KNOWN_VALUES
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SessionFoldView(Protocol):
|
|
105
|
+
"""The fold as a consumer may READ it — the TS ``SessionFoldView`` allowlist.
|
|
106
|
+
|
|
107
|
+
``Session.fold`` returns the concrete :class:`SessionFold`, but this narrow
|
|
108
|
+
type is what the consumer contract advertises: the fold's own mutators
|
|
109
|
+
(``apply``, ``mark_ephemeral_host_death``, ``gap_filled``) are hazardous in
|
|
110
|
+
a consumer's hands — ``apply`` would fold an event while skipping turn
|
|
111
|
+
routing and the discard latch, and ``gap_filled`` would report a hole
|
|
112
|
+
closed that nothing filled — so they stay off the read surface. The same
|
|
113
|
+
seal ``turn()`` gets from the :class:`Turn` protocol.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def items(self) -> FoldItems:
|
|
118
|
+
"""Items, snapshot-only: the mutators are ``apply()``'s, not yours."""
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def session_state(self) -> FoldSessionState:
|
|
123
|
+
"""The session-state families, keyed by notification method name."""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def active_turn_id(self) -> str | None:
|
|
128
|
+
"""The turn currently running, if one is."""
|
|
129
|
+
...
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def pending_gap(self) -> Mapping[str, str] | None:
|
|
133
|
+
"""The outstanding delivery hole, or ``None``."""
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def current(self) -> bool:
|
|
138
|
+
"""Is this fold current with the session view?"""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
def turns(self) -> list[TurnEntry]:
|
|
142
|
+
"""Turns in first-observed order."""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
def turn(self, turn_id: str) -> TurnEntry | None:
|
|
146
|
+
"""One turn's entry, or ``None`` if the fold never observed it."""
|
|
147
|
+
...
|
|
148
|
+
|
|
149
|
+
def pending_approvals(self) -> list[PendingApproval]:
|
|
150
|
+
"""Approvals awaiting a durable terminal, in first-observed order."""
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
def resolved_approvals(self) -> list[Params]:
|
|
154
|
+
"""The winning durable terminal per approval, in first-observed order."""
|
|
155
|
+
...
|
|
156
|
+
|
|
157
|
+
def pending_user_inputs(self) -> list[Params]:
|
|
158
|
+
"""Prompts awaiting a durable settlement, in first-observed order."""
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
def settled_user_inputs(self) -> list[Params]:
|
|
162
|
+
"""The winning durable settlement per prompt, in first-observed order."""
|
|
163
|
+
...
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class PendingCommandView(Protocol[_I]):
|
|
167
|
+
"""The pending set as a consumer may READ and safely DRIVE — the TS
|
|
168
|
+
``PendingCommandView`` allowlist.
|
|
169
|
+
|
|
170
|
+
``Session.pending`` returns the concrete :class:`PendingCommandSet`, but
|
|
171
|
+
this narrow type hides the three mutators ``Session`` drives itself
|
|
172
|
+
(``discard_ephemeral``, ``observed_reclaim``, ``observed_user_message``)
|
|
173
|
+
and the two re-exposed as ``Session`` methods (``stop_retrying``,
|
|
174
|
+
``replay_answered``): a consumer calling ``discard_ephemeral`` directly
|
|
175
|
+
drains and latches the set so the later real ``host_exited`` reports empty
|
|
176
|
+
retirements, and a bare ``stop_retrying``/``replay_answered`` skips
|
|
177
|
+
``TurnSubmitter.forget_retired`` and leaks the command's ``turn/start``
|
|
178
|
+
params in replay memory. The rest (a fold-only consumer performing its own
|
|
179
|
+
I/O) stays available.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def discarded(self) -> bool:
|
|
184
|
+
"""Whether the ephemeral discard has closed this set."""
|
|
185
|
+
...
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def size(self) -> int:
|
|
189
|
+
"""The number of pending entries."""
|
|
190
|
+
...
|
|
191
|
+
|
|
192
|
+
def submitted(
|
|
193
|
+
self,
|
|
194
|
+
command_id: str,
|
|
195
|
+
input: _I,
|
|
196
|
+
*,
|
|
197
|
+
display_text: str | None = ...,
|
|
198
|
+
anchor_after_item_id: str | None = ...,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Records a submission."""
|
|
201
|
+
...
|
|
202
|
+
|
|
203
|
+
def acked(self, command_id: str, ack: PendingCommandAck) -> None:
|
|
204
|
+
"""Records that the submit (or a replay) was acked."""
|
|
205
|
+
...
|
|
206
|
+
|
|
207
|
+
def ack_errored(
|
|
208
|
+
self, command_id: str, error: CommandErrorResponse
|
|
209
|
+
) -> PendingRetirement[_I] | Held:
|
|
210
|
+
"""The submit (or a replay) answered with an error."""
|
|
211
|
+
...
|
|
212
|
+
|
|
213
|
+
def observed_queue_movement(self, turn_id: str) -> tuple[str, ...]:
|
|
214
|
+
"""Queue movement: a turn event for a turn that is NOT the entry's own."""
|
|
215
|
+
...
|
|
216
|
+
|
|
217
|
+
def reconnected_without_snapshot(self) -> PendingJoinPlan[_I]:
|
|
218
|
+
"""Reconnect with NO snapshot (the default cursor-resume path)."""
|
|
219
|
+
...
|
|
220
|
+
|
|
221
|
+
def join_snapshot(self, facts: SnapshotJoinFacts) -> PendingJoinPlan[_I]:
|
|
222
|
+
"""Joins local entries against an authoritative snapshot."""
|
|
223
|
+
...
|
|
224
|
+
|
|
225
|
+
def resolve_replay_at_join(
|
|
226
|
+
self,
|
|
227
|
+
command_id: str,
|
|
228
|
+
answer: ReplayAnswer[_I],
|
|
229
|
+
snapshot_queued_command_ids: Iterable[str],
|
|
230
|
+
) -> PendingRetirement[_I] | Held:
|
|
231
|
+
"""Resolves a ``must_replay`` answer with the snapshot in hand."""
|
|
232
|
+
...
|
|
233
|
+
|
|
234
|
+
def list(self) -> tuple[PendingCommandEntry[_I], ...]:
|
|
235
|
+
"""Entries in render order."""
|
|
236
|
+
...
|
|
237
|
+
|
|
238
|
+
def get(self, command_id: str) -> PendingCommandEntry[_I] | None:
|
|
239
|
+
"""The entry snapshot for ``command_id``, or ``None``."""
|
|
240
|
+
...
|
|
241
|
+
|
|
242
|
+
def has(self, command_id: str) -> bool:
|
|
243
|
+
"""Whether an entry with ``command_id`` is pending."""
|
|
244
|
+
...
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class _StartOpening:
|
|
249
|
+
result: Mapping[str, Any]
|
|
250
|
+
verb: Literal["session/start"] = "session/start"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True)
|
|
254
|
+
class _ResumeOpening:
|
|
255
|
+
result: Mapping[str, Any]
|
|
256
|
+
verb: Literal["session/resume"] = "session/resume"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
SessionOpening = Union[_StartOpening, _ResumeOpening]
|
|
260
|
+
"""Which verb opened this session, and what it answered.
|
|
261
|
+
|
|
262
|
+
``MuseClient.start_session``/``resume_session`` resolve to a ``Session``, so
|
|
263
|
+
without this the typed wire result (``viewCursor``, the resume history, the
|
|
264
|
+
server's own ``sessionId``) would be swallowed by the wrapper. Discriminated by
|
|
265
|
+
the verb so a consumer that only handles resume cannot silently read a start
|
|
266
|
+
result's absent members.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass(frozen=True)
|
|
271
|
+
class SessionApplyRefusal:
|
|
272
|
+
"""``Session.apply``'s own refusal.
|
|
273
|
+
|
|
274
|
+
NOT a ``FoldOutcome``: the fold can never emit it, and putting it there
|
|
275
|
+
would promise a direct ``SessionFold`` consumer a refusal that seam does
|
|
276
|
+
not produce.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
kind: Literal["refusedSessionDiscarded"] = "refusedSessionDiscarded"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class SessionGapBuffered:
|
|
284
|
+
"""``Session.apply``'s other own verdict: a frame held while an SS4.8 fill
|
|
285
|
+
is in flight (FR-638-019c / T032).
|
|
286
|
+
|
|
287
|
+
NOT a ``FoldOutcome`` — the fold has not seen this frame yet and reporting
|
|
288
|
+
one would state a fold move that has not happened. The frame is not lost:
|
|
289
|
+
it is re-fed through this same path, in cursor order behind the paged
|
|
290
|
+
prefix, when the fill splices. The pause a consumer sees is exactly this.
|
|
291
|
+
|
|
292
|
+
Attributes:
|
|
293
|
+
method: The held frame's notification method name.
|
|
294
|
+
kind: The verdict discriminator, always ``"bufferedDuringGap"``.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
method: str
|
|
298
|
+
kind: Literal["bufferedDuringGap"] = "bufferedDuringGap"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True)
|
|
302
|
+
class SessionGapOverlap:
|
|
303
|
+
"""A live frame the SS4.8 fill already applied from a page — the other
|
|
304
|
+
half of the recipe's overlap discard.
|
|
305
|
+
|
|
306
|
+
The wire promises no order between a ``view/gap`` and the frame at its
|
|
307
|
+
``next``, so the live twin of an event the page served can arrive after
|
|
308
|
+
the fill finished, when the buffer is gone. Refused once, by cursor
|
|
309
|
+
equality, because folding it again would re-run its routing — a duplicate
|
|
310
|
+
SS4.13 queue-movement replay on the wire, which is exactly what the
|
|
311
|
+
discard exists to prevent.
|
|
312
|
+
|
|
313
|
+
Attributes:
|
|
314
|
+
method: The refused frame's notification method name.
|
|
315
|
+
view_cursor: The cursor the page already applied this event at.
|
|
316
|
+
kind: The verdict discriminator, always ``"ignoredGapOverlap"``.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
method: str
|
|
320
|
+
view_cursor: str
|
|
321
|
+
kind: Literal["ignoredGapOverlap"] = "ignoredGapOverlap"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass(frozen=True)
|
|
325
|
+
class SessionApplyOutcome(Generic[_I]):
|
|
326
|
+
"""What one ``Session.apply`` did.
|
|
327
|
+
|
|
328
|
+
Attributes:
|
|
329
|
+
fold: The fold's own outcome, or this layer's own verdict (the
|
|
330
|
+
discard refusal, the mid-fill buffering, the overlap refusal).
|
|
331
|
+
retirements: The SS4.13 pending-command retirements the event
|
|
332
|
+
triggered synchronously. ONE stated exception (FR-638-019c): a
|
|
333
|
+
frame HELD during an SS4.8 fill reports ``bufferedDuringGap`` with
|
|
334
|
+
no retirements, because at that moment nothing has folded — its
|
|
335
|
+
real effects surface on the ``io`` of the EARLIER ``view/gap``
|
|
336
|
+
apply that started the fill, never on the buffered frame's own
|
|
337
|
+
return. A second ``view/gap`` landing mid-fill rides the same
|
|
338
|
+
channel: it extends the running walk, so its own ``io`` is empty.
|
|
339
|
+
io: The client->server I/O this event triggered, settled — resolves to
|
|
340
|
+
the retirements that I/O produced (the SS4.13 replays queue
|
|
341
|
+
movement demands, FR-638-019b's ``approval/decide`` submission,
|
|
342
|
+
and the FR-638-019c fill). A SEPARATE channel from
|
|
343
|
+
``retirements`` because ``apply`` is synchronous by contract: a
|
|
344
|
+
consumer's notification pump must not await a round trip before it
|
|
345
|
+
can fold the next frame. IT NEVER REJECTS — a replay whose
|
|
346
|
+
transport failed proves nothing about the intake, so its entry
|
|
347
|
+
holds and the authoritative report is the host-death notification
|
|
348
|
+
already on its way; an approval failure reports to
|
|
349
|
+
``on_approval_error`` and a fill failure to ``on_gap_error``.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
fold: Union[FoldOutcome, SessionApplyRefusal, SessionGapBuffered, SessionGapOverlap]
|
|
353
|
+
retirements: tuple[PendingRetirement[_I], ...]
|
|
354
|
+
io: Awaitable[tuple[PendingRetirement[_I], ...]]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class Session(Generic[_I]):
|
|
358
|
+
"""One conversation with the agent.
|
|
359
|
+
|
|
360
|
+
A session folds every server event into readable state — items, turns,
|
|
361
|
+
session facts, pending approvals — and submits your side of the exchange.
|
|
362
|
+
Get one from :meth:`MuseClient.start_session` or ``resume_session``, then
|
|
363
|
+
``send_user_turn`` to talk, ``turn`` to follow a turn's items to its
|
|
364
|
+
outcome, and (from slice T031) ``on_approval`` to answer permission
|
|
365
|
+
requests.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
def __init__(
|
|
369
|
+
self,
|
|
370
|
+
session_id: str,
|
|
371
|
+
durability: SessionDurabilityProfile,
|
|
372
|
+
*,
|
|
373
|
+
connection: Connection | None = None,
|
|
374
|
+
discarded: DiscardedSessions | None = None,
|
|
375
|
+
opening: SessionOpening | None = None,
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Builds a session over the transport-less core.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
session_id: The server-named session id every event must carry.
|
|
381
|
+
durability: Whether this session survives its host (SS2.13.3b).
|
|
382
|
+
Required, not defaulted: it decides what happens to every
|
|
383
|
+
in-flight item and command when the host dies, and a default
|
|
384
|
+
would let a caller skip reading the handshake and silently
|
|
385
|
+
inherit the wrong obligation. ``read_session_durability`` turns
|
|
386
|
+
an ``InitializeResult`` into it.
|
|
387
|
+
connection: The wire seam the submit verbs author through. OPTIONAL
|
|
388
|
+
because fold-only construction is a real current use: every
|
|
389
|
+
transport-less arm in this package builds a ``Session`` with no
|
|
390
|
+
host at all.
|
|
391
|
+
discarded: What an ephemeral host death already discarded, shared
|
|
392
|
+
across the sessions ONE client opened (T030 obligation (c)).
|
|
393
|
+
Omitted, this session remembers only its own discards.
|
|
394
|
+
opening: Set by ``MuseClient``; see :data:`SessionOpening`.
|
|
395
|
+
"""
|
|
396
|
+
self.session_id = session_id
|
|
397
|
+
self.opening = opening
|
|
398
|
+
self._durability = durability
|
|
399
|
+
self._discarded = discarded
|
|
400
|
+
self._connection = connection
|
|
401
|
+
self._fold: SessionFold = SessionFold()
|
|
402
|
+
self._pending: PendingCommandSet[_I] = PendingCommandSet(
|
|
403
|
+
discarded_command_ids=(
|
|
404
|
+
discarded.command_ids if discarded is not None else set()
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
self._submit: TurnSubmitter[_I] = TurnSubmitter(
|
|
408
|
+
session_id, connection, self._pending
|
|
409
|
+
)
|
|
410
|
+
# The FR-638-019b round trip (``approval.py``).
|
|
411
|
+
self._approvals = ApprovalRouter(session_id, connection)
|
|
412
|
+
# The FR-638-019c splice-fill (``gap_fill.py``).
|
|
413
|
+
self._gaps: GapFiller[_I] = GapFiller(
|
|
414
|
+
GapFillerOptions(
|
|
415
|
+
session_id=session_id,
|
|
416
|
+
connection=connection,
|
|
417
|
+
fold=self._fold,
|
|
418
|
+
apply=self._fold_and_route,
|
|
419
|
+
discarded=lambda: (
|
|
420
|
+
self._discharge is not None
|
|
421
|
+
and self._discharge.kind == "discharged"
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
self._turns: dict[str, TurnHandle] = {}
|
|
426
|
+
# ``item/delta`` frames whose item the fold does not hold yet: the
|
|
427
|
+
# store buffers the delta TEXT (SS4.7.3) but a delta's TURN is equally
|
|
428
|
+
# unknowable until the item lands, so the frames wait here and are
|
|
429
|
+
# attributed when it does. Bounded by the durable ``item/completed``
|
|
430
|
+
# that always lands the item.
|
|
431
|
+
self._unattributed_deltas: dict[str, List[ItemDeltaParams]] = {}
|
|
432
|
+
# The first discharge, replayed verbatim on every later call.
|
|
433
|
+
self._discharge: HostDeathDischarge[_I] | None = None
|
|
434
|
+
# Set on a durable abnormal death, so late handles inherit the failure.
|
|
435
|
+
self._death_error: MuseHostDiedError | None = None
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def durability(self) -> SessionDurabilityProfile:
|
|
439
|
+
"""The profile this session discharges a host death under."""
|
|
440
|
+
return self._durability
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def fold(self) -> SessionFoldView:
|
|
444
|
+
"""The read view of the fold; events enter through :meth:`apply` so
|
|
445
|
+
turn routing cannot be skipped (the mutators are off this surface)."""
|
|
446
|
+
return self._fold
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def pending(self) -> PendingCommandView[_I]:
|
|
450
|
+
"""The SS4.13 set's read/safe-drive view. The three mutators ``Session``
|
|
451
|
+
drives itself and the two re-exposed as session methods are hidden;
|
|
452
|
+
drive those through this session's verbs, which keep the submitter's
|
|
453
|
+
replay memory in step."""
|
|
454
|
+
return self._pending
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def known_turn_count(self) -> int:
|
|
458
|
+
"""How many turns this session has minted a handle for.
|
|
459
|
+
|
|
460
|
+
Distinct from ``fold.turns()``, which counts turns the WIRE named: a
|
|
461
|
+
handle is also minted by ``turn()`` and by item routing, so this
|
|
462
|
+
reveals a phantom turn minted from a bad key (a ``userShell`` item's
|
|
463
|
+
null ``turnId``). Observation-only.
|
|
464
|
+
"""
|
|
465
|
+
return len(self._turns)
|
|
466
|
+
|
|
467
|
+
def apply(self, event: Mapping[str, Any]) -> SessionApplyOutcome[_I]:
|
|
468
|
+
"""Fold one view event, then fan it out to the turn handles that want it.
|
|
469
|
+
|
|
470
|
+
Raises on an event for another session: every view-event params type
|
|
471
|
+
carries a required ``sessionId``, so on a multiplexed feed one
|
|
472
|
+
misrouted frame would silently corrupt this transcript.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
event: A wire notification mapping (``method`` + ``params``).
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
The fold outcome, the synchronous retirements, and the awaitable
|
|
479
|
+
``io`` channel.
|
|
480
|
+
|
|
481
|
+
Raises:
|
|
482
|
+
MuseForeignSessionError: The frame names a different session.
|
|
483
|
+
"""
|
|
484
|
+
# Only enforce when the frame actually NAMES a session: a newer host's
|
|
485
|
+
# unknown notification reaches this at runtime and SS1.5.4 tolerates it
|
|
486
|
+
# losslessly, so an unconditional read would throw on it.
|
|
487
|
+
params = event.get("params")
|
|
488
|
+
named = params.get("sessionId") if isinstance(params, dict) else None
|
|
489
|
+
if isinstance(named, str) and named != self.session_id:
|
|
490
|
+
from ..errors import MuseForeignSessionError
|
|
491
|
+
|
|
492
|
+
raise MuseForeignSessionError(
|
|
493
|
+
f"event {event.get('method')} belongs to session {named}, "
|
|
494
|
+
f"not {self.session_id}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# AFTER the foreign check: a discarded session must still not fold
|
|
498
|
+
# (ItemStore's own guard covers only ITEM events, so a post-discharge
|
|
499
|
+
# ``turn/completed`` once settled a fresh turn on a discarded session).
|
|
500
|
+
# REFUSED, not thrown — throwing killed the consumer's pump on a single
|
|
501
|
+
# trailing drain frame. SS2.13.3b requires that nothing fold; it does
|
|
502
|
+
# not require an exception.
|
|
503
|
+
if self._discharge is not None and self._discharge.kind == "discharged":
|
|
504
|
+
return SessionApplyOutcome(
|
|
505
|
+
fold=SessionApplyRefusal(),
|
|
506
|
+
retirements=(),
|
|
507
|
+
io=_settled_io([]),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# An SS4.8 fill is in flight, so the live tail waits for the paged
|
|
511
|
+
# prefix (tdd SS4.8's "buffer live events … splice the buffer after
|
|
512
|
+
# the paged prefix"). Folding it now would run the fold backwards when
|
|
513
|
+
# the older paged events land, and would hand the iterators the tail
|
|
514
|
+
# before the hole they follow. The MARKER itself is exempt: a second
|
|
515
|
+
# overflow has to reach the fold to extend the outstanding hole, or
|
|
516
|
+
# the walk stops short of it and reports current with a hole still
|
|
517
|
+
# open.
|
|
518
|
+
method = str(event.get("method"))
|
|
519
|
+
if self._gaps.filling and method != "view/gap":
|
|
520
|
+
self._gaps.hold(event)
|
|
521
|
+
return SessionApplyOutcome(
|
|
522
|
+
fold=SessionGapBuffered(method=method),
|
|
523
|
+
retirements=(),
|
|
524
|
+
io=_settled_io([]),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# The overlap's late half: a frame a completed fill already applied
|
|
528
|
+
# from a page, whose live twin the wire delivered afterwards. Reached
|
|
529
|
+
# only when NO fill is running — during one the buffer takes the frame
|
|
530
|
+
# first, and the drain refuses it there against both its own paged
|
|
531
|
+
# cursors and the persistent set.
|
|
532
|
+
twin = self._gaps.claim_served_twin(event)
|
|
533
|
+
if twin is not None:
|
|
534
|
+
return SessionApplyOutcome(
|
|
535
|
+
fold=SessionGapOverlap(method=method, view_cursor=twin),
|
|
536
|
+
retirements=(),
|
|
537
|
+
io=_settled_io([]),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
sink: DrainSink[_I] = DrainSink(retirements=[], tasks=[])
|
|
541
|
+
outcome = self._fold_and_route(event, sink)
|
|
542
|
+
# FR-638-019c: recover before this fold may call itself current again.
|
|
543
|
+
# Started HERE rather than inside the routing arms because it is the
|
|
544
|
+
# one arm besides approvals that authors client→server I/O off a
|
|
545
|
+
# notification, and ``io`` is the channel that carries it. ``None``
|
|
546
|
+
# means no walk started (mid-fill coalesce, or the fold-only failure
|
|
547
|
+
# already reported synchronously) — appending an already-settled stub
|
|
548
|
+
# instead would force ``_settled_io``'s gather arm, which needs a
|
|
549
|
+
# running loop a fold-only ``apply`` may not have.
|
|
550
|
+
if isinstance(outcome, DeliveryGap):
|
|
551
|
+
fill = self._gaps.start()
|
|
552
|
+
if fill is not None:
|
|
553
|
+
sink.tasks.append(fill)
|
|
554
|
+
return SessionApplyOutcome(
|
|
555
|
+
fold=outcome,
|
|
556
|
+
retirements=tuple(sink.retirements),
|
|
557
|
+
io=_settled_io(sink.tasks),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def on_gap_error(self, handler: GapFillFailureHandler) -> None:
|
|
561
|
+
"""Observe SS4.8 fills that did not complete. See
|
|
562
|
+
:class:`~muse_code.errors.MuseGapFillError`."""
|
|
563
|
+
self._gaps.on_error(handler)
|
|
564
|
+
|
|
565
|
+
def _fold_and_route(
|
|
566
|
+
self, event: WireFrame, sink: DrainSink[_I]
|
|
567
|
+
) -> FoldOutcome:
|
|
568
|
+
# The shared path: ``apply`` uses it for a live frame, and ``GapFiller``
|
|
569
|
+
# uses it to re-feed the paged prefix and the buffered tail. One path
|
|
570
|
+
# is what makes a spliced frame indistinguishable from a live one to
|
|
571
|
+
# everything downstream — the fold, the iterators, and the SS4.13
|
|
572
|
+
# retirements.
|
|
573
|
+
retirements: List[PendingRetirement[_I]] = []
|
|
574
|
+
outcome = self._fold.apply(event)
|
|
575
|
+
# The fold drops a params-less frame as IgnoredMissingParams (SS1.5.4);
|
|
576
|
+
# the arms below read ``params`` on the turn/item arms, so return early.
|
|
577
|
+
method = str(event.get("method"))
|
|
578
|
+
params = event.get("params")
|
|
579
|
+
if not isinstance(params, dict):
|
|
580
|
+
return outcome
|
|
581
|
+
|
|
582
|
+
if method in ("item/started", "item/updated", "item/completed"):
|
|
583
|
+
# A stale re-emission changed nothing (INV-003), so it yields none.
|
|
584
|
+
if isinstance(outcome, ItemFold) and not isinstance(
|
|
585
|
+
outcome.outcome, IgnoredStaleRevision
|
|
586
|
+
):
|
|
587
|
+
self._item_folded(params["item"], retirements)
|
|
588
|
+
elif method == "item/delta":
|
|
589
|
+
self._delta_folded(cast(ItemDeltaParams, params))
|
|
590
|
+
elif method == "turn/started":
|
|
591
|
+
self._handle(str(params["turnId"])).mark_started()
|
|
592
|
+
self._queue_moved(str(params["turnId"]), sink.tasks)
|
|
593
|
+
elif method == "turn/completed":
|
|
594
|
+
self._handle(str(params["turnId"])).settle_completed(
|
|
595
|
+
cast(TurnCompletedParams, params)
|
|
596
|
+
)
|
|
597
|
+
self._queue_moved(str(params["turnId"]), sink.tasks)
|
|
598
|
+
elif method == "turn/unqueued":
|
|
599
|
+
self._handle(str(params["turnId"])).settle_unqueued(
|
|
600
|
+
cast(TurnUnqueuedParams, params)
|
|
601
|
+
)
|
|
602
|
+
# SS4.13 "Reclaimed": the reclaim this client just observed retires
|
|
603
|
+
# the entry NOW, with its input restored to the composer.
|
|
604
|
+
command_id = params.get("commandId")
|
|
605
|
+
if isinstance(command_id, str):
|
|
606
|
+
reclaimed = self._pending.observed_reclaim(command_id)
|
|
607
|
+
if reclaimed is not None:
|
|
608
|
+
retirements.append(reclaimed)
|
|
609
|
+
elif method == "approval/requested":
|
|
610
|
+
# FR-638-019b's outbound half. Gated on the fold's own verdict: a
|
|
611
|
+
# request the fold IGNORED (a redelivery for an approval already
|
|
612
|
+
# durably resolved) must author no decision — no second resolution
|
|
613
|
+
# is coming, so a decide against it can only bounce.
|
|
614
|
+
if isinstance(outcome, ApprovalPending):
|
|
615
|
+
self._approval_requested(
|
|
616
|
+
cast(ApprovalRequestParams, params), sink.tasks
|
|
617
|
+
)
|
|
618
|
+
# ``view/gap`` deliberately routes to no turn: it names a hole in
|
|
619
|
+
# DELIVERY, so what it moves is the fold's currency, and the recovery
|
|
620
|
+
# it triggers is started by ``apply`` off the ``DeliveryGap`` verdict
|
|
621
|
+
# rather than from here (FR-638-019c). Every other method is folded
|
|
622
|
+
# but routed to no turn.
|
|
623
|
+
|
|
624
|
+
# The retired entries are gone from the set, so their replay memory
|
|
625
|
+
# goes with them.
|
|
626
|
+
if retirements:
|
|
627
|
+
self._submit.forget_retired(retirements)
|
|
628
|
+
sink.retirements.extend(retirements)
|
|
629
|
+
return outcome
|
|
630
|
+
|
|
631
|
+
def turn(self, turn_id: str) -> Turn:
|
|
632
|
+
"""The handle for a turn, created on first mention from either side.
|
|
633
|
+
|
|
634
|
+
A turn the fold has already settled returns its settled handle, so a
|
|
635
|
+
wait registered late resolves instead of hanging on an event that has
|
|
636
|
+
passed. A CONSUMER asking about a turn this session has no news of,
|
|
637
|
+
after the host died, must not hang: no answer is coming on this
|
|
638
|
+
connection (the SS3.1.4 trap SS2.13.3b's "stop waiting" forbids).
|
|
639
|
+
Server-minted handles deliberately skip this — see :meth:`_handle`.
|
|
640
|
+
"""
|
|
641
|
+
held = self._turns.get(turn_id)
|
|
642
|
+
if held is not None:
|
|
643
|
+
return held
|
|
644
|
+
fresh = self._mint(turn_id)
|
|
645
|
+
if self._discharge is not None and self._discharge.kind == "discharged":
|
|
646
|
+
fresh.settle_terminal_unknown()
|
|
647
|
+
elif self._death_error is not None:
|
|
648
|
+
fresh.fail(self._death_error)
|
|
649
|
+
return fresh
|
|
650
|
+
|
|
651
|
+
def _handle(self, turn_id: str) -> TurnHandle:
|
|
652
|
+
# A handle minted because a SERVER FRAME named this turn. Never
|
|
653
|
+
# pre-failed, and that is what makes the post-death behaviour
|
|
654
|
+
# order-independent (a durable death does not stop the fold, FM-001, so
|
|
655
|
+
# frames still arrive for a turn with no handle yet). A frame arriving
|
|
656
|
+
# IS the connection still speaking; ``turn()`` keeps the latch for the
|
|
657
|
+
# case that genuinely is a waiter with no answer coming.
|
|
658
|
+
held = self._turns.get(turn_id)
|
|
659
|
+
return held if held is not None else self._mint(turn_id)
|
|
660
|
+
|
|
661
|
+
def _mint(self, turn_id: str) -> TurnHandle:
|
|
662
|
+
fresh = TurnHandle(turn_id, lambda: self._items_of_turn(turn_id))
|
|
663
|
+
self._turns[turn_id] = fresh
|
|
664
|
+
return fresh
|
|
665
|
+
|
|
666
|
+
def host_exited(
|
|
667
|
+
self, exit_notification: HostDeathNotification
|
|
668
|
+
) -> HostDeathDischarge[_I]:
|
|
669
|
+
"""A host process exited or the transport reached EOF; classify it, and
|
|
670
|
+
discharge SS2.13.3b if the profile demands it (T033, FR-638-019d).
|
|
671
|
+
|
|
672
|
+
REPORT EVERY NOTIFICATION OF THE DEATH — the exit AND transport EOF. A
|
|
673
|
+
repeat report of an already-latched death is not a no-op: it marks END
|
|
674
|
+
OF DRAIN and settles waits the drain minted after the first report. A
|
|
675
|
+
consumer that dedupes and reports the death once leaves those waits
|
|
676
|
+
pending forever (FM-001).
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
exit_notification: The SS2.11 exit classification, or the
|
|
680
|
+
transport EOF.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
The discharge: ``notADeath`` (still usable), ``durableDeath`` (dead
|
|
684
|
+
until resume, waits rejected), or ``discharged`` (ephemeral
|
|
685
|
+
obligation paid in full). The FIRST is latched and replayed.
|
|
686
|
+
"""
|
|
687
|
+
if self._discharge is not None:
|
|
688
|
+
# END-OF-DRAIN SWEEP: between the first notification and this one a
|
|
689
|
+
# durable death's still-running fold can mint handles from
|
|
690
|
+
# ``turn/started`` frames whose terminals never arrive. Those are
|
|
691
|
+
# left pending by ``_handle`` on purpose — a delivered terminal
|
|
692
|
+
# must be able to win — but a SECOND notification means the drain
|
|
693
|
+
# is over, so anything still unsettled would hang forever.
|
|
694
|
+
self._sweep_unsettled_after_drain()
|
|
695
|
+
return self._discharge
|
|
696
|
+
|
|
697
|
+
profile = self._durability
|
|
698
|
+
if not is_abnormal_host_death(exit_notification):
|
|
699
|
+
self._discharge = _NotADeath(profile=profile, exit=exit_notification)
|
|
700
|
+
return self._discharge
|
|
701
|
+
|
|
702
|
+
if survives_host_death(profile):
|
|
703
|
+
# FM-001: the stores are left exactly as observed — the terminals
|
|
704
|
+
# arrive on resume — so only the waiters are told.
|
|
705
|
+
died = MuseHostDiedError(exit_notification)
|
|
706
|
+
self._death_error = died
|
|
707
|
+
for handle in self._turns.values():
|
|
708
|
+
handle.fail(died)
|
|
709
|
+
self._discharge = _DurableDeath(profile=profile, exit=exit_notification)
|
|
710
|
+
return self._discharge
|
|
711
|
+
|
|
712
|
+
retired_commands = self._pending.discard_ephemeral()
|
|
713
|
+
terminal_unknown_items = self._fold.mark_ephemeral_host_death(
|
|
714
|
+
lambda item: is_item_in_progress(cast(Item, item))
|
|
715
|
+
)
|
|
716
|
+
for handle in self._turns.values():
|
|
717
|
+
handle.settle_terminal_unknown()
|
|
718
|
+
# SS2.13.3b "do not attempt to reattach". ``discard_ephemeral`` already
|
|
719
|
+
# wrote the commandIds into the shared set (it holds it by reference);
|
|
720
|
+
# the sessionId is this layer's to record, because the pending set does
|
|
721
|
+
# not know one. ``MuseClient.resume_session`` is the reader.
|
|
722
|
+
if self._discarded is not None:
|
|
723
|
+
self._discarded.session_ids.add(self.session_id)
|
|
724
|
+
self._submit.forget_retired(retired_commands)
|
|
725
|
+
self._discharge = _Discharged(
|
|
726
|
+
profile=profile,
|
|
727
|
+
exit=exit_notification,
|
|
728
|
+
terminal_unknown_items=tuple(terminal_unknown_items),
|
|
729
|
+
retired_commands=tuple(retired_commands),
|
|
730
|
+
)
|
|
731
|
+
return self._discharge
|
|
732
|
+
|
|
733
|
+
def _sweep_unsettled_after_drain(self) -> None:
|
|
734
|
+
# DURABLE ONLY, and ``_death_error`` is set iff a durable death
|
|
735
|
+
# latched, so the condition is the discriminator. There is deliberately
|
|
736
|
+
# no ephemeral arm: after a discharge the first notification settled
|
|
737
|
+
# every held handle, ``apply()`` refuses before a frame can mint one,
|
|
738
|
+
# and ``turn()`` settles a fresh mint on the spot.
|
|
739
|
+
if self._death_error is None:
|
|
740
|
+
return
|
|
741
|
+
for handle in self._turns.values():
|
|
742
|
+
handle.fail(self._death_error)
|
|
743
|
+
|
|
744
|
+
# ---- FR-638-019a submit verb (T030 obligation a) ------------------------
|
|
745
|
+
|
|
746
|
+
async def send_user_turn(self, options: SendUserTurnOptions[_I]) -> Turn:
|
|
747
|
+
"""Submit a user turn (tdd SS3.2) and hand back THIS session's handle
|
|
748
|
+
for the turn the ack named.
|
|
749
|
+
|
|
750
|
+
The returned handle is the one ``apply`` already routes events to — not
|
|
751
|
+
a second one minted beside it, which would compare equal on every field
|
|
752
|
+
and then never receive an item, a delta, or a terminal.
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
options: The turn's input and optional composer/display members.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
The turn handle the ack named.
|
|
759
|
+
"""
|
|
760
|
+
ack = await self._submit.submit(options, self._last_folded_item_id())
|
|
761
|
+
return self._handle(ack["turnId"])
|
|
762
|
+
|
|
763
|
+
# ---- FR-638-019b approval round trip (T031) ------------------------------
|
|
764
|
+
|
|
765
|
+
def on_approval(self, handler: ApprovalHandler) -> None:
|
|
766
|
+
"""Answer approvals with ``handler`` (FR-638-019b). See
|
|
767
|
+
:class:`~muse_code.facade.approval.ApprovalRouter`."""
|
|
768
|
+
self._approvals.on_approval(handler)
|
|
769
|
+
|
|
770
|
+
def on_approval_error(self, handler: ApprovalFailureHandler) -> None:
|
|
771
|
+
"""Observe round trips that did not complete. See
|
|
772
|
+
:data:`~muse_code.facade.approval.ApprovalFailure`."""
|
|
773
|
+
self._approvals.on_approval_error(handler)
|
|
774
|
+
|
|
775
|
+
# ---- consumer-driven SS4.13 retirement verbs ----------------------------
|
|
776
|
+
|
|
777
|
+
def stop_retrying(self, command_id: str) -> PendingRetirement[_I] | None:
|
|
778
|
+
"""Stop the SS3.1.1 retry loop for one entry and take its input back
|
|
779
|
+
(SS4.13 "Abandoned").
|
|
780
|
+
|
|
781
|
+
A session method rather than a ``pending`` view member: the retirement
|
|
782
|
+
must also prune the submitter's replay memory, or the abandoned
|
|
783
|
+
command's ``turn/start`` params live for the session's lifetime.
|
|
784
|
+
"""
|
|
785
|
+
retired = self._pending.stop_retrying(command_id)
|
|
786
|
+
if retired is not None:
|
|
787
|
+
self._submit.forget_retired([retired])
|
|
788
|
+
return retired
|
|
789
|
+
|
|
790
|
+
def replay_answered(
|
|
791
|
+
self, command_id: str, answer: ReplayAnswer[_I]
|
|
792
|
+
) -> PendingRetirement[_I] | Literal["held"]:
|
|
793
|
+
"""Feed a consumer-driven replay's answer through the live SS4.13
|
|
794
|
+
settlement rules. ``"held"`` means the answer settled nothing."""
|
|
795
|
+
settled = self._pending.replay_answered(command_id, answer)
|
|
796
|
+
if settled != "held":
|
|
797
|
+
self._submit.forget_retired([settled])
|
|
798
|
+
return settled
|
|
799
|
+
|
|
800
|
+
# ---- SS4.13 drivers that need client->server I/O (T030 obligation d) ----
|
|
801
|
+
|
|
802
|
+
async def resolve_snapshot_join(
|
|
803
|
+
self, facts: SnapshotJoinFacts
|
|
804
|
+
) -> tuple[PendingRetirement[_I], ...]:
|
|
805
|
+
"""Resolve a snapshot join by performing the I/O its plan demands
|
|
806
|
+
(SS4.13, SS4.9): same-``commandId`` resubmits FIRST, then the replays,
|
|
807
|
+
then the reclaimed-signature verdict.
|
|
808
|
+
|
|
809
|
+
The order is normative: SS4.13 requires an unacked entry be resubmitted
|
|
810
|
+
before any retire-to-composer, because a join miss does not prove the
|
|
811
|
+
intake was never written.
|
|
812
|
+
"""
|
|
813
|
+
self._submit.require_connection("resolve_snapshot_join")
|
|
814
|
+
plan = self._pending.join_snapshot(facts)
|
|
815
|
+
out: List[PendingRetirement[_I]] = list(plan.retirements)
|
|
816
|
+
for command_id in plan.must_resubmit:
|
|
817
|
+
await self._submit.drive_replay(command_id, out)
|
|
818
|
+
queued_command_ids = [queued.command_id for queued in facts.queued_turns]
|
|
819
|
+
for command_id in plan.must_replay:
|
|
820
|
+
answer = await self._submit.replay(command_id)
|
|
821
|
+
if answer is None:
|
|
822
|
+
continue
|
|
823
|
+
settled = self._pending.resolve_replay_at_join(
|
|
824
|
+
command_id, answer, queued_command_ids
|
|
825
|
+
)
|
|
826
|
+
if settled != "held":
|
|
827
|
+
out.append(settled)
|
|
828
|
+
self._submit.forget_retired(out)
|
|
829
|
+
return tuple(out)
|
|
830
|
+
|
|
831
|
+
async def resolve_reconnect(self) -> tuple[PendingRetirement[_I], ...]:
|
|
832
|
+
"""Resolve a reconnect with NO snapshot (the default cursor-resume
|
|
833
|
+
path): replay each acked entry once, resubmit each unacked entry's
|
|
834
|
+
same ``commandId`` once."""
|
|
835
|
+
self._submit.require_connection("resolve_reconnect")
|
|
836
|
+
plan = self._pending.reconnected_without_snapshot()
|
|
837
|
+
out: List[PendingRetirement[_I]] = []
|
|
838
|
+
for command_id in plan.must_resubmit:
|
|
839
|
+
await self._submit.drive_replay(command_id, out)
|
|
840
|
+
for command_id in plan.must_replay:
|
|
841
|
+
await self._submit.drive_replay(command_id, out)
|
|
842
|
+
self._submit.forget_retired(out)
|
|
843
|
+
return tuple(out)
|
|
844
|
+
|
|
845
|
+
# ---- event-driven I/O ---------------------------------------------------
|
|
846
|
+
|
|
847
|
+
def _last_folded_item_id(self) -> str | None:
|
|
848
|
+
items = self._fold.items.list()
|
|
849
|
+
if not items:
|
|
850
|
+
return None
|
|
851
|
+
last = items[-1]
|
|
852
|
+
item_id = last.get("itemId")
|
|
853
|
+
return item_id if isinstance(item_id, str) else None
|
|
854
|
+
|
|
855
|
+
def _queue_moved(
|
|
856
|
+
self,
|
|
857
|
+
turn_id: str,
|
|
858
|
+
tasks: List[Awaitable[tuple[PendingRetirement[_I], ...]]],
|
|
859
|
+
) -> None:
|
|
860
|
+
# SS4.13 "Queue movement": a ``turn/started``/``turn/completed`` for a
|
|
861
|
+
# turn that is not an entry's own decides that entry's fate at a launch
|
|
862
|
+
# boundary, and SS4.3 carries no view event for a command-intake
|
|
863
|
+
# settlement — so the only way to learn it is to replay.
|
|
864
|
+
if not self._submit.wired:
|
|
865
|
+
return
|
|
866
|
+
command_ids = self._pending.observed_queue_movement(turn_id)
|
|
867
|
+
if not command_ids:
|
|
868
|
+
return
|
|
869
|
+
tasks.append(self._drive_queue_replays(command_ids))
|
|
870
|
+
|
|
871
|
+
async def _drive_queue_replays(
|
|
872
|
+
self, command_ids: tuple[str, ...]
|
|
873
|
+
) -> tuple[PendingRetirement[_I], ...]:
|
|
874
|
+
out: List[PendingRetirement[_I]] = []
|
|
875
|
+
for command_id in command_ids:
|
|
876
|
+
try:
|
|
877
|
+
await self._submit.drive_replay(command_id, out)
|
|
878
|
+
except Exception:
|
|
879
|
+
# Swallowed narrowly: ``replay`` already converts a
|
|
880
|
+
# server-authored MSP error into an answer, so reaching here
|
|
881
|
+
# means the TRANSPORT failed. That proves nothing about the
|
|
882
|
+
# intake, so the entry holds — the authoritative report is the
|
|
883
|
+
# host-death notification already on its way. Re-raising would
|
|
884
|
+
# reject an ``io`` most consumers never await.
|
|
885
|
+
pass
|
|
886
|
+
self._submit.forget_retired(out)
|
|
887
|
+
return tuple(out)
|
|
888
|
+
|
|
889
|
+
def _approval_requested(
|
|
890
|
+
self,
|
|
891
|
+
params: ApprovalRequestParams,
|
|
892
|
+
tasks: List[Awaitable[tuple[PendingRetirement[_I], ...]]],
|
|
893
|
+
) -> None:
|
|
894
|
+
decided = self._approvals.requested(params)
|
|
895
|
+
# An approval decision retires no pending command — it is not an
|
|
896
|
+
# SS4.13 entry — but it still has to be awaitable through the same
|
|
897
|
+
# ``io``, or a consumer has no barrier for the round trip it just
|
|
898
|
+
# triggered.
|
|
899
|
+
if decided is not None:
|
|
900
|
+
tasks.append(_decided_io(decided))
|
|
901
|
+
|
|
902
|
+
def _items_of_turn(self, turn_id: str) -> tuple[Item, ...]:
|
|
903
|
+
return tuple(
|
|
904
|
+
cast(Item, item)
|
|
905
|
+
for item in self._fold.items.list()
|
|
906
|
+
if item.get("turnId") == turn_id
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
def _item_folded(
|
|
910
|
+
self, item: Item, retirements: List[PendingRetirement[_I]]
|
|
911
|
+
) -> None:
|
|
912
|
+
# SS4.13 "Materialized": the commandId-bearing ``userMessage`` landed,
|
|
913
|
+
# so the optimistic entry is replaced by the real item at the item's
|
|
914
|
+
# own position (item ids and command ids are separate namespaces).
|
|
915
|
+
command_id = item.get("commandId")
|
|
916
|
+
if item.get("kind") == _USER_MESSAGE and isinstance(command_id, str):
|
|
917
|
+
materialized = self._pending.observed_user_message(
|
|
918
|
+
command_id, str(item["itemId"])
|
|
919
|
+
)
|
|
920
|
+
if materialized is not None:
|
|
921
|
+
retirements.append(materialized)
|
|
922
|
+
# ``userShell`` items carry ``turnId: null`` on the wire — guard on
|
|
923
|
+
# "is a str", never "is not None": a null turnId minted a phantom turn.
|
|
924
|
+
turn_id = item.get("turnId")
|
|
925
|
+
if isinstance(turn_id, str):
|
|
926
|
+
self._handle(turn_id).push_item(item)
|
|
927
|
+
buffered = self._unattributed_deltas.pop(str(item["itemId"]), None)
|
|
928
|
+
if buffered is None:
|
|
929
|
+
return
|
|
930
|
+
if not isinstance(turn_id, str):
|
|
931
|
+
return # ``userShell`` is not turn-scoped
|
|
932
|
+
handle = self._handle(turn_id)
|
|
933
|
+
for delta_params in buffered:
|
|
934
|
+
handle.push_delta(delta_params)
|
|
935
|
+
|
|
936
|
+
def _delta_folded(self, params: ItemDeltaParams) -> None:
|
|
937
|
+
item_id = str(params["itemId"])
|
|
938
|
+
held = self._fold.items.get(item_id)
|
|
939
|
+
if held is None:
|
|
940
|
+
self._unattributed_deltas.setdefault(item_id, []).append(params)
|
|
941
|
+
return
|
|
942
|
+
turn_id = held.get("turnId")
|
|
943
|
+
if isinstance(turn_id, str):
|
|
944
|
+
self._handle(turn_id).push_delta(params)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
async def _decided_io(
|
|
948
|
+
decided: Awaitable[None],
|
|
949
|
+
) -> tuple[PendingRetirement[_I], ...]:
|
|
950
|
+
await decided
|
|
951
|
+
return ()
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _settled_io(
|
|
955
|
+
tasks: List[Awaitable[tuple[PendingRetirement[_I], ...]]],
|
|
956
|
+
) -> Awaitable[tuple[PendingRetirement[_I], ...]]:
|
|
957
|
+
"""Flatten the event's I/O tasks, or hand back an already-settled empty.
|
|
958
|
+
|
|
959
|
+
Returns something that NEVER rejects (SessionApplyOutcome.io's contract):
|
|
960
|
+
every task producer here — ``_drive_queue_replays``, the approval decide,
|
|
961
|
+
the gap fill — contains its own failures, so the gather cannot fail.
|
|
962
|
+
Tasks only exist on a WIRED session inside a running loop (the fold-only
|
|
963
|
+
arms of the approval router and the gap filler report synchronously and
|
|
964
|
+
schedule nothing), so ``ensure_future`` is safe there; the empty case is
|
|
965
|
+
loop-free.
|
|
966
|
+
"""
|
|
967
|
+
if not tasks:
|
|
968
|
+
return ResolvedIo(())
|
|
969
|
+
|
|
970
|
+
async def run() -> tuple[PendingRetirement[_I], ...]:
|
|
971
|
+
batches = await asyncio.gather(*tasks)
|
|
972
|
+
out: List[PendingRetirement[_I]] = []
|
|
973
|
+
for batch in batches:
|
|
974
|
+
out.extend(batch)
|
|
975
|
+
return tuple(out)
|
|
976
|
+
|
|
977
|
+
return asyncio.ensure_future(run())
|