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.
@@ -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())