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,254 @@
1
+ """The ephemeral-profile host-death discard obligation, client side (spec 638
2
+ FR-638-019d carrying spec 14990 FM-002 / Scenario 4.3; tdd SS2.13, SS4.4.3).
3
+
4
+ Port of ``clients/sdk-ts/src/facade/host-death.ts``. Slice S1/S2 landed the
5
+ two STORE-LEVEL primitives with no production callers:
6
+ :meth:`~muse_code.pending.PendingCommandSet.discard_ephemeral` and
7
+ :meth:`~muse_code.fold.ItemStore.mark_ephemeral_host_death`. This module holds
8
+ the two facts the stores deliberately do not know — which durability profile
9
+ the handshake declared, and whether a given process exit was abnormal — and
10
+ :class:`~muse_code.facade.session.Session` composes them into the discharge.
11
+
12
+ The stores stay wire-shape-blind (INV-638-01): the ``Item`` probe lives here,
13
+ where it reads the generated ``ItemStatus`` vocabulary.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING, Generic, Literal, TypeVar, Union
20
+
21
+ from muse_code_msp import (
22
+ ITEM_STATUS_KNOWN_VALUES,
23
+ SESSION_DURABILITY_KNOWN_VALUES,
24
+ InitializeResult,
25
+ Item,
26
+ )
27
+
28
+ from ..connection.spawn import ExitClassification
29
+
30
+ if TYPE_CHECKING:
31
+ from ..fold.item_store import TerminalUnknownItemAnnotation
32
+ from ..pending.pending_command_set import PendingRetirement
33
+
34
+ _I = TypeVar("_I")
35
+
36
+ # Read from the generated KNOWN_VALUES tuples so a misspelling is caught by
37
+ # the codegen contract rather than compiling green against an open ``str``
38
+ # enum — the runtime twin of the TS ``Extract<…>`` closed-vocabulary bind.
39
+ _DURABLE = "durable"
40
+ _EPHEMERAL = "ephemeral"
41
+ _IN_PROGRESS = "inProgress"
42
+ assert _DURABLE in SESSION_DURABILITY_KNOWN_VALUES
43
+ assert _EPHEMERAL in SESSION_DURABILITY_KNOWN_VALUES
44
+ assert _IN_PROGRESS in ITEM_STATUS_KNOWN_VALUES
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class _Durable:
49
+ kind: Literal["durable"] = "durable"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class _Ephemeral:
54
+ kind: Literal["ephemeral"] = "ephemeral"
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class _Unrecognized:
59
+ """An open-enum value this SDK predates. Guarantees nothing (SS2.13.1)."""
60
+
61
+ value: str
62
+ kind: Literal["unrecognized"] = "unrecognized"
63
+
64
+
65
+ SessionDurabilityProfile = Union[_Durable, _Ephemeral, _Unrecognized]
66
+ """Which durability profile the host declared at the handshake (SS2.13.1).
67
+
68
+ Absent and declared-``durable`` collapse to ONE reading on purpose: nothing
69
+ downstream needs to tell them apart, and a ``source`` discriminator would be a
70
+ field carried for a future that has not arrived.
71
+ """
72
+
73
+
74
+ def read_session_durability(result: InitializeResult) -> SessionDurabilityProfile:
75
+ """Reads ``sessionDurability`` off the handshake result (SS2.13.1).
76
+
77
+ Two readings are easy to conflate and must not be:
78
+
79
+ - ABSENT reads as ``durable``, decidable rather than fabricated: the
80
+ member is optional only so the addition was additive (SS1.5.4), and no
81
+ server that omits it has the ephemeral profile.
82
+ - An UNRECOGNIZED value is NOT durable. SS2.13.1: a client that does not
83
+ know the value must infer no durability guarantee from it and must not
84
+ fall through to the absent-means-durable rule — that rule keys on the
85
+ member being MISSING, not on its value being unfamiliar. The open enum
86
+ makes the conservative read "assume nothing survives this host".
87
+
88
+ Args:
89
+ result: The typed ``initialize`` result.
90
+
91
+ Returns:
92
+ The durability profile every session opened under it inherits.
93
+ """
94
+ declared = result.get("sessionDurability")
95
+ if declared is None or declared == _DURABLE:
96
+ return _Durable()
97
+ if declared == _EPHEMERAL:
98
+ return _Ephemeral()
99
+ return _Unrecognized(value=declared)
100
+
101
+
102
+ def survives_host_death(profile: SessionDurabilityProfile) -> bool:
103
+ """Does a session on this profile survive its host? Only a durable one."""
104
+ return profile.kind == _DURABLE
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class TransportEof:
109
+ """The transport reached EOF with no orderly SS2.1.2 close (FR-638-019d).
110
+
111
+ SS2.13.3b names TWO notifications of a host death — process exit OR
112
+ transport EOF — and only the exit half was implementable while nothing in
113
+ this SDK owned the close. A host that closes stdout while still hung emits
114
+ no exit on the timescale that matters, so without this arm its session
115
+ never discharged: every pending command sat as the durable-looking echo
116
+ SS4.13 forbids, and every turn wait hung.
117
+
118
+ It carries no evidence fields, and that is the honest shape: EOF IS the
119
+ whole observation. The stderr tail and the SS2.11 row belong to the
120
+ process boundary, which has not reported yet — and when it does,
121
+ :meth:`Session.host_exited` latches the FIRST discharge, so a later exit
122
+ replays the EOF's report rather than overwriting it with a richer one.
123
+
124
+ ORDERLINESS IS NOT DECIDABLE HERE. ``Connection`` is duplex-generic and a
125
+ transport cannot tell a peer's hang-up from its own shutdown, so the layer
126
+ that KNOWS whether the close was its own is the one that builds this:
127
+ ``MuseClient``, which owns both ``close()`` and the connection.
128
+
129
+ Attributes:
130
+ kind: The notification discriminator, always ``"transportEof"``.
131
+ """
132
+
133
+ kind: Literal["transportEof"] = "transportEof"
134
+
135
+
136
+ HostDeathNotification = Union[ExitClassification, TransportEof]
137
+ """How a client learns its host is gone: the process exit, or transport EOF."""
138
+
139
+ AbnormalExit = HostDeathNotification
140
+ """Every death notification except the orderly ``cleanShutdown`` row.
141
+
142
+ The TS twin narrows this with ``Exclude<…, {kind: "cleanShutdown"}>``; Python
143
+ carries the same intent at the boundary — :func:`is_abnormal_host_death` is
144
+ the only gate that constructs a :class:`MuseHostDiedError`, and it refuses a
145
+ ``cleanShutdown`` — rather than in the type, which has no structural exclude.
146
+ """
147
+
148
+
149
+ def is_abnormal_host_death(notification: HostDeathNotification) -> bool:
150
+ """Was this notification an abnormal death (SS2.13.3b)?
151
+
152
+ Exit 0 (``cleanShutdown``) is the ONLY SS2.11 row where the run was
153
+ cancelled, the drain completed, and the durable ``SessionEnd`` records
154
+ were written. Every other row — including tidy-looking names like
155
+ ``configError`` — left no ``SessionEnd``, which is what "abnormal" means
156
+ here. A transport EOF the client did not cause left no ``SessionEnd``
157
+ either, so it joins the same predicate rather than a parallel one that
158
+ could drift.
159
+
160
+ Args:
161
+ notification: The process-exit classification or the transport EOF.
162
+
163
+ Returns:
164
+ ``True`` for every notification but the orderly ``cleanShutdown``.
165
+ """
166
+ return notification.kind != "cleanShutdown"
167
+
168
+
169
+ def is_item_in_progress(item: Item) -> bool:
170
+ """The in-progress probe the store asks its caller for.
171
+
172
+ ``Item.status`` is an open enum whose terminal rule is "anything other
173
+ than ``inProgress``", so this is a positive test on the one non-terminal
174
+ value rather than a negative test against a list a schema advance would
175
+ silently widen.
176
+
177
+ Args:
178
+ item: The generated item dict.
179
+
180
+ Returns:
181
+ ``True`` iff the item is still ``inProgress``.
182
+ """
183
+ return item.get("status") == _IN_PROGRESS
184
+
185
+
186
+ class MuseHostDiedError(Exception):
187
+ """A durable host died abnormally: its terminals arrive on resume (FM-001).
188
+
189
+ Attributes:
190
+ exit: The abnormal notification (never a ``cleanShutdown``).
191
+ """
192
+
193
+ def __init__(self, exit_notification: AbnormalExit) -> None:
194
+ """Builds the error from the abnormal notification.
195
+
196
+ Args:
197
+ exit_notification: The exit classification or transport EOF; a
198
+ ``cleanShutdown`` is not a death and is never passed here (the
199
+ one gate is :func:`is_abnormal_host_death`).
200
+ """
201
+ super().__init__(
202
+ f"MSP host died: {exit_notification.kind} "
203
+ f"({MuseHostDiedError._cause(exit_notification)})"
204
+ )
205
+ self.exit = exit_notification
206
+
207
+ @staticmethod
208
+ def _cause(exit_notification: AbnormalExit) -> str:
209
+ # EOF has no exit code to render, and "exit code None" would read as a
210
+ # missing datum rather than the fact that the process has not reported.
211
+ if isinstance(exit_notification, TransportEof):
212
+ return "transport EOF with no orderly close"
213
+ # A signal kill has a null exit code and the signal is its ONLY
214
+ # diagnostic — rendering "exit code None" throws that away.
215
+ if exit_notification.kind == "crash" and exit_notification.exit_code is None:
216
+ return f"signal {exit_notification.exit_signal}"
217
+ return f"exit code {exit_notification.exit_code}"
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class _NotADeath:
222
+ profile: SessionDurabilityProfile
223
+ exit: HostDeathNotification
224
+ kind: Literal["notADeath"] = "notADeath"
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class _DurableDeath:
229
+ profile: SessionDurabilityProfile
230
+ exit: AbnormalExit
231
+ kind: Literal["durableDeath"] = "durableDeath"
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class _Discharged(Generic[_I]):
236
+ profile: SessionDurabilityProfile
237
+ exit: AbnormalExit
238
+ # Items still ``inProgress`` at death, annotated — never a synthesized
239
+ # ``item/completed``.
240
+ terminal_unknown_items: "tuple[TerminalUnknownItemAnnotation, ...]"
241
+ retired_commands: "tuple[PendingRetirement[_I], ...]"
242
+ kind: Literal["discharged"] = "discharged"
243
+
244
+
245
+ HostDeathDischarge = Union[_NotADeath, _DurableDeath, _Discharged[_I]]
246
+ """What one :meth:`Session.host_exited` call did.
247
+
248
+ Discriminated on ``kind`` because the three outcomes leave the session in
249
+ three different states: after ``notADeath`` it is still usable; after
250
+ ``durableDeath`` it is dead until resume with every live wait rejected; after
251
+ ``discharged`` the ephemeral obligation was paid in full. A bare
252
+ ``discharged: bool`` collapsed the first two, so an embedder had to re-derive
253
+ the state from the profile and the exit row this call already read.
254
+ """