muse-code-sdk 1.4.2__tar.gz → 1.4.6__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.4.2/src/muse_code_sdk.egg-info → muse_code_sdk-1.4.6}/PKG-INFO +1 -1
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/pyproject.toml +1 -1
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/__init__.py +6 -2
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/spawn.py +17 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/errors.py +33 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/__init__.py +2 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/approval.py +121 -4
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/client.py +182 -22
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/gap_fill.py +13 -0
- muse_code_sdk-1.4.6/src/muse_code/facade/host_start.py +115 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/session.py +23 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/turn_handle.py +4 -4
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/turn_submit.py +11 -1
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/fold/session_fold.py +52 -6
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6/src/muse_code_sdk.egg-info}/PKG-INFO +1 -1
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code_sdk.egg-info/SOURCES.txt +6 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_compatibility_row.py +69 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_facade_approval.py +394 -19
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_facade_gap_fill.py +102 -0
- muse_code_sdk-1.4.6/tests/test_facade_open_same_chunk.py +265 -0
- muse_code_sdk-1.4.6/tests/test_facade_required_selection.py +305 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_facade_turn.py +71 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_muse_serve_child.py +40 -0
- muse_code_sdk-1.4.6/tests/test_permission_state_wire_types.py +174 -0
- muse_code_sdk-1.4.6/tests/test_public_mirror_text.py +1844 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_publish_shape.py +26 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_session_fold.py +79 -2
- muse_code_sdk-1.4.6/tests/test_spawn_host_lost.py +295 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/LICENSE +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/README.md +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/setup.cfg +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/__init__.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/connection.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/discovery.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/connection/validation.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/discarded.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/facade/host_death.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/fold/__init__.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/fold/item_store.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/fold/state_store.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/pending/__init__.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/pending/pending_command_set.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/py.typed +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/replay.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code/sync_facade.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code_sdk.egg-info/dependency_links.txt +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code_sdk.egg-info/requires.txt +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/src/muse_code_sdk.egg-info/top_level.txt +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_asyncio_cancellation.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_connection.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_connection_error_arms.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_docs_generation.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_external_audience.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_facade_persistent_approval.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_host_discovery.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_item_store.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_manifests.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_pending_command_corpus.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_pending_command_set.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_pending_command_wire_binding.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_source_closure.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_spawn_handshake.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_state_store.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_sync_facade.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_tolerance_corpus.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/tests/test_transcript_replay.py +0 -0
- {muse_code_sdk-1.4.2 → muse_code_sdk-1.4.6}/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.4.
|
|
3
|
+
Version: 1.4.6
|
|
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.4.
|
|
8
|
+
version = "1.4.6"
|
|
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"
|
|
@@ -24,6 +24,7 @@ from .errors import (
|
|
|
24
24
|
MuseForeignSessionError,
|
|
25
25
|
MuseGapFillError,
|
|
26
26
|
MuseSessionDiscardedError,
|
|
27
|
+
PermissionProfileNotApplied,
|
|
27
28
|
)
|
|
28
29
|
from .fold import ItemStore, SessionFold, SessionStateStore
|
|
29
30
|
from .pending import PendingCommandSet
|
|
@@ -47,9 +48,11 @@ __all__ = [
|
|
|
47
48
|
"MuseForeignSessionError",
|
|
48
49
|
"MuseGapFillError",
|
|
49
50
|
"MuseHostDiedError",
|
|
51
|
+
"MuseHostStartError",
|
|
50
52
|
"MuseSessionDiscardedError",
|
|
51
53
|
"PendingCommandSet",
|
|
52
54
|
"PendingCommandView",
|
|
55
|
+
"PermissionProfileNotApplied",
|
|
53
56
|
"ResumeSessionOptions",
|
|
54
57
|
"SendUserTurnOptions",
|
|
55
58
|
"Session",
|
|
@@ -77,7 +80,7 @@ __all__ = [
|
|
|
77
80
|
]
|
|
78
81
|
|
|
79
82
|
EXPECTED_SCHEMA_FINGERPRINT: Final[str] = (
|
|
80
|
-
"sha256:
|
|
83
|
+
"sha256:eae467ab60cb9a5d5af4de616830f065e59375c987fd59269c8cc33ed2a1137f"
|
|
81
84
|
)
|
|
82
85
|
"""The stable schema-bundle fingerprint this SDK was built against.
|
|
83
86
|
|
|
@@ -89,7 +92,7 @@ Python SDK reds this lane instead of drifting silently.
|
|
|
89
92
|
# The lockstep rule: the release train bumps pyproject.toml, the
|
|
90
93
|
# distribution-metadata authority; this constant is aligned at publication
|
|
91
94
|
# time.
|
|
92
|
-
__version__: Final[str] = "1.4.
|
|
95
|
+
__version__: Final[str] = "1.4.6"
|
|
93
96
|
|
|
94
97
|
# The facade is imported last so the constants above are already bound: the
|
|
95
98
|
# spawn path reads ``EXPECTED_SCHEMA_FINGERPRINT`` (lazily, at initialize
|
|
@@ -108,6 +111,7 @@ from .facade import ( # noqa: E402
|
|
|
108
111
|
MuseClientOptions,
|
|
109
112
|
MuseClientSpawnOptions,
|
|
110
113
|
MuseHostDiedError,
|
|
114
|
+
MuseHostStartError,
|
|
111
115
|
PendingCommandView,
|
|
112
116
|
ResumeSessionOptions,
|
|
113
117
|
SendUserTurnOptions,
|
|
@@ -39,6 +39,13 @@ from .validation import parse_initialize_result, parse_spawn_options
|
|
|
39
39
|
if TYPE_CHECKING:
|
|
40
40
|
from muse_code_msp import InitializeParams, InitializeResult, RequestId
|
|
41
41
|
|
|
42
|
+
_CREATE_NO_WINDOW = 0x0800_0000
|
|
43
|
+
"""Win32 ``CREATE_NO_WINDOW`` process-creation flag (``subprocess`` defines it
|
|
44
|
+
only on Windows builds). A spawned ``muse serve`` is never meant to be seen,
|
|
45
|
+
so on Windows the SDK starts it without a console window: a windowless host
|
|
46
|
+
(a GUI app, a service) would otherwise get a stray console on every spawn."""
|
|
47
|
+
|
|
48
|
+
|
|
42
49
|
DEFAULT_STDERR_MAX_BYTES = 8 * 1024
|
|
43
50
|
DEFAULT_STDERR_MAX_LINES = 100
|
|
44
51
|
DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000
|
|
@@ -817,6 +824,9 @@ class MuseServeChild:
|
|
|
817
824
|
env=env,
|
|
818
825
|
shutdown_timeout_ms=shutdown_timeout_ms,
|
|
819
826
|
)
|
|
827
|
+
# POSIX: the child leads its own process group so the close ladder can
|
|
828
|
+
# signal the group. Windows: no process group, and no console window
|
|
829
|
+
# (CPython accepts ``creationflags=0`` on POSIX).
|
|
820
830
|
owns_process_group = sys.platform != "win32"
|
|
821
831
|
child = await asyncio.create_subprocess_exec(
|
|
822
832
|
options.command,
|
|
@@ -827,6 +837,7 @@ class MuseServeChild:
|
|
|
827
837
|
cwd=options.cwd,
|
|
828
838
|
env=options.env,
|
|
829
839
|
start_new_session=owns_process_group,
|
|
840
|
+
creationflags=0 if owns_process_group else _CREATE_NO_WINDOW,
|
|
830
841
|
)
|
|
831
842
|
return MuseServeChild(
|
|
832
843
|
child,
|
|
@@ -897,6 +908,11 @@ class MspHandshake:
|
|
|
897
908
|
self._transport = child._transport
|
|
898
909
|
self._connection = connection
|
|
899
910
|
self._started = False
|
|
911
|
+
# Did ``initialize`` get an answer before the handshake failed? Read
|
|
912
|
+
# by the facade, never a public member: it lets ``MuseClient.spawn``
|
|
913
|
+
# say truthfully that a host died AFTER answering, while the
|
|
914
|
+
# ``initialized`` notification was being sent.
|
|
915
|
+
self._answered = False
|
|
900
916
|
self._transport._adopt_flush_source(connection._submission_tail)
|
|
901
917
|
|
|
902
918
|
@property
|
|
@@ -957,6 +973,7 @@ class MspHandshake:
|
|
|
957
973
|
# stays a no-op.
|
|
958
974
|
try:
|
|
959
975
|
raw: Any = await self._connection.request("initialize", dict(params))
|
|
976
|
+
self._answered = True
|
|
960
977
|
# The contract frame seam: the members this
|
|
961
978
|
# handshake reads validate through a pydantic model, so a
|
|
962
979
|
# malformed frame is the typed MuseValidationError — the seam
|
|
@@ -82,6 +82,39 @@ class MuseGapFillError(Exception):
|
|
|
82
82
|
self.__cause__ = cause
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
class PermissionProfileNotApplied(Exception):
|
|
86
|
+
"""A required session-start permission-profile selection the client could
|
|
87
|
+
not prove applied: the start result carried no ``permissionState``, and
|
|
88
|
+
the ``session/read`` read-back served a different profile — or none at
|
|
89
|
+
all. Never a silent default: the session stays open and the caller
|
|
90
|
+
decides (set/retry or close) via ``session_id``, with no auto-close and
|
|
91
|
+
no auto-set.
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
requested: The profile id the caller required.
|
|
95
|
+
served: The committed profile id the read-back served, or None when
|
|
96
|
+
unknown.
|
|
97
|
+
session_id: The open session, so the caller can set, retry, or close it.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, requested: str, served: str | None, session_id: str) -> None:
|
|
101
|
+
"""Builds the refusal.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
requested: The profile id the caller required.
|
|
105
|
+
served: The committed profile id served, or None when unknown.
|
|
106
|
+
session_id: The open session's id.
|
|
107
|
+
"""
|
|
108
|
+
served_text = "unknown" if served is None else repr(served)
|
|
109
|
+
super().__init__(
|
|
110
|
+
f"required permissionProfile {requested!r} was not applied:"
|
|
111
|
+
f" the committed selection is {served_text} (session {session_id})"
|
|
112
|
+
)
|
|
113
|
+
self.requested: str = requested
|
|
114
|
+
self.served: str | None = served
|
|
115
|
+
self.session_id: str = session_id
|
|
116
|
+
|
|
117
|
+
|
|
85
118
|
ValidationSeam = Literal["initializeResult", "spawnOptions"]
|
|
86
119
|
"""Which validating pydantic seam refused its input.
|
|
87
120
|
|
|
@@ -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,
|
|
@@ -43,8 +53,9 @@ from ..connection.spawn import (
|
|
|
43
53
|
SpawnedMspConnection,
|
|
44
54
|
spawn_msp_connection,
|
|
45
55
|
)
|
|
46
|
-
from ..errors import MuseSessionDiscardedError
|
|
56
|
+
from ..errors import MuseSessionDiscardedError, PermissionProfileNotApplied
|
|
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,
|
|
@@ -82,6 +93,18 @@ class StartSessionOptions:
|
|
|
82
93
|
``workspace_root`` is also given the two must agree.
|
|
83
94
|
model_id: The model to serve the session with; omitted = the
|
|
84
95
|
provider's default.
|
|
96
|
+
permission_profile: The permission profile to start the session
|
|
97
|
+
under (wire ``permissionProfile``): a profile id the host offers;
|
|
98
|
+
omitted = the host's configured default. An id the host does not
|
|
99
|
+
offer refuses the start before the session exists.
|
|
100
|
+
require_permission_profile: Require the ``permission_profile``
|
|
101
|
+
selection: when true, the client proves the
|
|
102
|
+
committed selection equals the requested id — from the start
|
|
103
|
+
result's ``session.permissionState``, else a ``session/read``
|
|
104
|
+
read-back — and raises ``PermissionProfileNotApplied`` instead
|
|
105
|
+
of proceeding on the host default. Client-local: never sent on
|
|
106
|
+
the wire. Setting it without ``permission_profile`` is a usage
|
|
107
|
+
error.
|
|
85
108
|
"""
|
|
86
109
|
|
|
87
110
|
approval_mode: str | None = None
|
|
@@ -90,6 +113,8 @@ class StartSessionOptions:
|
|
|
90
113
|
workspace_root: str | None = None
|
|
91
114
|
workspace_roots: List[str] | None = None
|
|
92
115
|
model_id: str | None = None
|
|
116
|
+
permission_profile: str | None = None
|
|
117
|
+
require_permission_profile: bool = False
|
|
93
118
|
|
|
94
119
|
|
|
95
120
|
@dataclass
|
|
@@ -116,12 +141,18 @@ class ResumeSessionOptions:
|
|
|
116
141
|
# Drift pins — the Python twin of the TS `AssertNever` on START_FORWARDED /
|
|
117
142
|
# RESUME_FORWARDED (client.ts). A schema regen that adds a member to the
|
|
118
143
|
# generated params but not to the options dataclass reds THIS import (and thus
|
|
119
|
-
# every test), so a regenerated member cannot silently fail to reach the wire
|
|
120
|
-
|
|
121
|
-
#
|
|
122
|
-
# (temporarily withheld until capability-checked forwarding lands).
|
|
144
|
+
# every test), so a regenerated member cannot silently fail to reach the wire.
|
|
145
|
+
# The excluded members are the ones the facade owns rather than forwards:
|
|
146
|
+
# `commandId` (the connection is the single minter) and `config`
|
|
147
|
+
# (temporarily withheld until capability-checked forwarding lands). The owned
|
|
148
|
+
# members are options-only posture the wire never carries (client-local
|
|
149
|
+
# `requirePermissionProfile`, the required-selection check): subtracted from
|
|
150
|
+
# the options side, so a regen that adds a same-named wire member reds here.
|
|
123
151
|
_START_EXCLUDED = {"commandId", "config"}
|
|
124
|
-
|
|
152
|
+
_START_OWNED = {"requirePermissionProfile"}
|
|
153
|
+
assert (
|
|
154
|
+
{_camel(f.name) for f in fields(StartSessionOptions)} - _START_OWNED
|
|
155
|
+
) | _START_EXCLUDED == set(
|
|
125
156
|
SessionStartParams.__annotations__
|
|
126
157
|
), "StartSessionOptions drifted from SessionStartParams — forward the new member"
|
|
127
158
|
|
|
@@ -131,6 +162,21 @@ assert {_camel(f.name) for f in fields(ResumeSessionOptions)} | _RESUME_EXCLUDED
|
|
|
131
162
|
), "ResumeSessionOptions drifted from SessionResumeParams — forward the new member"
|
|
132
163
|
|
|
133
164
|
|
|
165
|
+
def _committed_profile_id(snapshot: Any) -> str | None:
|
|
166
|
+
"""The committed profile id a session snapshot claims, or None when the
|
|
167
|
+
snapshot is silent (the stable bundle strips the member; absent and null
|
|
168
|
+
both mean unknown, never applied). Structural on purpose: an unknown wire
|
|
169
|
+
shape reads as unknown rather than raising.
|
|
170
|
+
"""
|
|
171
|
+
if not isinstance(snapshot, Mapping):
|
|
172
|
+
return None
|
|
173
|
+
state = snapshot.get("permissionState")
|
|
174
|
+
if not isinstance(state, Mapping):
|
|
175
|
+
return None
|
|
176
|
+
profile_id = state.get("profileId")
|
|
177
|
+
return profile_id if isinstance(profile_id, str) else None
|
|
178
|
+
|
|
179
|
+
|
|
134
180
|
@dataclass
|
|
135
181
|
class MuseClientOptions:
|
|
136
182
|
"""Options for building a ``MuseClient`` around a connection you own.
|
|
@@ -204,6 +250,14 @@ class MuseClient(Generic[_I]):
|
|
|
204
250
|
# sharing knob would be declared-ahead surface (Constitution XI).
|
|
205
251
|
self._discarded = DiscardedSessions()
|
|
206
252
|
self._sessions: Set[Session[_I]] = set()
|
|
253
|
+
# Frames naming a session this client has not registered YET, held
|
|
254
|
+
# only while a ``session/start``/``session/resume`` is in flight: the
|
|
255
|
+
# host flushes the resume replay BEFORE the result, and the coroutine
|
|
256
|
+
# awaiting that result resumes only after the whole chunk has been
|
|
257
|
+
# routed. ``_route`` holds, ``_open_session`` drains,
|
|
258
|
+
# ``_open`` clears when the last open settles.
|
|
259
|
+
self._opening = 0
|
|
260
|
+
self._held: dict[str, List[Mapping[str, Any]]] = {}
|
|
207
261
|
host = options.host
|
|
208
262
|
self._child: MuseServeChild | None = host.child if host is not None else None
|
|
209
263
|
self._initialize_result: InitializeResult | None = (
|
|
@@ -239,6 +293,19 @@ class MuseClient(Generic[_I]):
|
|
|
239
293
|
|
|
240
294
|
Returns:
|
|
241
295
|
A wired client, its connection already initialized.
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
MuseHostStartError: The host ended before the handshake
|
|
299
|
+
completed; it carries the exit classification row, the
|
|
300
|
+
bounded stderr tail, and ``answered_initialize`` (``True``
|
|
301
|
+
when the host answered ``initialize`` and died while this
|
|
302
|
+
client sent ``initialized``). A host whose answer was itself
|
|
303
|
+
the failure — its own error, a fingerprint mismatch or a
|
|
304
|
+
malformed result — keeps that error; an ``initialize`` frame
|
|
305
|
+
this client cannot encode (for example a non-UTF-8
|
|
306
|
+
``client_info``) keeps its ``ProtocolError``; a cancellation
|
|
307
|
+
stays a cancellation; and a binary that cannot be started
|
|
308
|
+
raises the operating system's own error from the spawn call.
|
|
242
309
|
"""
|
|
243
310
|
handshake = await spawn_msp_connection(
|
|
244
311
|
options.muse_bin,
|
|
@@ -260,7 +327,7 @@ class MuseClient(Generic[_I]):
|
|
|
260
327
|
init_params["capabilities"] = options.capabilities
|
|
261
328
|
try:
|
|
262
329
|
spawned = await handshake.initialize(init_params)
|
|
263
|
-
except BaseException:
|
|
330
|
+
except BaseException as error:
|
|
264
331
|
# A failed handshake must not leak the process it already spawned.
|
|
265
332
|
# (``initialize`` already closes on its own failure paths; this is
|
|
266
333
|
# the belt-and-braces close for anything that escapes it.)
|
|
@@ -268,7 +335,22 @@ class MuseClient(Generic[_I]):
|
|
|
268
335
|
await handshake.close()
|
|
269
336
|
except BaseException:
|
|
270
337
|
pass
|
|
271
|
-
|
|
338
|
+
# A cancellation or interrupt is the caller's, a frame that never
|
|
339
|
+
# left this process is the caller's, and a host that ANSWERED was
|
|
340
|
+
# not lost: decided before the exit is read, so our own close
|
|
341
|
+
# ladder can never dress any of them up as a death.
|
|
342
|
+
if not isinstance(error, Exception) or keeps_its_own_error(error):
|
|
343
|
+
raise
|
|
344
|
+
# The host was lost: before answering, or after a usable answer
|
|
345
|
+
# while we sent ``initialized`` (the error then says so). close()
|
|
346
|
+
# ended at its observed exit, so the row is settled: raise it with
|
|
347
|
+
# the stderr tail instead of the transport's first symptom.
|
|
348
|
+
child = handshake.child
|
|
349
|
+
raise MuseHostStartError(
|
|
350
|
+
await child.exit,
|
|
351
|
+
child.stderr_tail,
|
|
352
|
+
answered_initialize=handshake._answered,
|
|
353
|
+
) from error
|
|
272
354
|
return MuseClient(
|
|
273
355
|
spawned.connection,
|
|
274
356
|
MuseClientOptions(
|
|
@@ -330,15 +412,61 @@ class MuseClient(Generic[_I]):
|
|
|
330
412
|
params["workspaceRoots"] = options.workspace_roots
|
|
331
413
|
if options.model_id is not None:
|
|
332
414
|
params["modelId"] = options.model_id
|
|
333
|
-
|
|
334
|
-
|
|
415
|
+
# The initial permission-profile selection, forwarded verbatim like the
|
|
416
|
+
# other start members (None stays omitted).
|
|
417
|
+
if options.permission_profile is not None:
|
|
418
|
+
params["permissionProfile"] = options.permission_profile
|
|
419
|
+
# Required selection: a caller that requires its selection proves it,
|
|
420
|
+
# never assuming the host default. The flag without the member is a
|
|
421
|
+
# usage error, raised pre-wire.
|
|
422
|
+
required = options.require_permission_profile is True
|
|
423
|
+
if required and options.permission_profile is None:
|
|
424
|
+
raise ValueError(
|
|
425
|
+
"require_permission_profile needs permission_profile:"
|
|
426
|
+
" require the selection you pass"
|
|
427
|
+
)
|
|
335
428
|
# Keyed by the id the SERVER named, never the one the caller asked for:
|
|
336
429
|
# ``sessionId`` is a REQUEST on start, and a session keyed by a hopeful
|
|
337
430
|
# id would reject every one of its own events as foreign.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
431
|
+
session = await self._open("session/start", params, _StartOpening)
|
|
432
|
+
if not required:
|
|
433
|
+
return session
|
|
434
|
+
assert options.permission_profile is not None # the usage guard above
|
|
435
|
+
return await self._prove_selection(session, options.permission_profile)
|
|
436
|
+
|
|
437
|
+
async def _prove_selection(
|
|
438
|
+
self, session: Session[_I], requested: str
|
|
439
|
+
) -> Session[_I]:
|
|
440
|
+
"""Prove a required start selection: the start result's own
|
|
441
|
+
``session.permissionState`` first (absent = unknown,
|
|
442
|
+
never applied); else a point-in-time ``session/read``, which never
|
|
443
|
+
subscribes. Only ``profileId == requested`` proceeds; anything else
|
|
444
|
+
raises ``PermissionProfileNotApplied`` BEFORE any turn is sent. The
|
|
445
|
+
refused session is dropped from the client (no orphan keeps folding
|
|
446
|
+
for an id the caller can never reach — the SDK has no set/close-by-id
|
|
447
|
+
verb); the error's ``session_id`` is the caller's handle to resume via
|
|
448
|
+
``session_id`` or start again. A failed read-back propagates: the
|
|
449
|
+
client still never proceeds on the default.
|
|
450
|
+
"""
|
|
451
|
+
opening = session.opening
|
|
452
|
+
started = (
|
|
453
|
+
_committed_profile_id(opening.result["session"])
|
|
454
|
+
if isinstance(opening, _StartOpening)
|
|
455
|
+
else None
|
|
456
|
+
)
|
|
457
|
+
if started is not None:
|
|
458
|
+
if started == requested:
|
|
459
|
+
return session
|
|
460
|
+
self._sessions.discard(session)
|
|
461
|
+
raise PermissionProfileNotApplied(requested, started, session.session_id)
|
|
462
|
+
read = await self._connection.command(
|
|
463
|
+
"session/read", {"sessionId": session.session_id}
|
|
341
464
|
)
|
|
465
|
+
served = _committed_profile_id(read.get("session"))
|
|
466
|
+
if served == requested:
|
|
467
|
+
return session
|
|
468
|
+
self._sessions.discard(session)
|
|
469
|
+
raise PermissionProfileNotApplied(requested, served, session.session_id)
|
|
342
470
|
|
|
343
471
|
async def resume_session(self, options: ResumeSessionOptions) -> Session[_I]:
|
|
344
472
|
"""Load an existing session and subscribe this connection to its view.
|
|
@@ -355,12 +483,7 @@ class MuseClient(Generic[_I]):
|
|
|
355
483
|
params["excludeItems"] = options.exclude_items
|
|
356
484
|
if options.history is not None:
|
|
357
485
|
params["history"] = options.history
|
|
358
|
-
|
|
359
|
-
result: Mapping[str, Any] = raw
|
|
360
|
-
return self._open_session(
|
|
361
|
-
str(result["session"]["sessionId"]),
|
|
362
|
-
_ResumeOpening(result=result),
|
|
363
|
-
)
|
|
486
|
+
return await self._open("session/resume", params, _ResumeOpening)
|
|
364
487
|
|
|
365
488
|
async def close(self) -> None:
|
|
366
489
|
"""Shut the host down in an orderly way.
|
|
@@ -384,6 +507,29 @@ class MuseClient(Generic[_I]):
|
|
|
384
507
|
return
|
|
385
508
|
await self._connection.close()
|
|
386
509
|
|
|
510
|
+
async def _open(
|
|
511
|
+
self,
|
|
512
|
+
verb: str,
|
|
513
|
+
params: dict[str, Any],
|
|
514
|
+
opening: Callable[[Mapping[str, Any]], SessionOpening],
|
|
515
|
+
) -> Session[_I]:
|
|
516
|
+
# The window ``_route`` holds unknown-session frames for: from the
|
|
517
|
+
# request to the Session's registration. Counted, not flagged, so two
|
|
518
|
+
# concurrent opens keep it open until the LAST settles; cleared on
|
|
519
|
+
# settle so a frame that named no session this client opened is held
|
|
520
|
+
# no longer than the open it rode in with.
|
|
521
|
+
self._opening += 1
|
|
522
|
+
try:
|
|
523
|
+
raw = await self._connection.command(verb, params)
|
|
524
|
+
result: Mapping[str, Any] = raw
|
|
525
|
+
return self._open_session(
|
|
526
|
+
str(result["session"]["sessionId"]), opening(result)
|
|
527
|
+
)
|
|
528
|
+
finally:
|
|
529
|
+
self._opening -= 1
|
|
530
|
+
if self._opening == 0:
|
|
531
|
+
self._held.clear()
|
|
532
|
+
|
|
387
533
|
def _open_session(
|
|
388
534
|
self, session_id: str, opening: SessionOpening
|
|
389
535
|
) -> Session[_I]:
|
|
@@ -395,6 +541,11 @@ class MuseClient(Generic[_I]):
|
|
|
395
541
|
opening=opening,
|
|
396
542
|
)
|
|
397
543
|
self._sessions.add(session)
|
|
544
|
+
# The frames that named this session while its verb was in flight
|
|
545
|
+
# fold NOW, in arrival order — synchronously, so nothing the pump
|
|
546
|
+
# routes next can overtake them.
|
|
547
|
+
for held in self._held.pop(session_id, ()):
|
|
548
|
+
session.apply(held)
|
|
398
549
|
return session
|
|
399
550
|
|
|
400
551
|
def _assert_reattach_allowed(self, session_id: str) -> None:
|
|
@@ -414,16 +565,25 @@ class MuseClient(Generic[_I]):
|
|
|
414
565
|
|
|
415
566
|
def _route(self, notification: Mapping[str, Any]) -> None:
|
|
416
567
|
# Deliver one view notification to the session(s) it names. A frame
|
|
417
|
-
# naming NO session is dropped here rather than fed to every session
|
|
418
|
-
# and a frame naming an UNKNOWN session is dropped for the same reason
|
|
568
|
+
# naming NO session is dropped here rather than fed to every session
|
|
419
569
|
# — routing it anywhere would trip the foreign-frame throw.
|
|
420
570
|
params = notification.get("params")
|
|
421
571
|
named = params.get("sessionId") if isinstance(params, dict) else None
|
|
422
572
|
if not isinstance(named, str):
|
|
423
573
|
return
|
|
574
|
+
delivered = False
|
|
424
575
|
for session in self._sessions:
|
|
425
576
|
if session.session_id == named:
|
|
426
577
|
session.apply(notification)
|
|
578
|
+
delivered = True
|
|
579
|
+
# An UNKNOWN session with no open in flight is dropped, for the same
|
|
580
|
+
# reason as a nameless frame.
|
|
581
|
+
if delivered or self._opening == 0:
|
|
582
|
+
return
|
|
583
|
+
# An open is in flight and this may be ITS session, named by the
|
|
584
|
+
# server before the awaiting coroutine has run: hold it for
|
|
585
|
+
# ``_open_session`` to fold, or for ``_open`` to clear.
|
|
586
|
+
self._held.setdefault(named, []).append(notification)
|
|
427
587
|
|
|
428
588
|
async def _watch_closed(self) -> None:
|
|
429
589
|
try:
|