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,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
|
+
"""
|