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.
Files changed (65) hide show
  1. {muse_code_sdk-1.3.1/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.4}/PKG-INFO +1 -1
  2. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/pyproject.toml +1 -1
  3. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/__init__.py +4 -2
  4. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/spawn.py +6 -0
  5. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/__init__.py +2 -0
  6. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/approval.py +121 -4
  7. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/client.py +97 -17
  8. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/gap_fill.py +13 -0
  9. muse_code_sdk-1.4.4/src/muse_code/facade/host_start.py +115 -0
  10. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/session.py +23 -0
  11. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/turn_handle.py +4 -4
  12. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/turn_submit.py +10 -1
  13. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/fold/session_fold.py +35 -4
  14. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4/src/muse_code_sdk.egg-info}/PKG-INFO +1 -1
  15. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code_sdk.egg-info/SOURCES.txt +4 -0
  16. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_compatibility_row.py +69 -0
  17. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_facade_approval.py +394 -19
  18. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_facade_gap_fill.py +102 -0
  19. muse_code_sdk-1.4.4/tests/test_facade_open_same_chunk.py +265 -0
  20. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_facade_turn.py +39 -0
  21. muse_code_sdk-1.4.4/tests/test_public_mirror_text.py +1844 -0
  22. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_publish_shape.py +40 -8
  23. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_session_fold.py +114 -7
  24. muse_code_sdk-1.4.4/tests/test_spawn_host_lost.py +295 -0
  25. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/LICENSE +0 -0
  26. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/README.md +0 -0
  27. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/setup.cfg +0 -0
  28. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/__init__.py +0 -0
  29. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/connection.py +0 -0
  30. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/discovery.py +0 -0
  31. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/connection/validation.py +0 -0
  32. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/errors.py +0 -0
  33. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/discarded.py +0 -0
  34. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/facade/host_death.py +0 -0
  35. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/fold/__init__.py +0 -0
  36. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/fold/item_store.py +0 -0
  37. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/fold/state_store.py +0 -0
  38. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/pending/__init__.py +0 -0
  39. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/pending/pending_command_set.py +0 -0
  40. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/py.typed +0 -0
  41. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/replay.py +0 -0
  42. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code/sync_facade.py +0 -0
  43. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
  44. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code_sdk.egg-info/requires.txt +0 -0
  45. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
  46. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_asyncio_cancellation.py +0 -0
  47. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_connection.py +0 -0
  48. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_connection_error_arms.py +0 -0
  49. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_docs_generation.py +0 -0
  50. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_external_audience.py +0 -0
  51. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_facade_persistent_approval.py +0 -0
  52. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_host_discovery.py +0 -0
  53. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_item_store.py +0 -0
  54. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_manifests.py +0 -0
  55. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_muse_serve_child.py +0 -0
  56. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_pending_command_corpus.py +0 -0
  57. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_pending_command_set.py +0 -0
  58. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_pending_command_wire_binding.py +0 -0
  59. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_source_closure.py +0 -0
  60. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_spawn_handshake.py +0 -0
  61. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_state_store.py +0 -0
  62. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_sync_facade.py +0 -0
  63. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_tolerance_corpus.py +0 -0
  64. {muse_code_sdk-1.3.1 → muse_code_sdk-1.4.4}/tests/test_transcript_replay.py +0 -0
  65. {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.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.3.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:b1e6676d624e116e2c1b150fec3192200d2cbca8ed79898e44f8921759c7872f"
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.3.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 ApprovalDecideParams, ApprovalRequestParams
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 to the consumer's handler
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=params["approvalId"], error=error))
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 Any, Awaitable, Generic, Mapping, Sequence, Set, TypeVar
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
- raise
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._open_session(
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
- raw = await self._connection.command("session/resume", params)
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)