muse-code-sdk 1.3.1__tar.gz → 1.4.4__tar.gz
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_sdk-1.3.1/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.4}/PKG-INFO +1 -1
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/pyproject.toml +1 -1
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/__init__.py +4 -2
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/spawn.py +6 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/__init__.py +2 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/approval.py +121 -4
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/client.py +97 -17
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/gap_fill.py +13 -0
- muse_code_sdk-1.4.4/src/muse_code/facade/host_start.py +115 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/session.py +23 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/turn_handle.py +4 -4
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/turn_submit.py +10 -1
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/fold/session_fold.py +35 -4
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4/src/muse_code_sdk.egg-info}/PKG-INFO +1 -1
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code_sdk.egg-info/SOURCES.txt +4 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_compatibility_row.py +69 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_facade_approval.py +394 -19
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_facade_gap_fill.py +102 -0
- muse_code_sdk-1.4.4/tests/test_facade_open_same_chunk.py +265 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_facade_turn.py +39 -0
- muse_code_sdk-1.4.4/tests/test_public_mirror_text.py +1844 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_publish_shape.py +40 -8
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_session_fold.py +114 -7
- muse_code_sdk-1.4.4/tests/test_spawn_host_lost.py +295 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/LICENSE +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/README.md +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/setup.cfg +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/__init__.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/connection.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/discovery.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/validation.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/errors.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/discarded.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/host_death.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/fold/__init__.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/fold/item_store.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/fold/state_store.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/pending/__init__.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/pending/pending_command_set.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/py.typed +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/replay.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/sync_facade.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code_sdk.egg-info/requires.txt +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_asyncio_cancellation.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_connection.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_connection_error_arms.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_docs_generation.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_external_audience.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_facade_persistent_approval.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_host_discovery.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_item_store.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_manifests.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_muse_serve_child.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_pending_command_corpus.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_pending_command_set.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_pending_command_wire_binding.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_source_closure.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_spawn_handshake.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_state_store.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_sync_facade.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_tolerance_corpus.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_transcript_replay.py +0 -0
- {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_typing_gates.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: muse-code-sdk
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: Muse Code SDK for MSP: asyncio-first facade over the generated wire types (muse-code-msp), with a thin sync wrapper.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/meta-models/muse-code-sdk
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "muse-code-sdk"
|
|
8
|
-
version = "1.
|
|
8
|
+
version = "1.4.4"
|
|
9
9
|
description = "Muse Code SDK for MSP: asyncio-first facade over the generated wire types (muse-code-msp), with a thin sync wrapper."
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -47,6 +47,7 @@ __all__ = [
|
|
|
47
47
|
"MuseForeignSessionError",
|
|
48
48
|
"MuseGapFillError",
|
|
49
49
|
"MuseHostDiedError",
|
|
50
|
+
"MuseHostStartError",
|
|
50
51
|
"MuseSessionDiscardedError",
|
|
51
52
|
"PendingCommandSet",
|
|
52
53
|
"PendingCommandView",
|
|
@@ -77,7 +78,7 @@ __all__ = [
|
|
|
77
78
|
]
|
|
78
79
|
|
|
79
80
|
EXPECTED_SCHEMA_FINGERPRINT: Final[str] = (
|
|
80
|
-
"sha256:
|
|
81
|
+
"sha256:7c94f153c41659cb3f1bd3c3e04438be254644cb2a97d65d48edc7449b74858a"
|
|
81
82
|
)
|
|
82
83
|
"""The stable schema-bundle fingerprint this SDK was built against.
|
|
83
84
|
|
|
@@ -89,7 +90,7 @@ Python SDK reds this lane instead of drifting silently.
|
|
|
89
90
|
# The lockstep rule: the release train bumps pyproject.toml, the
|
|
90
91
|
# distribution-metadata authority; this constant is aligned at publication
|
|
91
92
|
# time.
|
|
92
|
-
__version__: Final[str] = "1.
|
|
93
|
+
__version__: Final[str] = "1.4.4"
|
|
93
94
|
|
|
94
95
|
# The facade is imported last so the constants above are already bound: the
|
|
95
96
|
# spawn path reads ``EXPECTED_SCHEMA_FINGERPRINT`` (lazily, at initialize
|
|
@@ -108,6 +109,7 @@ from .facade import ( # noqa: E402
|
|
|
108
109
|
MuseClientOptions,
|
|
109
110
|
MuseClientSpawnOptions,
|
|
110
111
|
MuseHostDiedError,
|
|
112
|
+
MuseHostStartError,
|
|
111
113
|
PendingCommandView,
|
|
112
114
|
ResumeSessionOptions,
|
|
113
115
|
SendUserTurnOptions,
|
|
@@ -897,6 +897,11 @@ class MspHandshake:
|
|
|
897
897
|
self._transport = child._transport
|
|
898
898
|
self._connection = connection
|
|
899
899
|
self._started = False
|
|
900
|
+
# Did ``initialize`` get an answer before the handshake failed? Read
|
|
901
|
+
# by the facade, never a public member: it lets ``MuseClient.spawn``
|
|
902
|
+
# say truthfully that a host died AFTER answering, while the
|
|
903
|
+
# ``initialized`` notification was being sent.
|
|
904
|
+
self._answered = False
|
|
900
905
|
self._transport._adopt_flush_source(connection._submission_tail)
|
|
901
906
|
|
|
902
907
|
@property
|
|
@@ -957,6 +962,7 @@ class MspHandshake:
|
|
|
957
962
|
# stays a no-op.
|
|
958
963
|
try:
|
|
959
964
|
raw: Any = await self._connection.request("initialize", dict(params))
|
|
965
|
+
self._answered = True
|
|
960
966
|
# The contract frame seam: the members this
|
|
961
967
|
# handshake reads validate through a pydantic model, so a
|
|
962
968
|
# malformed frame is the typed MuseValidationError — the seam
|
|
@@ -39,6 +39,7 @@ from .host_death import (
|
|
|
39
39
|
is_abnormal_host_death,
|
|
40
40
|
read_session_durability,
|
|
41
41
|
)
|
|
42
|
+
from .host_start import MuseHostStartError
|
|
42
43
|
from .session import (
|
|
43
44
|
PendingCommandView,
|
|
44
45
|
Session,
|
|
@@ -67,6 +68,7 @@ __all__ = [
|
|
|
67
68
|
"MuseClientOptions",
|
|
68
69
|
"MuseClientSpawnOptions",
|
|
69
70
|
"MuseHostDiedError",
|
|
71
|
+
"MuseHostStartError",
|
|
70
72
|
"PendingCommandView",
|
|
71
73
|
"ResumeSessionOptions",
|
|
72
74
|
"SendUserTurnOptions",
|
|
@@ -17,11 +17,16 @@ request upstream, never a local interface.
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import asyncio
|
|
20
21
|
import inspect
|
|
21
22
|
from dataclasses import dataclass
|
|
22
23
|
from typing import Any, Awaitable, Callable, Literal, Union
|
|
23
24
|
|
|
24
|
-
from muse_code_msp import
|
|
25
|
+
from muse_code_msp import (
|
|
26
|
+
ApprovalDecideParams,
|
|
27
|
+
ApprovalRequestParams,
|
|
28
|
+
ApprovalUpdatedParams,
|
|
29
|
+
)
|
|
25
30
|
|
|
26
31
|
from ..connection.connection import Connection
|
|
27
32
|
|
|
@@ -37,6 +42,40 @@ assert _DECIDE_FORWARDED | {"commandId"} == (
|
|
|
37
42
|
ApprovalDecideParams.__required_keys__ | ApprovalDecideParams.__optional_keys__
|
|
38
43
|
), "approval/decide grew a member — forward it through ApprovalRouter"
|
|
39
44
|
|
|
45
|
+
# The refreshed-request merge in ``ApprovalRouter.updated``, listed by SOURCE:
|
|
46
|
+
# stage-scoped members come from the ``approval/updated`` frame, identity
|
|
47
|
+
# members from the retained request (stable across the stages of one
|
|
48
|
+
# approval). A regenerated member, required or optional, fails this import
|
|
49
|
+
# until it is given a side; otherwise it would silently vanish from every
|
|
50
|
+
# later stage's request.
|
|
51
|
+
_REFRESH_FROM_UPDATE = frozenset(
|
|
52
|
+
{
|
|
53
|
+
"availableChoices",
|
|
54
|
+
"currentRequirementId",
|
|
55
|
+
"sessionId",
|
|
56
|
+
"sourceRange",
|
|
57
|
+
"subagentOrigin",
|
|
58
|
+
"subject",
|
|
59
|
+
"viewCursor",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
_REFRESH_FROM_REQUEST = frozenset(
|
|
63
|
+
{
|
|
64
|
+
"approvalId",
|
|
65
|
+
"itemId",
|
|
66
|
+
"judgeEscalated",
|
|
67
|
+
"protectedWrite",
|
|
68
|
+
"rawArgs",
|
|
69
|
+
"taskId",
|
|
70
|
+
"toolCallId",
|
|
71
|
+
"toolName",
|
|
72
|
+
"turnId",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
assert _REFRESH_FROM_UPDATE | _REFRESH_FROM_REQUEST == (
|
|
76
|
+
ApprovalRequestParams.__required_keys__ | ApprovalRequestParams.__optional_keys__
|
|
77
|
+
), "approval/requested grew a member — give it a side in ApprovalRouter.updated"
|
|
78
|
+
|
|
40
79
|
|
|
41
80
|
@dataclass(frozen=True)
|
|
42
81
|
class ApprovalDecisionInput:
|
|
@@ -107,7 +146,8 @@ ApprovalFailureHandler = Callable[[ApprovalFailure], None]
|
|
|
107
146
|
|
|
108
147
|
|
|
109
148
|
class ApprovalRouter:
|
|
110
|
-
"""Routes folded ``approval/requested`` frames
|
|
149
|
+
"""Routes folded ``approval/requested`` frames, and ``approval/updated``
|
|
150
|
+
frames that advance a pending approval's stage, to the consumer's handler
|
|
111
151
|
and authors the ``approval/decide`` command."""
|
|
112
152
|
|
|
113
153
|
def __init__(self, session_id: str, connection: Connection | None) -> None:
|
|
@@ -131,6 +171,14 @@ class ApprovalRouter:
|
|
|
131
171
|
# ``requirementId`` and therefore a new key, so "ask the consumer once
|
|
132
172
|
# per stage" holds absolutely.
|
|
133
173
|
self._decided_stages: set[str] = set()
|
|
174
|
+
# Approvals whose own decide ack said ``terminal: true``: the host
|
|
175
|
+
# closed the whole approval, so a trailing stage update names nobody's
|
|
176
|
+
# question.
|
|
177
|
+
self._terminal_approvals: set[str] = set()
|
|
178
|
+
# The decide each approval has on the wire, set when its ack lands.
|
|
179
|
+
# An update read in the same chunk as a ``terminal: true`` ack is
|
|
180
|
+
# folded before that decide resumes, so the update must wait for it.
|
|
181
|
+
self._in_flight: dict[str, asyncio.Event] = {}
|
|
134
182
|
|
|
135
183
|
def on_approval(self, handler: ApprovalHandler) -> None:
|
|
136
184
|
"""Register the decision callback.
|
|
@@ -189,6 +237,63 @@ class ApprovalRouter:
|
|
|
189
237
|
return None
|
|
190
238
|
return self._decide(connection, params, handler)
|
|
191
239
|
|
|
240
|
+
def updated(
|
|
241
|
+
self,
|
|
242
|
+
requested: ApprovalRequestParams,
|
|
243
|
+
params: ApprovalUpdatedParams,
|
|
244
|
+
) -> Awaitable[None] | None:
|
|
245
|
+
"""An ``approval/updated`` folded onto a STILL-PENDING approval.
|
|
246
|
+
|
|
247
|
+
A multi-stage approval advances ``currentRequirementId`` through this
|
|
248
|
+
frame alone on the notification plane, so waiting for a re-issued
|
|
249
|
+
``approval/requested`` would leave the new stage undecided and the turn
|
|
250
|
+
pending. The update carries none of the request's identity members,
|
|
251
|
+
but the fold retains the original request and those members are stable
|
|
252
|
+
across the stages of one approval, so the merged request is honest.
|
|
253
|
+
Routing it through :meth:`requested` reuses the per-stage latch: an
|
|
254
|
+
update naming an already-decided stage authors nothing.
|
|
255
|
+
"""
|
|
256
|
+
# ``alreadyTerminal``: the host holds a durable terminal whose resolve
|
|
257
|
+
# frame has not landed yet; a decision against it can only bounce.
|
|
258
|
+
if params["change"]["kind"] == "alreadyTerminal":
|
|
259
|
+
return None
|
|
260
|
+
if requested["approvalId"] in self._terminal_approvals:
|
|
261
|
+
return None
|
|
262
|
+
refreshed: ApprovalRequestParams = {
|
|
263
|
+
"approvalId": requested["approvalId"],
|
|
264
|
+
"availableChoices": params["availableChoices"],
|
|
265
|
+
"currentRequirementId": params["currentRequirementId"],
|
|
266
|
+
"itemId": requested["itemId"],
|
|
267
|
+
"judgeEscalated": requested["judgeEscalated"],
|
|
268
|
+
"protectedWrite": requested["protectedWrite"],
|
|
269
|
+
"rawArgs": requested["rawArgs"],
|
|
270
|
+
"sessionId": params["sessionId"],
|
|
271
|
+
"sourceRange": params["sourceRange"],
|
|
272
|
+
"subject": params["subject"],
|
|
273
|
+
"taskId": requested["taskId"],
|
|
274
|
+
"toolCallId": requested["toolCallId"],
|
|
275
|
+
"toolName": requested["toolName"],
|
|
276
|
+
"turnId": requested["turnId"],
|
|
277
|
+
"viewCursor": params["viewCursor"],
|
|
278
|
+
}
|
|
279
|
+
# Omitted rather than nulled when absent, like ``feedback`` below.
|
|
280
|
+
if "subagentOrigin" in params:
|
|
281
|
+
refreshed["subagentOrigin"] = params["subagentOrigin"]
|
|
282
|
+
acked = self._in_flight.get(requested["approvalId"])
|
|
283
|
+
if acked is not None:
|
|
284
|
+
return self._after_ack(acked, refreshed)
|
|
285
|
+
return self.requested(refreshed)
|
|
286
|
+
|
|
287
|
+
async def _after_ack(
|
|
288
|
+
self, acked: asyncio.Event, refreshed: ApprovalRequestParams
|
|
289
|
+
) -> None:
|
|
290
|
+
await acked.wait()
|
|
291
|
+
if refreshed["approvalId"] in self._terminal_approvals:
|
|
292
|
+
return
|
|
293
|
+
decided = self.requested(refreshed)
|
|
294
|
+
if decided is not None:
|
|
295
|
+
await decided
|
|
296
|
+
|
|
192
297
|
@staticmethod
|
|
193
298
|
def _stage_key(params: ApprovalRequestParams) -> str:
|
|
194
299
|
requirement = params["currentRequirementId"]
|
|
@@ -252,14 +357,26 @@ class ApprovalRouter:
|
|
|
252
357
|
if feedback is not None:
|
|
253
358
|
decide_params["feedback"] = feedback
|
|
254
359
|
|
|
360
|
+
# The wait covers only this round trip: an update for this approval
|
|
361
|
+
# read before the ack lands waits for it, while a handler still open
|
|
362
|
+
# or a decide that never started blocks nobody.
|
|
363
|
+
approval_id = params["approvalId"]
|
|
364
|
+
acked = asyncio.Event()
|
|
365
|
+
self._in_flight[approval_id] = acked
|
|
255
366
|
try:
|
|
256
367
|
# No explicit ``command_id``: unlike ``send_user_turn``, nothing
|
|
257
368
|
# here needs the id before the ack, so ``Connection.command()``
|
|
258
369
|
# mints and stamps it — and its own same-id retry then reuses that
|
|
259
370
|
# id for free.
|
|
260
|
-
await connection.command("approval/decide", decide_params)
|
|
371
|
+
result = await connection.command("approval/decide", decide_params)
|
|
372
|
+
if result.get("terminal") is True:
|
|
373
|
+
self._terminal_approvals.add(approval_id)
|
|
261
374
|
except Exception as error: # noqa: BLE001 - reported, never escapes
|
|
262
|
-
self._report(SubmitFailed(approval_id=
|
|
375
|
+
self._report(SubmitFailed(approval_id=approval_id, error=error))
|
|
376
|
+
finally:
|
|
377
|
+
acked.set()
|
|
378
|
+
if self._in_flight.get(approval_id) is acked:
|
|
379
|
+
del self._in_flight[approval_id]
|
|
263
380
|
|
|
264
381
|
def _report(self, failure: ApprovalFailure) -> None:
|
|
265
382
|
handler = self._on_failure
|
|
@@ -23,7 +23,17 @@ from __future__ import annotations
|
|
|
23
23
|
|
|
24
24
|
import asyncio
|
|
25
25
|
from dataclasses import dataclass, fields
|
|
26
|
-
from typing import
|
|
26
|
+
from typing import (
|
|
27
|
+
Any,
|
|
28
|
+
Awaitable,
|
|
29
|
+
Callable,
|
|
30
|
+
Generic,
|
|
31
|
+
List,
|
|
32
|
+
Mapping,
|
|
33
|
+
Sequence,
|
|
34
|
+
Set,
|
|
35
|
+
TypeVar,
|
|
36
|
+
)
|
|
27
37
|
|
|
28
38
|
from muse_code_msp import (
|
|
29
39
|
ClientCapabilities,
|
|
@@ -45,6 +55,7 @@ from ..connection.spawn import (
|
|
|
45
55
|
)
|
|
46
56
|
from ..errors import MuseSessionDiscardedError
|
|
47
57
|
from .discarded import DiscardedSessions
|
|
58
|
+
from .host_start import MuseHostStartError, keeps_its_own_error
|
|
48
59
|
from .host_death import (
|
|
49
60
|
SessionDurabilityProfile,
|
|
50
61
|
TransportEof,
|
|
@@ -77,6 +88,9 @@ class StartSessionOptions:
|
|
|
77
88
|
session_id: A caller-chosen session id; omitted = the host mints one.
|
|
78
89
|
workspace_root: The workspace the session works in; omitted = the
|
|
79
90
|
host decides.
|
|
91
|
+
workspace_roots: The session's initial runtime workspace-root set
|
|
92
|
+
(wire ``workspaceRoots``; first root = the CWD). When
|
|
93
|
+
``workspace_root`` is also given the two must agree.
|
|
80
94
|
model_id: The model to serve the session with; omitted = the
|
|
81
95
|
provider's default.
|
|
82
96
|
"""
|
|
@@ -85,6 +99,7 @@ class StartSessionOptions:
|
|
|
85
99
|
provider_id: str | None = None
|
|
86
100
|
session_id: str | None = None
|
|
87
101
|
workspace_root: str | None = None
|
|
102
|
+
workspace_roots: List[str] | None = None
|
|
88
103
|
model_id: str | None = None
|
|
89
104
|
|
|
90
105
|
|
|
@@ -200,6 +215,14 @@ class MuseClient(Generic[_I]):
|
|
|
200
215
|
# sharing knob would be declared-ahead surface (Constitution XI).
|
|
201
216
|
self._discarded = DiscardedSessions()
|
|
202
217
|
self._sessions: Set[Session[_I]] = set()
|
|
218
|
+
# Frames naming a session this client has not registered YET, held
|
|
219
|
+
# only while a ``session/start``/``session/resume`` is in flight: the
|
|
220
|
+
# host flushes the resume replay BEFORE the result, and the coroutine
|
|
221
|
+
# awaiting that result resumes only after the whole chunk has been
|
|
222
|
+
# routed. ``_route`` holds, ``_open_session`` drains,
|
|
223
|
+
# ``_open`` clears when the last open settles.
|
|
224
|
+
self._opening = 0
|
|
225
|
+
self._held: dict[str, List[Mapping[str, Any]]] = {}
|
|
203
226
|
host = options.host
|
|
204
227
|
self._child: MuseServeChild | None = host.child if host is not None else None
|
|
205
228
|
self._initialize_result: InitializeResult | None = (
|
|
@@ -235,6 +258,19 @@ class MuseClient(Generic[_I]):
|
|
|
235
258
|
|
|
236
259
|
Returns:
|
|
237
260
|
A wired client, its connection already initialized.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
MuseHostStartError: The host ended before the handshake
|
|
264
|
+
completed; it carries the exit classification row, the
|
|
265
|
+
bounded stderr tail, and ``answered_initialize`` (``True``
|
|
266
|
+
when the host answered ``initialize`` and died while this
|
|
267
|
+
client sent ``initialized``). A host whose answer was itself
|
|
268
|
+
the failure — its own error, a fingerprint mismatch or a
|
|
269
|
+
malformed result — keeps that error; an ``initialize`` frame
|
|
270
|
+
this client cannot encode (for example a non-UTF-8
|
|
271
|
+
``client_info``) keeps its ``ProtocolError``; a cancellation
|
|
272
|
+
stays a cancellation; and a binary that cannot be started
|
|
273
|
+
raises the operating system's own error from the spawn call.
|
|
238
274
|
"""
|
|
239
275
|
handshake = await spawn_msp_connection(
|
|
240
276
|
options.muse_bin,
|
|
@@ -256,7 +292,7 @@ class MuseClient(Generic[_I]):
|
|
|
256
292
|
init_params["capabilities"] = options.capabilities
|
|
257
293
|
try:
|
|
258
294
|
spawned = await handshake.initialize(init_params)
|
|
259
|
-
except BaseException:
|
|
295
|
+
except BaseException as error:
|
|
260
296
|
# A failed handshake must not leak the process it already spawned.
|
|
261
297
|
# (``initialize`` already closes on its own failure paths; this is
|
|
262
298
|
# the belt-and-braces close for anything that escapes it.)
|
|
@@ -264,7 +300,22 @@ class MuseClient(Generic[_I]):
|
|
|
264
300
|
await handshake.close()
|
|
265
301
|
except BaseException:
|
|
266
302
|
pass
|
|
267
|
-
|
|
303
|
+
# A cancellation or interrupt is the caller's, a frame that never
|
|
304
|
+
# left this process is the caller's, and a host that ANSWERED was
|
|
305
|
+
# not lost: decided before the exit is read, so our own close
|
|
306
|
+
# ladder can never dress any of them up as a death.
|
|
307
|
+
if not isinstance(error, Exception) or keeps_its_own_error(error):
|
|
308
|
+
raise
|
|
309
|
+
# The host was lost: before answering, or after a usable answer
|
|
310
|
+
# while we sent ``initialized`` (the error then says so). close()
|
|
311
|
+
# ended at its observed exit, so the row is settled: raise it with
|
|
312
|
+
# the stderr tail instead of the transport's first symptom.
|
|
313
|
+
child = handshake.child
|
|
314
|
+
raise MuseHostStartError(
|
|
315
|
+
await child.exit,
|
|
316
|
+
child.stderr_tail,
|
|
317
|
+
answered_initialize=handshake._answered,
|
|
318
|
+
) from error
|
|
268
319
|
return MuseClient(
|
|
269
320
|
spawned.connection,
|
|
270
321
|
MuseClientOptions(
|
|
@@ -322,17 +373,14 @@ class MuseClient(Generic[_I]):
|
|
|
322
373
|
params["sessionId"] = options.session_id
|
|
323
374
|
if options.workspace_root is not None:
|
|
324
375
|
params["workspaceRoot"] = options.workspace_root
|
|
376
|
+
if options.workspace_roots is not None:
|
|
377
|
+
params["workspaceRoots"] = options.workspace_roots
|
|
325
378
|
if options.model_id is not None:
|
|
326
379
|
params["modelId"] = options.model_id
|
|
327
|
-
raw = await self._connection.command("session/start", params)
|
|
328
|
-
result: Mapping[str, Any] = raw
|
|
329
380
|
# Keyed by the id the SERVER named, never the one the caller asked for:
|
|
330
381
|
# ``sessionId`` is a REQUEST on start, and a session keyed by a hopeful
|
|
331
382
|
# id would reject every one of its own events as foreign.
|
|
332
|
-
return self.
|
|
333
|
-
str(result["session"]["sessionId"]),
|
|
334
|
-
_StartOpening(result=result),
|
|
335
|
-
)
|
|
383
|
+
return await self._open("session/start", params, _StartOpening)
|
|
336
384
|
|
|
337
385
|
async def resume_session(self, options: ResumeSessionOptions) -> Session[_I]:
|
|
338
386
|
"""Load an existing session and subscribe this connection to its view.
|
|
@@ -349,12 +397,7 @@ class MuseClient(Generic[_I]):
|
|
|
349
397
|
params["excludeItems"] = options.exclude_items
|
|
350
398
|
if options.history is not None:
|
|
351
399
|
params["history"] = options.history
|
|
352
|
-
|
|
353
|
-
result: Mapping[str, Any] = raw
|
|
354
|
-
return self._open_session(
|
|
355
|
-
str(result["session"]["sessionId"]),
|
|
356
|
-
_ResumeOpening(result=result),
|
|
357
|
-
)
|
|
400
|
+
return await self._open("session/resume", params, _ResumeOpening)
|
|
358
401
|
|
|
359
402
|
async def close(self) -> None:
|
|
360
403
|
"""Shut the host down in an orderly way.
|
|
@@ -378,6 +421,29 @@ class MuseClient(Generic[_I]):
|
|
|
378
421
|
return
|
|
379
422
|
await self._connection.close()
|
|
380
423
|
|
|
424
|
+
async def _open(
|
|
425
|
+
self,
|
|
426
|
+
verb: str,
|
|
427
|
+
params: dict[str, Any],
|
|
428
|
+
opening: Callable[[Mapping[str, Any]], SessionOpening],
|
|
429
|
+
) -> Session[_I]:
|
|
430
|
+
# The window ``_route`` holds unknown-session frames for: from the
|
|
431
|
+
# request to the Session's registration. Counted, not flagged, so two
|
|
432
|
+
# concurrent opens keep it open until the LAST settles; cleared on
|
|
433
|
+
# settle so a frame that named no session this client opened is held
|
|
434
|
+
# no longer than the open it rode in with.
|
|
435
|
+
self._opening += 1
|
|
436
|
+
try:
|
|
437
|
+
raw = await self._connection.command(verb, params)
|
|
438
|
+
result: Mapping[str, Any] = raw
|
|
439
|
+
return self._open_session(
|
|
440
|
+
str(result["session"]["sessionId"]), opening(result)
|
|
441
|
+
)
|
|
442
|
+
finally:
|
|
443
|
+
self._opening -= 1
|
|
444
|
+
if self._opening == 0:
|
|
445
|
+
self._held.clear()
|
|
446
|
+
|
|
381
447
|
def _open_session(
|
|
382
448
|
self, session_id: str, opening: SessionOpening
|
|
383
449
|
) -> Session[_I]:
|
|
@@ -389,6 +455,11 @@ class MuseClient(Generic[_I]):
|
|
|
389
455
|
opening=opening,
|
|
390
456
|
)
|
|
391
457
|
self._sessions.add(session)
|
|
458
|
+
# The frames that named this session while its verb was in flight
|
|
459
|
+
# fold NOW, in arrival order — synchronously, so nothing the pump
|
|
460
|
+
# routes next can overtake them.
|
|
461
|
+
for held in self._held.pop(session_id, ()):
|
|
462
|
+
session.apply(held)
|
|
392
463
|
return session
|
|
393
464
|
|
|
394
465
|
def _assert_reattach_allowed(self, session_id: str) -> None:
|
|
@@ -408,16 +479,25 @@ class MuseClient(Generic[_I]):
|
|
|
408
479
|
|
|
409
480
|
def _route(self, notification: Mapping[str, Any]) -> None:
|
|
410
481
|
# Deliver one view notification to the session(s) it names. A frame
|
|
411
|
-
# naming NO session is dropped here rather than fed to every session
|
|
412
|
-
# and a frame naming an UNKNOWN session is dropped for the same reason
|
|
482
|
+
# naming NO session is dropped here rather than fed to every session
|
|
413
483
|
# — routing it anywhere would trip the foreign-frame throw.
|
|
414
484
|
params = notification.get("params")
|
|
415
485
|
named = params.get("sessionId") if isinstance(params, dict) else None
|
|
416
486
|
if not isinstance(named, str):
|
|
417
487
|
return
|
|
488
|
+
delivered = False
|
|
418
489
|
for session in self._sessions:
|
|
419
490
|
if session.session_id == named:
|
|
420
491
|
session.apply(notification)
|
|
492
|
+
delivered = True
|
|
493
|
+
# An UNKNOWN session with no open in flight is dropped, for the same
|
|
494
|
+
# reason as a nameless frame.
|
|
495
|
+
if delivered or self._opening == 0:
|
|
496
|
+
return
|
|
497
|
+
# An open is in flight and this may be ITS session, named by the
|
|
498
|
+
# server before the awaiting coroutine has run: hold it for
|
|
499
|
+
# ``_open_session`` to fold, or for ``_open`` to clear.
|
|
500
|
+
self._held.setdefault(named, []).append(notification)
|
|
421
501
|
|
|
422
502
|
async def _watch_closed(self) -> None:
|
|
423
503
|
try:
|
|
@@ -284,10 +284,15 @@ class GapFiller(Generic[_I]):
|
|
|
284
284
|
return
|
|
285
285
|
result = await self._page(connection, self._cursor)
|
|
286
286
|
reached = False
|
|
287
|
+
# The last cursor this page served, skipped events included —
|
|
288
|
+
# where a walk toward an extended target resumes after an
|
|
289
|
+
# end-of-view page.
|
|
290
|
+
last_served: str | None = None
|
|
287
291
|
events = result["events"]
|
|
288
292
|
for event in events:
|
|
289
293
|
self._require_own_session(event, after, target)
|
|
290
294
|
cursor = str(event["params"]["viewCursor"])
|
|
295
|
+
last_served = cursor
|
|
291
296
|
# An EARLIER fill already served — and applied — this durable
|
|
292
297
|
# event. A later walk may legally page through the same range,
|
|
293
298
|
# and re-applying it here is the same double fold the discard
|
|
@@ -317,7 +322,15 @@ class GapFiller(Generic[_I]):
|
|
|
317
322
|
# the protocol), so a hole whose tail was ephemeral ends here and never
|
|
318
323
|
# at ``target``. An ABSENT ``nextCursor`` is a different fact — the
|
|
319
324
|
# member is "never omitted", so it falls through to the stall arm.
|
|
325
|
+
#
|
|
326
|
+
# The cursor still advances to the last event served: if a
|
|
327
|
+
# ``view/gap`` extended the target while this page was in flight,
|
|
328
|
+
# ``_walk`` comes back here toward the new one, and re-asking from
|
|
329
|
+
# where this walk started would re-serve, and fold a second time,
|
|
330
|
+
# every event this page just delivered.
|
|
320
331
|
if "nextCursor" in result and result["nextCursor"] is None:
|
|
332
|
+
if last_served is not None:
|
|
333
|
+
self._cursor = last_served
|
|
321
334
|
return
|
|
322
335
|
next_cursor = result.get("nextCursor")
|
|
323
336
|
# PROGRESS is the only bound on this walk, and it has to be,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The typed failure of :meth:`MuseClient.spawn` when the host is lost before
|
|
2
|
+
the handshake completes.
|
|
3
|
+
|
|
4
|
+
Without it the exception is whatever the transport saw first — usually
|
|
5
|
+
``ProtocolError("connection reached EOF")`` — while the evidence that explains
|
|
6
|
+
it (the exit classification row and the bounded stderr tail) sits unread on
|
|
7
|
+
the child the facade already owns. Every process-exit error carries that
|
|
8
|
+
evidence; this module is the handshake-failure arm of that rule.
|
|
9
|
+
|
|
10
|
+
A binary that cannot be started is not this error: it raises the operating
|
|
11
|
+
system's own error (``FileNotFoundError``, ``PermissionError``) from the spawn
|
|
12
|
+
call, before any handshake exists, so no process ran and there is no row to
|
|
13
|
+
attach.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Tuple
|
|
19
|
+
|
|
20
|
+
from ..connection.connection import MspError, ProtocolError, _UnencodableFrameError
|
|
21
|
+
from ..connection.spawn import ExitClassification
|
|
22
|
+
from ..errors import MuseHostMismatchError, MuseValidationError
|
|
23
|
+
|
|
24
|
+
_MESSAGE_TAIL_LINES = 5
|
|
25
|
+
"""How many tail lines the message quotes; ``stderr_tail`` carries them all."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MuseHostStartError(Exception):
|
|
29
|
+
""":meth:`MuseClient.spawn` lost its host before ``initialize`` was answered.
|
|
30
|
+
|
|
31
|
+
Typed rather than a bare ``Exception`` because ``exit.kind`` and
|
|
32
|
+
``exit.retry`` name STATES an embedder branches on (fix the configuration,
|
|
33
|
+
wait for a lease, give up). The transport error that first surfaced the
|
|
34
|
+
loss rides as ``__cause__``, so nothing the old exception carried is lost.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
exit: The host's exit classification row — any row, the clean one
|
|
38
|
+
included: a host that wrote its complaint to stderr and exited 0
|
|
39
|
+
before its first frame is still a lost host, and the row says
|
|
40
|
+
only how it ended.
|
|
41
|
+
stderr_tail: The SDK's bounded stderr tail when the host was lost.
|
|
42
|
+
The only carrier of that evidence when the row is
|
|
43
|
+
``cleanShutdown``, which keeps no tail of its own.
|
|
44
|
+
answered_initialize: Whether the host got as far as an
|
|
45
|
+
``initialize`` answer before it was lost. ``True`` means it
|
|
46
|
+
started and answered, then died while this client was completing
|
|
47
|
+
the handshake — not a startup problem.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
exit: ExitClassification, # noqa: A002 - mirrors the row's name
|
|
53
|
+
stderr_tail: Tuple[str, ...],
|
|
54
|
+
*,
|
|
55
|
+
answered_initialize: bool,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Builds the error; raise it ``from`` the transport error.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
exit: The observed exit's classification row.
|
|
61
|
+
stderr_tail: The bounded stderr evidence.
|
|
62
|
+
answered_initialize: Whether ``initialize`` was answered first.
|
|
63
|
+
"""
|
|
64
|
+
super().__init__(_describe(exit, stderr_tail, answered_initialize))
|
|
65
|
+
self.exit: ExitClassification = exit
|
|
66
|
+
self.stderr_tail: Tuple[str, ...] = stderr_tail
|
|
67
|
+
self.answered_initialize: bool = answered_initialize
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _describe(
|
|
71
|
+
exit: ExitClassification, # noqa: A002
|
|
72
|
+
tail: Tuple[str, ...],
|
|
73
|
+
answered_initialize: bool,
|
|
74
|
+
) -> str:
|
|
75
|
+
if exit.kind == "cleanShutdown":
|
|
76
|
+
how = "exit code 0"
|
|
77
|
+
elif exit.exit_signal is not None:
|
|
78
|
+
how = f"signal {exit.exit_signal}"
|
|
79
|
+
else:
|
|
80
|
+
how = f"exit code {exit.exit_code}"
|
|
81
|
+
if exit.retry is not None:
|
|
82
|
+
how += f", retry: {exit.retry}"
|
|
83
|
+
quoted = tail[-_MESSAGE_TAIL_LINES:]
|
|
84
|
+
if not tail:
|
|
85
|
+
evidence = "stderr tail: (empty)"
|
|
86
|
+
else:
|
|
87
|
+
count = (
|
|
88
|
+
f" (last {len(quoted)} of {len(tail)} lines)" if len(quoted) < len(tail) else ""
|
|
89
|
+
)
|
|
90
|
+
evidence = f"stderr tail{count}:\n " + "\n ".join(quoted)
|
|
91
|
+
when = (
|
|
92
|
+
"after answering initialize, before the handshake completed"
|
|
93
|
+
if answered_initialize
|
|
94
|
+
else "before answering initialize"
|
|
95
|
+
)
|
|
96
|
+
return f"muse host exited {when}: {exit.kind} ({how}); {evidence}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def keeps_its_own_error(error: BaseException) -> bool:
|
|
100
|
+
"""Is this handshake failure something other than a lost host?
|
|
101
|
+
|
|
102
|
+
"Lost" is decided by whether the failure carries the host's answer,
|
|
103
|
+
never by how it ended:
|
|
104
|
+
its own error, a fingerprint mismatch, a result this SDK refused as
|
|
105
|
+
malformed, or any protocol violation that carries the refused frame are
|
|
106
|
+
all answers, and each is a more useful error than the exit our own
|
|
107
|
+
``close()`` then caused. An ``initialize`` frame that could not even be
|
|
108
|
+
encoded never reached the host: that is the caller's defect, and the
|
|
109
|
+
host it was meant for is not to blame.
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(error, (MspError, MuseHostMismatchError, MuseValidationError)):
|
|
112
|
+
return True
|
|
113
|
+
if isinstance(error, _UnencodableFrameError):
|
|
114
|
+
return True
|
|
115
|
+
return isinstance(error, ProtocolError) and error.line is not None
|
|
@@ -34,6 +34,7 @@ from typing import (
|
|
|
34
34
|
from muse_code_msp import (
|
|
35
35
|
ITEM_KIND_KNOWN_VALUES,
|
|
36
36
|
ApprovalRequestParams,
|
|
37
|
+
ApprovalUpdatedParams,
|
|
37
38
|
Item,
|
|
38
39
|
ItemDeltaParams,
|
|
39
40
|
TurnCompletedParams,
|
|
@@ -612,6 +613,18 @@ class Session(Generic[_I]):
|
|
|
612
613
|
self._approval_requested(
|
|
613
614
|
cast(ApprovalRequestParams, params), sink.tasks
|
|
614
615
|
)
|
|
616
|
+
elif method == "approval/updated":
|
|
617
|
+
# A stage advance arrives as this frame alone on the notification
|
|
618
|
+
# plane, so it must drive the handler too (see
|
|
619
|
+
# ``ApprovalRouter.updated``). The same fold verdict gates it: an
|
|
620
|
+
# update the fold IGNORED (unknown or already-resolved approval)
|
|
621
|
+
# authors nothing.
|
|
622
|
+
if isinstance(outcome, ApprovalPending):
|
|
623
|
+
self._approval_updated(
|
|
624
|
+
cast(ApprovalRequestParams, outcome.requested),
|
|
625
|
+
cast(ApprovalUpdatedParams, params),
|
|
626
|
+
sink.tasks,
|
|
627
|
+
)
|
|
615
628
|
# ``view/gap`` deliberately routes to no turn: it names a hole in
|
|
616
629
|
# DELIVERY, so what it moves is the fold's currency, and the recovery
|
|
617
630
|
# it triggers is started by ``apply`` off the ``DeliveryGap`` verdict
|
|
@@ -895,6 +908,16 @@ class Session(Generic[_I]):
|
|
|
895
908
|
if decided is not None:
|
|
896
909
|
tasks.append(_decided_io(decided))
|
|
897
910
|
|
|
911
|
+
def _approval_updated(
|
|
912
|
+
self,
|
|
913
|
+
requested: ApprovalRequestParams,
|
|
914
|
+
params: ApprovalUpdatedParams,
|
|
915
|
+
tasks: List[Awaitable[tuple[PendingRetirement[_I], ...]]],
|
|
916
|
+
) -> None:
|
|
917
|
+
decided = self._approvals.updated(requested, params)
|
|
918
|
+
if decided is not None:
|
|
919
|
+
tasks.append(_decided_io(decided))
|
|
920
|
+
|
|
898
921
|
def _items_of_turn(self, turn_id: str) -> tuple[Item, ...]:
|
|
899
922
|
return tuple(
|
|
900
923
|
cast(Item, item)
|