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,444 @@
|
|
|
1
|
+
"""``MuseClient`` — the SS7.1 facade's session-opening half (spec 638
|
|
2
|
+
FR-638-019a / T030, FR-638-019d / T033). Port of
|
|
3
|
+
``clients/sdk-ts/src/facade/client.ts``.
|
|
4
|
+
|
|
5
|
+
It turns the two v1 session verbs into correctly-shaped ``session/start`` /
|
|
6
|
+
``session/resume`` params and hands back a WIRED ``Session``. It frames
|
|
7
|
+
nothing and mints nothing: ``Connection.command()`` stamps the ``commandId``
|
|
8
|
+
from the single injected mint, so INV-013's single-minter property is
|
|
9
|
+
preserved by DELEGATION rather than re-implemented here.
|
|
10
|
+
|
|
11
|
+
It owns two facts no other layer holds, which is why T030's obligations (b)
|
|
12
|
+
and (c) live here rather than on ``Session``:
|
|
13
|
+
|
|
14
|
+
- WHETHER A CLOSE WAS OURS. ``Connection`` is duplex-generic; a transport
|
|
15
|
+
cannot tell a peer's hang-up from its own shutdown. SS2.13.3b counts
|
|
16
|
+
"process exit OR transport EOF" as a death, so somebody has to know the
|
|
17
|
+
difference, and only the layer that owns ``close()`` does.
|
|
18
|
+
- WHAT AN EARLIER DEATH DISCARDED. :class:`DiscardedSessions` is
|
|
19
|
+
client-scoped, so a ``commandId`` refusal and a withheld reattach survive
|
|
20
|
+
the ``Session`` object that died.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
from dataclasses import dataclass, fields
|
|
27
|
+
from typing import Any, Awaitable, Generic, Mapping, Sequence, Set, TypeVar
|
|
28
|
+
|
|
29
|
+
from muse_code_msp import (
|
|
30
|
+
ClientCapabilities,
|
|
31
|
+
ClientInfo,
|
|
32
|
+
InitializeParams,
|
|
33
|
+
InitializeResult,
|
|
34
|
+
SessionResumeParams,
|
|
35
|
+
SessionStartParams,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from ..connection.connection import Connection
|
|
39
|
+
from ..connection.spawn import (
|
|
40
|
+
DEFAULT_SHUTDOWN_TIMEOUT_MS,
|
|
41
|
+
ExitClassification,
|
|
42
|
+
MuseServeChild,
|
|
43
|
+
OnStderr,
|
|
44
|
+
SpawnedMspConnection,
|
|
45
|
+
spawn_msp_connection,
|
|
46
|
+
)
|
|
47
|
+
from ..errors import MuseSessionDiscardedError
|
|
48
|
+
from .discarded import DiscardedSessions
|
|
49
|
+
from .host_death import (
|
|
50
|
+
SessionDurabilityProfile,
|
|
51
|
+
TransportEof,
|
|
52
|
+
is_abnormal_host_death,
|
|
53
|
+
read_session_durability,
|
|
54
|
+
survives_host_death,
|
|
55
|
+
)
|
|
56
|
+
from .session import Session, SessionOpening, _ResumeOpening, _StartOpening
|
|
57
|
+
from .turn_submit import _camel
|
|
58
|
+
|
|
59
|
+
_I = TypeVar("_I")
|
|
60
|
+
|
|
61
|
+
# OPTIONAL MEANS OMITTED, NEVER null (tdd SS1.2): every member the two option
|
|
62
|
+
# builders can leave unset is optional-non-nullable in the generated params,
|
|
63
|
+
# so an unset member is dropped from the frame rather than nulled.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class StartSessionOptions:
|
|
68
|
+
"""Caller-facing options for ``session/start`` (tdd SS2.5.1).
|
|
69
|
+
|
|
70
|
+
Composed from the generated ``SessionStartParams`` (idiomatic snake_case,
|
|
71
|
+
C-638-1). ``config`` is excluded as well as ``commandId`` until the facade
|
|
72
|
+
implements the required ``sessionMcp`` grant check and forwarding.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
approval_mode: The approval enforcement mode to start under; omitted
|
|
76
|
+
= the host's default.
|
|
77
|
+
provider_id: The provider to serve the session with; omitted = the
|
|
78
|
+
host's default.
|
|
79
|
+
session_id: A caller-chosen session id; omitted = the host mints one.
|
|
80
|
+
workspace_root: The workspace the session works in; omitted = the
|
|
81
|
+
host decides.
|
|
82
|
+
model_id: The model to serve the session with; omitted = the
|
|
83
|
+
provider's default.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
approval_mode: str | None = None
|
|
87
|
+
provider_id: str | None = None
|
|
88
|
+
session_id: str | None = None
|
|
89
|
+
workspace_root: str | None = None
|
|
90
|
+
model_id: str | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ResumeSessionOptions:
|
|
95
|
+
"""Caller-facing options for ``session/resume`` (tdd SS2.5.2).
|
|
96
|
+
|
|
97
|
+
``config`` remains excluded until the same capability-checked forwarding
|
|
98
|
+
as ``session/start`` lands.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
session_id: The session to resume.
|
|
102
|
+
cursor: Resume from this view cursor; omitted = from genesis.
|
|
103
|
+
exclude_items: Ask the host to omit item bodies from the history.
|
|
104
|
+
history: The history preference forwarded as the wire's ``history``
|
|
105
|
+
member.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
session_id: str
|
|
109
|
+
cursor: str | None = None
|
|
110
|
+
exclude_items: bool | None = None
|
|
111
|
+
history: Mapping[str, Any] | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Drift pins — the Python twin of the TS `AssertNever` on START_FORWARDED /
|
|
115
|
+
# RESUME_FORWARDED (client.ts). A schema regen that adds a member to the
|
|
116
|
+
# generated params but not to the options dataclass reds THIS import (and thus
|
|
117
|
+
# every test), so a regenerated member cannot silently fail to reach the wire
|
|
118
|
+
# (INV-638-02). The excluded members are the ones the facade owns rather than
|
|
119
|
+
# forwards: `commandId` (the connection is the single minter) and `config`
|
|
120
|
+
# (temporarily withheld until capability-checked forwarding lands).
|
|
121
|
+
_START_EXCLUDED = {"commandId", "config"}
|
|
122
|
+
assert {_camel(f.name) for f in fields(StartSessionOptions)} | _START_EXCLUDED == set(
|
|
123
|
+
SessionStartParams.__annotations__
|
|
124
|
+
), "StartSessionOptions drifted from SessionStartParams — forward the new member"
|
|
125
|
+
|
|
126
|
+
_RESUME_EXCLUDED = {"commandId", "config"}
|
|
127
|
+
assert {_camel(f.name) for f in fields(ResumeSessionOptions)} | _RESUME_EXCLUDED == set(
|
|
128
|
+
SessionResumeParams.__annotations__
|
|
129
|
+
), "ResumeSessionOptions drifted from SessionResumeParams — forward the new member"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class MuseClientOptions:
|
|
134
|
+
"""Options for building a ``MuseClient`` around a connection you own.
|
|
135
|
+
|
|
136
|
+
:meth:`MuseClient.spawn` fills these in for you; supply them yourself only
|
|
137
|
+
when you compose the client with your own transport and handshake.
|
|
138
|
+
|
|
139
|
+
Attributes:
|
|
140
|
+
durability: The handshake's ``sessionDurability``, read through
|
|
141
|
+
:func:`~muse_code.facade.host_death.read_session_durability`.
|
|
142
|
+
REQUIRED for the same reason ``Session``'s is.
|
|
143
|
+
host: The owned host, set by :meth:`MuseClient.spawn`. A client built
|
|
144
|
+
around a connection somebody else owns has none, and says so by
|
|
145
|
+
omitting it — which is why ``close()`` falls back to closing just
|
|
146
|
+
the connection.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
durability: SessionDurabilityProfile
|
|
150
|
+
host: SpawnedMspConnection | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class MuseClientSpawnOptions:
|
|
155
|
+
"""What :meth:`MuseClient.spawn` needs: the host binary, plus the
|
|
156
|
+
handshake identity.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
muse_bin: The host binary to run.
|
|
160
|
+
client_info: Client identification, forwarded verbatim into
|
|
161
|
+
``initialize`` (SS1.4).
|
|
162
|
+
args: Passed through verbatim (a bare ``muse`` is the TUI — pass
|
|
163
|
+
``["serve"]`` for the MSP host).
|
|
164
|
+
cwd: Working directory for the host.
|
|
165
|
+
env: The host's environment (inherited when ``None``).
|
|
166
|
+
capabilities: Capability posture; absent means all defaults.
|
|
167
|
+
on_stderr: Raw stderr chunks, drained from birth, never parsed
|
|
168
|
+
(INV-638-04 carrying INV-010).
|
|
169
|
+
shutdown_timeout_ms: See ``MuseServeChild.spawn``.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
muse_bin: str
|
|
173
|
+
client_info: ClientInfo
|
|
174
|
+
args: Sequence[str] | None = None
|
|
175
|
+
cwd: str | None = None
|
|
176
|
+
env: Mapping[str, str] | None = None
|
|
177
|
+
capabilities: ClientCapabilities | None = None
|
|
178
|
+
on_stderr: OnStderr | None = None
|
|
179
|
+
shutdown_timeout_ms: int | None = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class MuseClient(Generic[_I]):
|
|
183
|
+
"""Your entry point to the SDK.
|
|
184
|
+
|
|
185
|
+
Spawn a host with :meth:`spawn`, then open conversations with
|
|
186
|
+
:meth:`start_session` or :meth:`resume_session`. The client owns the
|
|
187
|
+
connection: it routes every server event to the session it belongs to and
|
|
188
|
+
reports a host death to all of them. Call :meth:`close` to shut the host
|
|
189
|
+
down cleanly.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(self, connection: Connection, options: MuseClientOptions) -> None:
|
|
193
|
+
"""Builds a client over a connection you own.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
connection: A ready :class:`~muse_code.connection.Connection`.
|
|
197
|
+
options: The durability profile and the optional owned host.
|
|
198
|
+
"""
|
|
199
|
+
self._connection = connection
|
|
200
|
+
self._durability = options.durability
|
|
201
|
+
# Client-owned, never injected: SS2.13.3b is a one-client obligation
|
|
202
|
+
# and no current consumer spans two clients over one host lineage, so a
|
|
203
|
+
# sharing knob would be declared-ahead surface (Constitution XI).
|
|
204
|
+
self._discarded = DiscardedSessions()
|
|
205
|
+
self._sessions: Set[Session[_I]] = set()
|
|
206
|
+
host = options.host
|
|
207
|
+
self._child: MuseServeChild | None = host.child if host is not None else None
|
|
208
|
+
self._initialize_result: InitializeResult | None = (
|
|
209
|
+
host.initialize_result if host is not None else None
|
|
210
|
+
)
|
|
211
|
+
# Set by close() BEFORE the connection is torn down: the same EOF is an
|
|
212
|
+
# orderly SS2.1.2 shutdown when we caused it and an abnormal death when
|
|
213
|
+
# we did not, and nothing below this layer can tell.
|
|
214
|
+
self._closing = False
|
|
215
|
+
# Latched by an abnormal EOF, so a later resume_session can be withheld.
|
|
216
|
+
self._host_died = False
|
|
217
|
+
# THE ONE INBOUND PUMP (FR-638-019): ``Session.apply`` is the only
|
|
218
|
+
# event entry point and ``_connection`` is private, so without this a
|
|
219
|
+
# spawn-built consumer hears no view frame. ``Connection`` holds ONE
|
|
220
|
+
# notification handler — this client claims it.
|
|
221
|
+
connection.on_notification(self._route)
|
|
222
|
+
# Registered at construction, not at the first session: an EOF can land
|
|
223
|
+
# before any session exists, and the flag has to be read at that moment.
|
|
224
|
+
self._closed_watch = asyncio.ensure_future(self._watch_closed())
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
async def spawn(options: MuseClientSpawnOptions) -> "MuseClient[Any]":
|
|
228
|
+
"""Spawn an owned host, run the SS1.4 handshake, and hand back a client
|
|
229
|
+
whose durability profile came from that handshake (FR-638-019a).
|
|
230
|
+
|
|
231
|
+
The profile is READ rather than asked of the caller, which is the whole
|
|
232
|
+
reason this factory exists beside the bare constructor: SS2.13.1 makes
|
|
233
|
+
the absent/unrecognized distinction load-bearing, and a caller
|
|
234
|
+
re-deriving it by hand can get it wrong.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
options: The host binary and handshake identity.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
A wired client, its connection already initialized.
|
|
241
|
+
"""
|
|
242
|
+
handshake = await spawn_msp_connection(
|
|
243
|
+
options.muse_bin,
|
|
244
|
+
args=tuple(options.args) if options.args is not None else (),
|
|
245
|
+
cwd=options.cwd,
|
|
246
|
+
env=options.env,
|
|
247
|
+
on_stderr=options.on_stderr,
|
|
248
|
+
# ``spawn_msp_connection``'s default IS this constant, so passing it
|
|
249
|
+
# when the caller left the option unset is byte-identical to the
|
|
250
|
+
# bare call and keeps the parameter's type an ``int`` for mypy.
|
|
251
|
+
shutdown_timeout_ms=(
|
|
252
|
+
DEFAULT_SHUTDOWN_TIMEOUT_MS
|
|
253
|
+
if options.shutdown_timeout_ms is None
|
|
254
|
+
else options.shutdown_timeout_ms
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
init_params: InitializeParams = {"clientInfo": options.client_info}
|
|
258
|
+
if options.capabilities is not None:
|
|
259
|
+
init_params["capabilities"] = options.capabilities
|
|
260
|
+
try:
|
|
261
|
+
spawned = await handshake.initialize(init_params)
|
|
262
|
+
except BaseException:
|
|
263
|
+
# A failed handshake must not leak the process it already spawned.
|
|
264
|
+
# (``initialize`` already closes on its own failure paths; this is
|
|
265
|
+
# the belt-and-braces close for anything that escapes it.)
|
|
266
|
+
try:
|
|
267
|
+
await handshake.close()
|
|
268
|
+
except BaseException:
|
|
269
|
+
pass
|
|
270
|
+
raise
|
|
271
|
+
return MuseClient(
|
|
272
|
+
spawned.connection,
|
|
273
|
+
MuseClientOptions(
|
|
274
|
+
durability=read_session_durability(spawned.initialize_result),
|
|
275
|
+
host=spawned,
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def initialize_result(self) -> InitializeResult:
|
|
281
|
+
"""The handshake result, when this client was built by :meth:`spawn`.
|
|
282
|
+
|
|
283
|
+
A plain error, not a typed one: reading this off a client not built by
|
|
284
|
+
``spawn`` is API misuse, not a protocol STATE an embedder branches on.
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
RuntimeError: This client was not built by ``spawn``.
|
|
288
|
+
"""
|
|
289
|
+
if self._initialize_result is None:
|
|
290
|
+
raise RuntimeError(
|
|
291
|
+
"initialize_result is only available on a client built by MuseClient.spawn"
|
|
292
|
+
)
|
|
293
|
+
return self._initialize_result
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def exit(self) -> Awaitable[ExitClassification]:
|
|
297
|
+
"""The host's SS2.11 exit row, when this client was built by
|
|
298
|
+
:meth:`spawn`.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
RuntimeError: This client was not built by ``spawn``.
|
|
302
|
+
"""
|
|
303
|
+
if self._child is None:
|
|
304
|
+
raise RuntimeError(
|
|
305
|
+
"exit is only available on a client built by MuseClient.spawn"
|
|
306
|
+
)
|
|
307
|
+
return self._child.exit
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def durability(self) -> SessionDurabilityProfile:
|
|
311
|
+
"""The durability profile every session this client opens inherits."""
|
|
312
|
+
return self._durability
|
|
313
|
+
|
|
314
|
+
async def start_session(
|
|
315
|
+
self, options: StartSessionOptions | None = None
|
|
316
|
+
) -> Session[_I]:
|
|
317
|
+
"""Open a new root session (tdd SS2.5.1)."""
|
|
318
|
+
options = options or StartSessionOptions()
|
|
319
|
+
params: dict[str, Any] = {}
|
|
320
|
+
if options.approval_mode is not None:
|
|
321
|
+
params["approvalMode"] = options.approval_mode
|
|
322
|
+
if options.provider_id is not None:
|
|
323
|
+
params["providerId"] = options.provider_id
|
|
324
|
+
if options.session_id is not None:
|
|
325
|
+
params["sessionId"] = options.session_id
|
|
326
|
+
if options.workspace_root is not None:
|
|
327
|
+
params["workspaceRoot"] = options.workspace_root
|
|
328
|
+
if options.model_id is not None:
|
|
329
|
+
params["modelId"] = options.model_id
|
|
330
|
+
raw = await self._connection.command("session/start", params)
|
|
331
|
+
result: Mapping[str, Any] = raw
|
|
332
|
+
# Keyed by the id the SERVER named, never the one the caller asked for:
|
|
333
|
+
# ``sessionId`` is a REQUEST on start, and a session keyed by a hopeful
|
|
334
|
+
# id would reject every one of its own events as foreign.
|
|
335
|
+
return self._open_session(
|
|
336
|
+
str(result["session"]["sessionId"]),
|
|
337
|
+
_StartOpening(result=result),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
async def resume_session(self, options: ResumeSessionOptions) -> Session[_I]:
|
|
341
|
+
"""Load an existing session and subscribe this connection to its view
|
|
342
|
+
(tdd SS2.5.2).
|
|
343
|
+
|
|
344
|
+
WITHHELD after an ephemeral host death (SS2.13.3b clause 2, "do not
|
|
345
|
+
attempt to reattach"), and refused on THIS side of the transport: a
|
|
346
|
+
reattach that reaches the wire has already violated the clause,
|
|
347
|
+
whatever the server answers.
|
|
348
|
+
"""
|
|
349
|
+
self._assert_reattach_allowed(options.session_id)
|
|
350
|
+
params: dict[str, Any] = {"sessionId": options.session_id}
|
|
351
|
+
if options.cursor is not None:
|
|
352
|
+
params["cursor"] = options.cursor
|
|
353
|
+
if options.exclude_items is not None:
|
|
354
|
+
params["excludeItems"] = options.exclude_items
|
|
355
|
+
if options.history is not None:
|
|
356
|
+
params["history"] = options.history
|
|
357
|
+
raw = await self._connection.command("session/resume", params)
|
|
358
|
+
result: Mapping[str, Any] = raw
|
|
359
|
+
return self._open_session(
|
|
360
|
+
str(result["session"]["sessionId"]),
|
|
361
|
+
_ResumeOpening(result=result),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
async def close(self) -> None:
|
|
365
|
+
"""Shut the host down in an orderly way (SS2.1.2).
|
|
366
|
+
|
|
367
|
+
The EOF this close produces is NOT a death, which is what
|
|
368
|
+
``_closing`` records. ``_closing`` covers only the EOF; it must not
|
|
369
|
+
swallow the EXIT ROW: when the host ignores stdin EOF, the child close
|
|
370
|
+
escalates to SIGTERM/SIGKILL and the exit classifies as a crash — an
|
|
371
|
+
abnormal death under SS2.13.3b no matter who started the close, and one
|
|
372
|
+
no transport event will report because ``_closing`` already claimed the
|
|
373
|
+
EOF. So the close's own classification is forwarded to every session;
|
|
374
|
+
``host_exited`` answers ``notADeath`` for a clean shutdown, keeping the
|
|
375
|
+
orderly arm inert.
|
|
376
|
+
"""
|
|
377
|
+
self._closing = True
|
|
378
|
+
if self._child is not None:
|
|
379
|
+
exit_classification: ExitClassification = await self._child.close()
|
|
380
|
+
if is_abnormal_host_death(exit_classification):
|
|
381
|
+
for session in self._sessions:
|
|
382
|
+
session.host_exited(exit_classification)
|
|
383
|
+
return
|
|
384
|
+
await self._connection.close()
|
|
385
|
+
|
|
386
|
+
def _open_session(
|
|
387
|
+
self, session_id: str, opening: SessionOpening
|
|
388
|
+
) -> Session[_I]:
|
|
389
|
+
session: Session[_I] = Session(
|
|
390
|
+
session_id,
|
|
391
|
+
self._durability,
|
|
392
|
+
connection=self._connection,
|
|
393
|
+
discarded=self._discarded,
|
|
394
|
+
opening=opening,
|
|
395
|
+
)
|
|
396
|
+
self._sessions.add(session)
|
|
397
|
+
return session
|
|
398
|
+
|
|
399
|
+
def _assert_reattach_allowed(self, session_id: str) -> None:
|
|
400
|
+
# Two facts, both recorded by an ephemeral discharge: THIS session was
|
|
401
|
+
# discarded, or this client's own ephemeral host died (nothing on the
|
|
402
|
+
# other end to reattach TO, whatever id is asked for).
|
|
403
|
+
if session_id in self._discarded.session_ids:
|
|
404
|
+
raise MuseSessionDiscardedError(
|
|
405
|
+
f"session {session_id} was discarded after an ephemeral host "
|
|
406
|
+
"death; it cannot be resumed"
|
|
407
|
+
)
|
|
408
|
+
if self._host_died and not survives_host_death(self._durability):
|
|
409
|
+
raise MuseSessionDiscardedError(
|
|
410
|
+
"this client's ephemeral host died; SS2.13.3b forbids "
|
|
411
|
+
"reattaching to it"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def _route(self, notification: Mapping[str, Any]) -> None:
|
|
415
|
+
# Deliver one view notification to the session(s) it names. A frame
|
|
416
|
+
# naming NO session is dropped here rather than fed to every session,
|
|
417
|
+
# and a frame naming an UNKNOWN session is dropped for the same reason
|
|
418
|
+
# — routing it anywhere would trip the foreign-frame throw.
|
|
419
|
+
params = notification.get("params")
|
|
420
|
+
named = params.get("sessionId") if isinstance(params, dict) else None
|
|
421
|
+
if not isinstance(named, str):
|
|
422
|
+
return
|
|
423
|
+
for session in self._sessions:
|
|
424
|
+
if session.session_id == named:
|
|
425
|
+
session.apply(notification)
|
|
426
|
+
|
|
427
|
+
async def _watch_closed(self) -> None:
|
|
428
|
+
try:
|
|
429
|
+
await self._connection.closed
|
|
430
|
+
except asyncio.CancelledError:
|
|
431
|
+
return
|
|
432
|
+
self._transport_closed()
|
|
433
|
+
|
|
434
|
+
def _transport_closed(self) -> None:
|
|
435
|
+
# The transport reached EOF. If this client did not cause it, that is
|
|
436
|
+
# SS2.13.3b's second death notification (T030 obligation b), and every
|
|
437
|
+
# session discharges through the SAME ``host_exited`` path the process
|
|
438
|
+
# exit uses — including its latch, so whichever notification arrives
|
|
439
|
+
# second replays the first one's report.
|
|
440
|
+
if self._closing:
|
|
441
|
+
return
|
|
442
|
+
self._host_died = True
|
|
443
|
+
for session in self._sessions:
|
|
444
|
+
session.host_exited(TransportEof())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""What an ephemeral host death discarded, remembered ACROSS the sessions one
|
|
2
|
+
client opened (spec 638 FR-638-019d carrying spec 14990 T030 obligation (c);
|
|
3
|
+
tdd SS2.13.3b). Port of ``clients/sdk-ts/src/facade/discarded.ts``.
|
|
4
|
+
|
|
5
|
+
SS2.13.3b's five-clause MUST includes "do not attempt to reattach" and "do
|
|
6
|
+
not replay the session's ``commandId``\\ s". Both are statements about what
|
|
7
|
+
the CLIENT does next, and "next" outlives the session object that died:
|
|
8
|
+
|
|
9
|
+
- :class:`~muse_code.pending.PendingCommandSet` already refuses a replay of
|
|
10
|
+
an id IT discarded, but its memory is per-instance, so a FRESH ``Session``
|
|
11
|
+
— the natural thing to build after a host dies — knew nothing and would
|
|
12
|
+
replay those ids at the new host. That is the exactly-once violation the
|
|
13
|
+
clause exists to prevent.
|
|
14
|
+
- nothing at all held the discarded ``sessionId``, so ``resume_session`` had
|
|
15
|
+
no fact to withhold on.
|
|
16
|
+
|
|
17
|
+
This is client state, never a wire shape and never durable (INV-638-05): it
|
|
18
|
+
lives exactly as long as the ``MuseClient`` that owns it. It is deliberately
|
|
19
|
+
NOT a module-global — two clients in one process are two independent trust
|
|
20
|
+
boundaries, and a process-wide set would let one client's dead host silence
|
|
21
|
+
another's live one.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DiscardedSessions:
|
|
28
|
+
"""The two sets, exposed directly rather than behind add/has pairs.
|
|
29
|
+
|
|
30
|
+
:class:`~muse_code.pending.PendingCommandSet` takes the ``command_ids``
|
|
31
|
+
set BY REFERENCE and writes to it from ``discard_ephemeral()``, so a
|
|
32
|
+
wrapper would have to re-expose a mutable seam anyway; a ``set[str]`` is
|
|
33
|
+
the honest shape, and it is already the shape that class uses internally.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
command_ids: ``commandId``\\ s an ephemeral discharge retired. Never
|
|
37
|
+
replayed again.
|
|
38
|
+
session_ids: ``sessionId``\\ s whose ephemeral host died. Never
|
|
39
|
+
reattached.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
"""Builds an empty pair of discard sets."""
|
|
44
|
+
self.command_ids: set[str] = set()
|
|
45
|
+
self.session_ids: set[str] = set()
|