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,289 @@
1
+ """``turn/start`` submission and same-``commandId`` replay (spec 638
2
+ FR-638-019a carrying spec 14990 FR-018 obligations (a) and (d); tdd SS3.1.1,
3
+ SS3.2, SS4.13). Port of ``clients/sdk-ts/src/facade/turn-submit.ts``.
4
+
5
+ It holds the ONE piece of state :class:`~muse_code.pending.PendingCommandSet`
6
+ deliberately cannot: the ``turn/start`` params per ``commandId``. The set
7
+ stores the COMPOSER input and is wire-blind by design (INV-638-01), so a
8
+ replay — which must re-send the SAME logical command to be the sanctioned
9
+ retry rather than a double execution — needs the params on this side of that
10
+ boundary.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, fields
16
+ from typing import Generic, List, Sequence, TypeVar, cast
17
+
18
+ from muse_code_msp import (
19
+ IfBusy,
20
+ ReasoningEffort,
21
+ TurnInputPart,
22
+ TurnStartParams,
23
+ TurnStartResult,
24
+ )
25
+
26
+ from ..connection.connection import Connection, MspError, Params
27
+ from ..pending.pending_command_set import (
28
+ CommandErrorResponse,
29
+ PendingCommandAck,
30
+ PendingCommandSet,
31
+ PendingRetirement,
32
+ ReplayAck,
33
+ ReplayAnswer,
34
+ ReplayError,
35
+ )
36
+
37
+ _I = TypeVar("_I")
38
+
39
+
40
+ @dataclass
41
+ class SendUserTurnOptions(Generic[_I]):
42
+ """What :meth:`Session.send_user_turn` sends, composed from the generated
43
+ ``turn/start`` params rather than restated (INV-638-02).
44
+
45
+ ``commandId`` is excluded because ``Connection`` is the single minter
46
+ (INV-638-04 carrying INV-013); ``sessionId`` because the session already
47
+ knows its own and a caller-supplied one could only disagree.
48
+
49
+ Attributes:
50
+ input: The turn's input parts (the wire ``input`` member).
51
+ display_text: Optional composer display text (wire ``displayText``).
52
+ if_busy: How to treat a submit while a turn runs (wire ``ifBusy``).
53
+ reasoning_effort: The turn's reasoning effort (wire
54
+ ``reasoningEffort``).
55
+ composer_input: What to hand back to the composer if this submit is
56
+ ever retired — SS4.13 restores the INPUT, not the wire parts.
57
+ Optional: a consumer rendering nothing optimistically has nothing
58
+ to restore.
59
+ """
60
+
61
+ input: List[TurnInputPart]
62
+ display_text: str | None = None
63
+ if_busy: IfBusy | None = None
64
+ reasoning_effort: ReasoningEffort | None = None
65
+ composer_input: _I | None = None
66
+
67
+
68
+ def _camel(field_name: str) -> str:
69
+ """snake_case → camelCase, the C-638-1 facade→wire member mapping.
70
+
71
+ The single copy: ``client.py`` imports this for its own drift pins rather
72
+ than restating it (that direction has no import cycle — this module reaches
73
+ neither ``client`` nor ``session``).
74
+ """
75
+ head, *rest = field_name.split("_")
76
+ return head + "".join(part.capitalize() for part in rest)
77
+
78
+
79
+ # Drift pin — the Python twin of the TS `AssertNever` on TURN_START_FORWARDED
80
+ # (turn-submit.ts). A schema regen that adds a member to TurnStartParams but not
81
+ # to SendUserTurnOptions reds THIS import, so a regenerated member cannot
82
+ # silently fail to reach the wire (INV-638-02). `composer_input` is the SDK's
83
+ # own field (the SS4.13 restore payload), never a wire member, so it is dropped
84
+ # before the compare; `commandId`/`sessionId` are excluded because the
85
+ # connection mints the first and the session owns the second.
86
+ _TURN_WIRE_FIELDS = {"input", "display_text", "if_busy", "reasoning_effort"}
87
+ _TURN_EXCLUDED = {"commandId", "sessionId"}
88
+ assert {
89
+ _camel(f.name) for f in fields(SendUserTurnOptions) if f.name in _TURN_WIRE_FIELDS
90
+ } | _TURN_EXCLUDED == set(
91
+ TurnStartParams.__annotations__
92
+ ), "SendUserTurnOptions drifted from TurnStartParams — forward the new member"
93
+
94
+
95
+ def command_error_response(error: BaseException) -> CommandErrorResponse | None:
96
+ """An :class:`MspError` as the pending set reads it (SS4.13's settlement
97
+ test).
98
+
99
+ ``None`` for anything that is NOT a server-authored MSP error — a
100
+ :class:`ProtocolError`, a dead transport — because those admit nothing AND
101
+ prove nothing about the intake, so the entry must HOLD rather than be
102
+ judged.
103
+
104
+ Args:
105
+ error: The exception a submit or replay raised.
106
+
107
+ Returns:
108
+ The typed response, or ``None`` for a non-MSP failure.
109
+ """
110
+ if not isinstance(error, MspError):
111
+ return None
112
+ reason = error.data.get("reason")
113
+ return CommandErrorResponse(
114
+ code=error.code,
115
+ kind=error.kind,
116
+ reason=reason if isinstance(reason, str) else None,
117
+ )
118
+
119
+
120
+ class TurnSubmitter(Generic[_I]):
121
+ """Shapes ``turn/start`` frames, records the optimistic SS4.13 entry, and
122
+ replays a remembered command under its original ``commandId``."""
123
+
124
+ def __init__(
125
+ self,
126
+ session_id: str,
127
+ connection: Connection | None,
128
+ pending: PendingCommandSet[_I],
129
+ ) -> None:
130
+ """Binds the submitter; ``connection`` is ``None`` on a fold-only
131
+ session."""
132
+ self._session_id = session_id
133
+ self._connection = connection
134
+ self._pending = pending
135
+ # See the module doc. Pruned on retirement, so it tracks the set.
136
+ self._memory: dict[str, Params] = {}
137
+
138
+ @property
139
+ def wired(self) -> bool:
140
+ """Whether a connection is present to author through."""
141
+ return self._connection is not None
142
+
143
+ def require_connection(self, verb: str) -> Connection:
144
+ """The connection, or a plain error naming the fold-only misuse.
145
+
146
+ A plain :class:`Exception`: calling a submit verb on a fold-only
147
+ session is API misuse, not the foreign-frame STATE
148
+ :class:`MuseForeignSessionError` names.
149
+
150
+ Args:
151
+ verb: The verb name, for the error message.
152
+
153
+ Returns:
154
+ The live connection.
155
+
156
+ Raises:
157
+ RuntimeError: This session was constructed fold-only.
158
+ """
159
+ if self._connection is None:
160
+ raise RuntimeError(
161
+ f"{verb} needs a connection; this Session was constructed fold-only"
162
+ )
163
+ return self._connection
164
+
165
+ async def submit(
166
+ self, options: SendUserTurnOptions[_I], anchor_after_item_id: str | None
167
+ ) -> PendingCommandAck:
168
+ """Records the optimistic SS4.13 entry, then sends ``turn/start``.
169
+
170
+ The ORDER is the point. An entry created only on the ack is invisible
171
+ for exactly the window it exists to cover — between the user pressing
172
+ enter and the host answering — and ``anchor_after_item_id`` is fixed
173
+ here, at submission time, because SS4.13's insertion point may never
174
+ be relocated by events that fold in afterwards.
175
+
176
+ Args:
177
+ options: The caller's turn options.
178
+ anchor_after_item_id: The last item the session holds now.
179
+
180
+ Returns:
181
+ The admission ack (``turnId`` + ``disposition``).
182
+ """
183
+ connection = self.require_connection("send_user_turn")
184
+ # Delegated, not re-implemented: the connection owns the single mint,
185
+ # so INV-013's single-minter property survives this verb needing the
186
+ # id early.
187
+ command_id = connection.mint_command_id()
188
+ params = self._params(command_id, options)
189
+ self._pending.submitted(
190
+ command_id,
191
+ # ``composer_input`` is optional on the options object; a consumer
192
+ # rendering nothing optimistically has nothing to restore, and the
193
+ # set stores whatever it is handed for restoration. ``None`` is a
194
+ # valid stored input, so the cast preserves it rather than
195
+ # inventing a value.
196
+ cast(_I, options.composer_input),
197
+ display_text=params.get("displayText"),
198
+ anchor_after_item_id=anchor_after_item_id,
199
+ )
200
+ self._memory[command_id] = params
201
+ try:
202
+ ack = await self._send(connection, params, command_id)
203
+ self._pending.acked(command_id, ack)
204
+ return ack
205
+ except MspError as error:
206
+ response = command_error_response(error)
207
+ # Only a durable -32030 settles; every other error admitted
208
+ # nothing and the entry HOLDS for the caller's same-commandId
209
+ # retry (SS4.13).
210
+ if response is not None:
211
+ settled = self._pending.ack_errored(command_id, response)
212
+ if settled != "held":
213
+ self.forget_retired([settled])
214
+ # Re-raised, deliberately: the caller who submitted still holds its
215
+ # own input, so this path needs no second retirement channel.
216
+ raise
217
+
218
+ async def replay(self, command_id: str) -> ReplayAnswer[_I] | None:
219
+ """Re-sends one remembered ``turn/start`` under its ORIGINAL
220
+ ``commandId`` (SS3.1.1 — always safe, the only re-send that is not a
221
+ double execution).
222
+
223
+ Args:
224
+ command_id: The command to replay.
225
+
226
+ Returns:
227
+ The answer, or ``None`` for "no answer this set can act on" —
228
+ either this session never authored the command, or the failure was
229
+ transport/protocol, which proves nothing about the intake.
230
+ """
231
+ params = self._memory.get(command_id)
232
+ if params is None:
233
+ return None
234
+ connection = self.require_connection("turn/start replay")
235
+ try:
236
+ ack = await self._send(connection, params, command_id)
237
+ return ReplayAck(ack=ack)
238
+ except MspError as error:
239
+ response = command_error_response(error)
240
+ if response is None:
241
+ return None
242
+ return ReplayError(error=response)
243
+
244
+ async def drive_replay(
245
+ self, command_id: str, into: List[PendingRetirement[_I]]
246
+ ) -> None:
247
+ """Replay one entry and feed the answer back through the live SS4.13
248
+ rules."""
249
+ answer = await self.replay(command_id)
250
+ if answer is None:
251
+ return
252
+ settled = self._pending.replay_answered(command_id, answer)
253
+ if settled != "held":
254
+ into.append(settled)
255
+
256
+ def forget_retired(
257
+ self, retirements: Sequence[PendingRetirement[_I]]
258
+ ) -> None:
259
+ """Drop the replay memory for retired entries so it tracks the set."""
260
+ for retirement in retirements:
261
+ self._memory.pop(retirement.command_id, None)
262
+
263
+ async def _send(
264
+ self, connection: Connection, params: Params, command_id: str
265
+ ) -> PendingCommandAck:
266
+ raw = await connection.command("turn/start", params, command_id=command_id)
267
+ result: TurnStartResult = raw # type: ignore[assignment]
268
+ # ``turnId`` comes from the ACK, never from the ``commandId``: SS3.2
269
+ # makes the ack authoritative, and a queued submit's turn may already
270
+ # exist.
271
+ return PendingCommandAck(
272
+ turnId=result["turnId"], disposition=result["disposition"]
273
+ )
274
+
275
+ def _params(self, command_id: str, options: SendUserTurnOptions[_I]) -> Params:
276
+ # OPTIONAL MEANS OMITTED, NEVER null (tdd SS1.2): an unset member is
277
+ # dropped from the frame rather than nulled.
278
+ params: Params = {
279
+ "commandId": command_id,
280
+ "input": options.input,
281
+ "sessionId": self._session_id,
282
+ }
283
+ if options.display_text is not None:
284
+ params["displayText"] = options.display_text
285
+ if options.if_busy is not None:
286
+ params["ifBusy"] = options.if_busy
287
+ if options.reasoning_effort is not None:
288
+ params["reasoningEffort"] = options.reasoning_effort
289
+ return params
@@ -0,0 +1,82 @@
1
+ """The SS4 client fold: item store, session-state store, and their
2
+ composition ``SessionFold`` (spec 638 FR-638-007; port of
3
+ ``clients/sdk-ts/src/fold``)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from .item_store import (
8
+ Appended,
9
+ BufferedForAbsentItem,
10
+ DeltaApplyOutcome,
11
+ IgnoredStaleRevision,
12
+ Inserted,
13
+ ItemApplyOutcome,
14
+ ItemLike,
15
+ ItemStore,
16
+ Replaced,
17
+ TerminalUnknownItemAnnotation,
18
+ )
19
+ from .session_fold import (
20
+ SESSION_STATE_METHODS,
21
+ LIVE_HOST_STATE_PROJECTIONS,
22
+ LIVE_HOST_STATE_PROJECTIONS,
23
+ VIEW_EVENT_METHODS,
24
+ ApprovalPending,
25
+ ApprovalResolvedFold,
26
+ DeliveryGap,
27
+ FoldItems,
28
+ FoldOutcome,
29
+ FoldSessionState,
30
+ IgnoredMissingParams,
31
+ IgnoredStaleFrame,
32
+ IgnoredUnrecognizedMethod,
33
+ ItemDeltaFold,
34
+ ItemFold,
35
+ PendingApproval,
36
+ SessionFold,
37
+ SessionStateFold,
38
+ TurnEntry,
39
+ TurnFold,
40
+ TurnState,
41
+ UserInputPending,
42
+ UserInputSettledFold,
43
+ )
44
+ from .state_store import SessionStateStore, StateApplyOutcome, StateValue
45
+
46
+ __all__ = [
47
+ "SESSION_STATE_METHODS",
48
+ "LIVE_HOST_STATE_PROJECTIONS",
49
+ "VIEW_EVENT_METHODS",
50
+ "Appended",
51
+ "ApprovalPending",
52
+ "ApprovalResolvedFold",
53
+ "BufferedForAbsentItem",
54
+ "DeliveryGap",
55
+ "DeltaApplyOutcome",
56
+ "FoldItems",
57
+ "FoldOutcome",
58
+ "FoldSessionState",
59
+ "IgnoredMissingParams",
60
+ "IgnoredStaleFrame",
61
+ "IgnoredStaleRevision",
62
+ "IgnoredUnrecognizedMethod",
63
+ "Inserted",
64
+ "ItemApplyOutcome",
65
+ "ItemDeltaFold",
66
+ "ItemFold",
67
+ "ItemLike",
68
+ "ItemStore",
69
+ "PendingApproval",
70
+ "Replaced",
71
+ "SessionFold",
72
+ "SessionStateFold",
73
+ "SessionStateStore",
74
+ "StateApplyOutcome",
75
+ "StateValue",
76
+ "TerminalUnknownItemAnnotation",
77
+ "TurnEntry",
78
+ "TurnFold",
79
+ "TurnState",
80
+ "UserInputPending",
81
+ "UserInputSettledFold",
82
+ ]
@@ -0,0 +1,268 @@
1
+ """The item half of the SS4 client fold: upsert-by-revision plus delta
2
+ accumulation (spec 638 FR-638-007 carrying spec 14990 FR-007,
3
+ INV-003/INV-004).
4
+
5
+ Items are the generated ``muse_code_msp`` shapes at runtime — plain dicts
6
+ carrying at least ``itemId`` and ``revision`` (the algebraic precondition the
7
+ rules need). The store reads only those two members, so it stays
8
+ wire-shape-blind exactly as the TS ``ItemStore`` is (INV-638-01/02); the
9
+ ``SessionFold`` binding supplies wire-aware probes where a rule needs one.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Callable, Iterable, List, Mapping
16
+
17
+ from ..errors import MuseSessionDiscardedError
18
+
19
+ # The store's item view: a wire item is a dict at runtime. Reading only
20
+ # `itemId`/`revision` here keeps the store wire-shape-blind (INV-638-01).
21
+ ItemLike = Mapping[str, object]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Inserted:
26
+ """A first sighting: the item was inserted."""
27
+
28
+ item_id: str
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Replaced:
33
+ """A strictly higher revision replaced the held item (INV-003)."""
34
+
35
+ item_id: str
36
+ from_revision: int
37
+ to_revision: int
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class IgnoredStaleRevision:
42
+ """A stale or replayed re-emission. The store did not change (INV-003)."""
43
+
44
+ item_id: str
45
+ held: int
46
+ offered: int
47
+
48
+
49
+ ItemApplyOutcome = Inserted | Replaced | IgnoredStaleRevision
50
+ """What an apply did — enough for a consumer to render without re-diffing."""
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Appended:
55
+ """A delta appended to a held item's field accumulator."""
56
+
57
+ item_id: str
58
+ field: str
59
+ length: int
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class BufferedForAbsentItem:
64
+ """A delta for an item the store does not hold.
65
+
66
+ Deltas are a streaming optimization and are never a fold's only source
67
+ of a fact (tdd SS4.7.3), so this is buffered against the item's arrival
68
+ rather than dropped or treated as an error.
69
+ """
70
+
71
+ item_id: str
72
+ field: str
73
+
74
+
75
+ DeltaApplyOutcome = Appended | BufferedForAbsentItem
76
+ """What a delta did. Deltas bump no revision (tdd SS4.4.1)."""
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class TerminalUnknownItemAnnotation:
81
+ """Client-only annotation required when an ephemeral host dies abnormally."""
82
+
83
+ item_id: str
84
+
85
+
86
+ def _item_id(item: ItemLike) -> str:
87
+ return str(item["itemId"])
88
+
89
+
90
+ def _revision(item: ItemLike) -> int:
91
+ revision = item["revision"]
92
+ if not isinstance(revision, int):
93
+ raise TypeError(f"item revision is not an integer: {revision!r}")
94
+ return revision
95
+
96
+
97
+ class ItemStore:
98
+ """Items in first-opened order (tdd SS4.9.1), each at its latest revision.
99
+
100
+ Holds per-field delta accumulators beside the items, and the
101
+ terminal-unknown annotation set an ephemeral host death produces.
102
+ """
103
+
104
+ def __init__(self) -> None:
105
+ """Create an empty store."""
106
+ self._items: dict[str, ItemLike] = {}
107
+ # item id -> field path -> accumulated text.
108
+ self._deltas: dict[str, dict[str, str]] = {}
109
+ # First-opened order; an item id appears exactly once.
110
+ self._order: list[str] = []
111
+ self._terminal_unknown: set[str] = set()
112
+ self._ephemeral_session_discarded = False
113
+
114
+ def apply(self, item: ItemLike) -> ItemApplyOutcome:
115
+ """Apply an ``item/started`` | ``item/updated`` | ``item/completed`` payload.
116
+
117
+ Replace iff the incoming revision is strictly higher (INV-003).
118
+
119
+ Args:
120
+ item: The wire item object, verbatim.
121
+
122
+ Returns:
123
+ What the apply did.
124
+
125
+ Raises:
126
+ MuseSessionDiscardedError: The ephemeral session was discarded.
127
+ """
128
+ self._assert_session_active()
129
+ item_id = _item_id(item)
130
+ held = self._items.get(item_id)
131
+ if held is None:
132
+ self._items[item_id] = item
133
+ self._order.append(item_id)
134
+ return Inserted(item_id)
135
+ held_revision = _revision(held)
136
+ offered = _revision(item)
137
+ if offered <= held_revision:
138
+ return IgnoredStaleRevision(item_id, held=held_revision, offered=offered)
139
+ self._items[item_id] = item
140
+ return Replaced(item_id, from_revision=held_revision, to_revision=offered)
141
+
142
+ def apply_delta(
143
+ self, item_id: str, delta: str, field: str = "text"
144
+ ) -> DeltaApplyOutcome:
145
+ """Apply an ``item/delta``: a lossless append to a field accumulator.
146
+
147
+ The concatenation of all deltas for a field path equals that field's
148
+ value on the final ``item/completed`` object (INV-004, tdd SS4.3.1).
149
+
150
+ Args:
151
+ item_id: The delta's item.
152
+ delta: The appended text, verbatim.
153
+ field: The streamed field path (wire default ``"text"``).
154
+
155
+ Returns:
156
+ ``Appended`` for a held item, ``BufferedForAbsentItem`` otherwise.
157
+
158
+ Raises:
159
+ MuseSessionDiscardedError: The ephemeral session was discarded.
160
+ """
161
+ self._assert_session_active()
162
+ fields = self._deltas.setdefault(item_id, {})
163
+ fields[field] = fields.get(field, "") + delta
164
+ if item_id not in self._items:
165
+ return BufferedForAbsentItem(item_id, field)
166
+ return Appended(item_id, field, length=len(fields[field]))
167
+
168
+ def accumulated(self, item_id: str, field: str = "text") -> str | None:
169
+ """The accumulated delta text for a field path, or ``None`` if none."""
170
+ fields = self._deltas.get(item_id)
171
+ return None if fields is None else fields.get(field)
172
+
173
+ def accumulated_fields(self, item_id: str) -> List[str]:
174
+ """Every field path that has accumulated deltas for this item, sorted."""
175
+ fields = self._deltas.get(item_id)
176
+ return [] if fields is None else sorted(fields)
177
+
178
+ def get(self, item_id: str) -> ItemLike | None:
179
+ """The held item at its latest revision, or ``None``."""
180
+ return self._items.get(item_id)
181
+
182
+ def has(self, item_id: str) -> bool:
183
+ """Whether the store holds this item."""
184
+ return item_id in self._items
185
+
186
+ def list(self) -> List[ItemLike]:
187
+ """Items in first-opened order (tdd SS4.9.1)."""
188
+ return [self._items[item_id] for item_id in self._order if item_id in self._items]
189
+
190
+ def last_opened_item_id(self) -> str | None:
191
+ """The id of the most recently opened item, or ``None`` when empty."""
192
+ return self._order[-1] if self._order else None
193
+
194
+ @property
195
+ def size(self) -> int:
196
+ """How many items the store holds."""
197
+ return len(self._items)
198
+
199
+ def seed(self, items: Iterable[ItemLike]) -> None:
200
+ """Seed from a snapshot's ``state.items`` (tdd SS4.9.1).
201
+
202
+ Seeding REPLACES the store — a snapshot is authoritative (tdd SS4.9),
203
+ never merged into stale local state. Delta accumulators are
204
+ intentionally NOT seeded: a snapshot carries streamed fields at their
205
+ accumulated-so-far values on the item itself, and ``view/page`` never
206
+ replays ephemeral-sourced deltas (tdd SS4.7.3).
207
+
208
+ Args:
209
+ items: Every item at its latest revision, in first-opened order.
210
+
211
+ Raises:
212
+ MuseSessionDiscardedError: The ephemeral session was discarded.
213
+ """
214
+ self._assert_session_active()
215
+ self._items.clear()
216
+ self._deltas.clear()
217
+ self._terminal_unknown.clear()
218
+ self._order.clear()
219
+ for item in items:
220
+ item_id = _item_id(item)
221
+ if item_id not in self._items:
222
+ self._order.append(item_id)
223
+ self._items[item_id] = item
224
+
225
+ def mark_ephemeral_host_death(
226
+ self, is_in_progress: Callable[[ItemLike], bool]
227
+ ) -> List[TerminalUnknownItemAnnotation]:
228
+ """Permanently discard an ephemeral session after abnormal host death.
229
+
230
+ The last wire item remains byte-for-byte intact: terminal-unknown is a
231
+ client display annotation, never a fabricated ``item/completed`` or
232
+ wire status (SS2.13.3b / SS4.4.3). Further events and snapshot seeding
233
+ are refused because this session has no resume surface.
234
+
235
+ Args:
236
+ is_in_progress: The wire-aware probe (the store never knows wire
237
+ shapes, INV-638-01); the ``SessionFold`` binding passes
238
+ ``item["status"] == "inProgress"`` against the generated
239
+ ``Item``.
240
+
241
+ Returns:
242
+ The terminal-unknown annotations, in first-opened item order.
243
+ """
244
+ if not self._ephemeral_session_discarded:
245
+ for item in self._items.values():
246
+ if is_in_progress(item):
247
+ self._terminal_unknown.add(_item_id(item))
248
+ self._ephemeral_session_discarded = True
249
+ return [
250
+ TerminalUnknownItemAnnotation(item_id)
251
+ for item_id in self._order
252
+ if item_id in self._terminal_unknown
253
+ ]
254
+
255
+ def is_terminal_unknown(self, item_id: str) -> bool:
256
+ """Whether this item carries the terminal-unknown annotation."""
257
+ return item_id in self._terminal_unknown
258
+
259
+ @property
260
+ def ephemeral_session_discarded(self) -> bool:
261
+ """Whether an ephemeral host death discarded this session."""
262
+ return self._ephemeral_session_discarded
263
+
264
+ def _assert_session_active(self) -> None:
265
+ if self._ephemeral_session_discarded:
266
+ raise MuseSessionDiscardedError(
267
+ "ephemeral session was discarded after abnormal host death"
268
+ )