muse-code-sdk 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- muse_code/__init__.py +129 -0
- muse_code/connection/__init__.py +49 -0
- muse_code/connection/connection.py +905 -0
- muse_code/connection/discovery.py +55 -0
- muse_code/connection/spawn.py +1067 -0
- muse_code/connection/validation.py +149 -0
- muse_code/errors.py +199 -0
- muse_code/facade/__init__.py +89 -0
- muse_code/facade/approval.py +279 -0
- muse_code/facade/client.py +444 -0
- muse_code/facade/discarded.py +45 -0
- muse_code/facade/gap_fill.py +483 -0
- muse_code/facade/host_death.py +254 -0
- muse_code/facade/session.py +977 -0
- muse_code/facade/turn_handle.py +440 -0
- muse_code/facade/turn_submit.py +289 -0
- muse_code/fold/__init__.py +82 -0
- muse_code/fold/item_store.py +268 -0
- muse_code/fold/session_fold.py +785 -0
- muse_code/fold/state_store.py +141 -0
- muse_code/pending/__init__.py +53 -0
- muse_code/pending/pending_command_set.py +842 -0
- muse_code/py.typed +0 -0
- muse_code/replay.py +144 -0
- muse_code/sync_facade.py +493 -0
- muse_code_sdk-1.3.0.dist-info/METADATA +47 -0
- muse_code_sdk-1.3.0.dist-info/RECORD +30 -0
- muse_code_sdk-1.3.0.dist-info/WHEEL +5 -0
- muse_code_sdk-1.3.0.dist-info/licenses/LICENSE +21 -0
- muse_code_sdk-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
"""``PendingCommand``: the client-side fold over pending commands.
|
|
2
|
+
|
|
3
|
+
Normative source: tdd SS4.13 (owned by decision record D-032). This module
|
|
4
|
+
implements that subsection and does not re-derive it; every rule below
|
|
5
|
+
quotes or cites the clause it realizes. It is **client state only** — no
|
|
6
|
+
wire event, method, snapshot member, or durable record (14990 INV-007,
|
|
7
|
+
carried by spec 638 INV-638-04), and it never crosses the wire.
|
|
8
|
+
|
|
9
|
+
The full fold is ``S' = f(S, server events, pending client events)``: server
|
|
10
|
+
events remain the only source of durable truth, and a ``PendingCommand``
|
|
11
|
+
entry is a rendering of the client's own intent.
|
|
12
|
+
|
|
13
|
+
The entry itself is client-local by ruling, and its join keys (``commandId``,
|
|
14
|
+
``turnId``, ``itemId``) are opaque strings by protocol rule (SS3.1.4, SS4.1).
|
|
15
|
+
The wire vocabulary it touches is fully generated: the ``turn/start`` ack and
|
|
16
|
+
the ``-32030`` ``commandRejected`` registry row come from ``muse_code_msp``
|
|
17
|
+
(spec 206 Phase 11, #22772), so nothing below restates a wire shape
|
|
18
|
+
(INV-638-01/02).
|
|
19
|
+
|
|
20
|
+
Where the TS module binds its constants to closed generated UNIONS so a typo
|
|
21
|
+
fails ``tsc``, the Python rendering exposes the registry as the generated
|
|
22
|
+
``ERRORS`` table instead — ``tests/test_pending_command_wire_binding.py``
|
|
23
|
+
pins ``COMMAND_REJECTED_CODE``/``COMMAND_REJECTED_KIND`` to that table's
|
|
24
|
+
``commandRejected`` row, the same shape ``EXPECTED_SCHEMA_FINGERPRINT`` uses.
|
|
25
|
+
|
|
26
|
+
Faithful port of ``clients/sdk-ts/src/pending/pending-command-set.ts``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Final, Generic, Iterable, List, Literal, Sequence, TypedDict, TypeVar, Union
|
|
33
|
+
|
|
34
|
+
from muse_code_msp import TurnStartDisposition
|
|
35
|
+
|
|
36
|
+
from muse_code.errors import MuseSessionDiscardedError
|
|
37
|
+
|
|
38
|
+
I = TypeVar("I")
|
|
39
|
+
|
|
40
|
+
COMMAND_REJECTED_CODE: Final[int] = -32030
|
|
41
|
+
"""The JSON-RPC code for a durable command rejection (tdd SS3.1.2, Appendix B).
|
|
42
|
+
|
|
43
|
+
Named exactly once, here; ``tests/test_pending_command_wire_binding.py``
|
|
44
|
+
pins it to the generated ``muse_code_msp.ERRORS`` row for
|
|
45
|
+
``commandRejected``, so a schema advance that moves the code reds this lane
|
|
46
|
+
instead of silently leaving the SDK settling on a dead code.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
COMMAND_REJECTED_KIND: Final[str] = "commandRejected"
|
|
50
|
+
"""The registry row's ``kind``, pinned to the generated table by test.
|
|
51
|
+
|
|
52
|
+
The generated Python rendering keeps open enums as ``str`` (SS1.5.4), so the
|
|
53
|
+
TS compile-time ``Extract<...>`` binding has no Python twin; the wire-binding
|
|
54
|
+
test is the misspelling guard here.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_QUEUED_DISPOSITION: Final[str] = "queued"
|
|
58
|
+
"""The ``"queued"`` disposition (SS3.2); pinned to the generated
|
|
59
|
+
``TURN_START_DISPOSITION_KNOWN_VALUES`` by the wire-binding test."""
|
|
60
|
+
|
|
61
|
+
PendingDisposition = TurnStartDisposition
|
|
62
|
+
"""The ack's disposition — the generated SS3.2 vocabulary.
|
|
63
|
+
|
|
64
|
+
Open: an unrecognized value is "acked, not otherwise classified" and holds
|
|
65
|
+
like a started turn.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PendingCommandAck(TypedDict):
|
|
70
|
+
"""The ack a submit received: the turn it named, and how it was admitted.
|
|
71
|
+
|
|
72
|
+
This IS the generated ``turn/start`` result's ``turnId``/``disposition``
|
|
73
|
+
pair — a ``TurnStartResult`` off the wire is handed to ``acked()``
|
|
74
|
+
unchanged (TypedDicts are structural, and the result carries both keys).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
turnId: str
|
|
78
|
+
disposition: TurnStartDisposition
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class CommandErrorResponse:
|
|
83
|
+
"""An error response to a submit, or to a ``commandId`` replay.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
code: The JSON-RPC error code.
|
|
87
|
+
kind: The ``error.data.kind`` (the open generated ``ErrorKind``).
|
|
88
|
+
reason: ``commandRejected`` reason, verbatim snake_case (tdd SS1.6
|
|
89
|
+
casing exemption); ``None`` when the error carried none.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
code: int
|
|
93
|
+
kind: str
|
|
94
|
+
reason: str | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class ReplayAck(Generic[I]):
|
|
99
|
+
"""A replay answered with an ack (still pending, or freshly admitted)."""
|
|
100
|
+
|
|
101
|
+
ack: PendingCommandAck
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class ReplayError:
|
|
106
|
+
"""A replay answered with an error response."""
|
|
107
|
+
|
|
108
|
+
error: CommandErrorResponse
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
ReplayAnswer = Union[ReplayAck[I], ReplayError]
|
|
112
|
+
"""The answer to an idempotent ``commandId`` replay (tdd SS3.1.1)."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class PendingCommandEntry(Generic[I]):
|
|
117
|
+
"""An entry, as a consumer sees it: an immutable snapshot of internal state.
|
|
118
|
+
|
|
119
|
+
Attributes:
|
|
120
|
+
command_id: The client-minted ``commandId`` (opaque, SS3.1.4).
|
|
121
|
+
input: The submitted input, held for composer restoration.
|
|
122
|
+
anchor_after_item_id: The item the entry renders AFTER — the last
|
|
123
|
+
item present in the client's fold at submission time (``None`` =
|
|
124
|
+
before every item). Fixed at insertion: server events folding in
|
|
125
|
+
afterwards MUST NOT relocate it (SS4.13 "Insertion point and
|
|
126
|
+
ordering"). A snapshot join may re-anchor a kept queued entry;
|
|
127
|
+
nothing else moves it.
|
|
128
|
+
submission_index: Monotonic submission order.
|
|
129
|
+
display_text: Optional composer display text.
|
|
130
|
+
ack: The ack received, when any.
|
|
131
|
+
queued_order: Set at a snapshot join for a server-confirmed queued
|
|
132
|
+
entry only.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
command_id: str
|
|
136
|
+
input: I
|
|
137
|
+
anchor_after_item_id: str | None
|
|
138
|
+
submission_index: int
|
|
139
|
+
display_text: str | None = None
|
|
140
|
+
ack: PendingCommandAck | None = None
|
|
141
|
+
queued_order: int | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class MaterializedRetirement:
|
|
146
|
+
"""The command materialized (SS4.13 "Materialized").
|
|
147
|
+
|
|
148
|
+
``matched_by == "userMessage"``: the ``commandId``-bearing ``userMessage``
|
|
149
|
+
folded in and ``item_id`` is that item's OWN id (item ids and command ids
|
|
150
|
+
are separate namespaces on the wire). ``matched_by == "activeTurn"``: a
|
|
151
|
+
snapshot's ``activeTurn.commandId`` matched; no user-message item id is in
|
|
152
|
+
hand, so ``item_id`` is ``None`` and the caller renders from the
|
|
153
|
+
snapshot's items.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
command_id: str
|
|
157
|
+
matched_by: Literal["userMessage", "activeTurn"]
|
|
158
|
+
item_id: str | None = None
|
|
159
|
+
kind: Literal["materialized"] = "materialized"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class RejectedRetirement(Generic[I]):
|
|
164
|
+
"""Durably rejected (``-32030``). Nothing happened; restore the input."""
|
|
165
|
+
|
|
166
|
+
command_id: str
|
|
167
|
+
reason: str
|
|
168
|
+
input: I
|
|
169
|
+
kind: Literal["rejected"] = "rejected"
|
|
170
|
+
restore_to_composer: Literal[True] = True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class ReclaimedRetirement(Generic[I]):
|
|
175
|
+
"""Reclaimed (D-024): the turn never launched. Restore the input."""
|
|
176
|
+
|
|
177
|
+
command_id: str
|
|
178
|
+
input: I
|
|
179
|
+
kind: Literal["reclaimed"] = "reclaimed"
|
|
180
|
+
restore_to_composer: Literal[True] = True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class AbandonedRetirement(Generic[I]):
|
|
185
|
+
"""Restart recovery settled the intake ``abandoned`` (SS3.1.3)."""
|
|
186
|
+
|
|
187
|
+
command_id: str
|
|
188
|
+
input: I
|
|
189
|
+
kind: Literal["abandoned"] = "abandoned"
|
|
190
|
+
restore_to_composer: Literal[True] = True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class RetryAbandonedByClientRetirement(Generic[I]):
|
|
195
|
+
"""The client itself stopped retrying a nothing-admitted error.
|
|
196
|
+
|
|
197
|
+
SS4.13: "a client that stops retrying MUST retire the entry back to its
|
|
198
|
+
composer rather than leave a durable-looking echo behind."
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
command_id: str
|
|
202
|
+
input: I
|
|
203
|
+
kind: Literal["retryAbandonedByClient"] = "retryAbandonedByClient"
|
|
204
|
+
restore_to_composer: Literal[True] = True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class TerminalUnknownRetirement(Generic[I]):
|
|
209
|
+
"""Ephemeral-profile host death (SS2.13 / SS4.4.3 carve-out).
|
|
210
|
+
|
|
211
|
+
The client does not know what happened and MUST NOT invent it.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
command_id: str
|
|
215
|
+
input: I
|
|
216
|
+
kind: Literal["terminalUnknown"] = "terminalUnknown"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
PendingRetirement = Union[
|
|
220
|
+
MaterializedRetirement,
|
|
221
|
+
RejectedRetirement[I],
|
|
222
|
+
ReclaimedRetirement[I],
|
|
223
|
+
AbandonedRetirement[I],
|
|
224
|
+
RetryAbandonedByClientRetirement[I],
|
|
225
|
+
TerminalUnknownRetirement[I],
|
|
226
|
+
]
|
|
227
|
+
"""Why an entry left the set.
|
|
228
|
+
|
|
229
|
+
Every retirement is triggered by a server-authored fact; the set never
|
|
230
|
+
invents one (INV-006, SS3.1.3).
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class TurnRef:
|
|
236
|
+
"""A snapshot's ``(turnId, commandId)`` pair, as the caller assembled it."""
|
|
237
|
+
|
|
238
|
+
turn_id: str
|
|
239
|
+
command_id: str
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True)
|
|
243
|
+
class UserMessageRef:
|
|
244
|
+
"""A ``commandId``-bearing ``userMessage`` item paired with its OWN item id.
|
|
245
|
+
|
|
246
|
+
The caller holds both when building the join facts, and the pair keeps
|
|
247
|
+
the materialized retirement's ``item_id`` a real item id on the
|
|
248
|
+
resume/reconnect path exactly as on the live path.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
command_id: str
|
|
252
|
+
item_id: str
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class SnapshotJoinFacts:
|
|
257
|
+
"""Facts a snapshot supplies for the join (tdd SS4.9.1 ``state`` members).
|
|
258
|
+
|
|
259
|
+
Attributes:
|
|
260
|
+
active_turn: The running turn, when any.
|
|
261
|
+
queued_turns: Admitted-but-not-launched submits, in launch order.
|
|
262
|
+
user_message_command_ids: ``commandId``-bearing ``userMessage`` items
|
|
263
|
+
in ``state.items``, each paired with the item's own id.
|
|
264
|
+
last_item_id: The last item of the reconciled fold — where kept
|
|
265
|
+
queued entries re-anchor.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
active_turn: TurnRef | None
|
|
269
|
+
queued_turns: Sequence[TurnRef]
|
|
270
|
+
user_message_command_ids: Iterable[UserMessageRef]
|
|
271
|
+
last_item_id: str | None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class PendingJoinPlan(Generic[I]):
|
|
276
|
+
"""The work a join leaves for the caller.
|
|
277
|
+
|
|
278
|
+
The set is transport-less: it names the I/O the SS4.13 MUSTs require and
|
|
279
|
+
the caller performs it, then feeds the answers back through
|
|
280
|
+
``replay_answered`` / ``acked``.
|
|
281
|
+
|
|
282
|
+
Attributes:
|
|
283
|
+
retirements: Retirements the join itself settled.
|
|
284
|
+
must_replay: Acked entries the caller MUST resolve by replaying the
|
|
285
|
+
``commandId``.
|
|
286
|
+
must_resubmit: Unacked entries the caller MUST resubmit with the
|
|
287
|
+
SAME ``commandId`` before any retire-to-composer — demanded once
|
|
288
|
+
per plan (the single ordered pass that builds a plan visits each
|
|
289
|
+
entry once), re-demanded on each new join; SS3.1.1 idempotency
|
|
290
|
+
makes the re-demand safe. A fresh-``commandId`` re-send is the
|
|
291
|
+
double execution this ordering prevents.
|
|
292
|
+
kept: Entries kept as server-confirmed pending, in their new render
|
|
293
|
+
order.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
retirements: tuple[PendingRetirement[I], ...]
|
|
297
|
+
must_replay: tuple[str, ...]
|
|
298
|
+
must_resubmit: tuple[str, ...]
|
|
299
|
+
kept: tuple[str, ...]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass
|
|
303
|
+
class _InternalEntry(Generic[I]):
|
|
304
|
+
command_id: str
|
|
305
|
+
input: I
|
|
306
|
+
anchor_after_item_id: str | None
|
|
307
|
+
submission_index: int
|
|
308
|
+
display_text: str | None = None
|
|
309
|
+
ack: PendingCommandAck | None = None
|
|
310
|
+
queued_order: int | None = None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
Held = Literal["held"]
|
|
314
|
+
_HELD: Final[Held] = "held"
|
|
315
|
+
"""The not-a-settlement outcome: the entry stays pending."""
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def is_settlement(error: CommandErrorResponse) -> bool:
|
|
319
|
+
"""Whether an error response durably settles a command.
|
|
320
|
+
|
|
321
|
+
Only a durable ``commandRejected`` (``-32030``) settles; everything else
|
|
322
|
+
admitted nothing (SS4.13).
|
|
323
|
+
|
|
324
|
+
The registry binds ``-32030`` <-> ``commandRejected`` one-to-one
|
|
325
|
+
(SS3.1.2 Appendix B), so a response where the two fields DISAGREE is a
|
|
326
|
+
server fault in which one field still asserts the durable rejection.
|
|
327
|
+
Either signal settles (OR): SS3.1.2's stated bias for unfamiliar
|
|
328
|
+
vocabulary is toward terminal ("treat unknown reasons as terminal
|
|
329
|
+
rejections"), and holding forever on a half-asserted rejection strands
|
|
330
|
+
the entry as the durable-looking echo SS4.13 forbids (FM-008).
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
error: The error response to classify.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
True when the error settles the command.
|
|
337
|
+
"""
|
|
338
|
+
return error.code == COMMAND_REJECTED_CODE or error.kind == COMMAND_REJECTED_KIND
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass
|
|
342
|
+
class PendingCommandSet(Generic[I]):
|
|
343
|
+
"""The client-local pending-command set.
|
|
344
|
+
|
|
345
|
+
Tracks the commands you have submitted that the server has not yet woven
|
|
346
|
+
into the view: their optimistic display order, acknowledgements, replay
|
|
347
|
+
answers at reconnect, and retirements. ``Session`` owns and drives one
|
|
348
|
+
per conversation; read it through the session's pending view.
|
|
349
|
+
|
|
350
|
+
A client that never renders optimistically simply holds no entries and
|
|
351
|
+
the fold degenerates to SS4.1 — that is a supported mode, not a degraded
|
|
352
|
+
one.
|
|
353
|
+
|
|
354
|
+
Attributes:
|
|
355
|
+
discarded_command_ids: Where ``discard_ephemeral()`` records the ids
|
|
356
|
+
it retired, and where every replay guard reads (SS2.13.3b "do
|
|
357
|
+
not replay the session's ``commandId``\\ s"). Injected BY
|
|
358
|
+
REFERENCE so one client can share a single set across the
|
|
359
|
+
sessions it opens: the clause constrains what this CLIENT does
|
|
360
|
+
next, and a per-instance set would let a FRESH session replay a
|
|
361
|
+
dead host's ids at a new host. Omitted, the set owns a private
|
|
362
|
+
one and behaves exactly as before.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
discarded_command_ids: set[str] = field(default_factory=set)
|
|
366
|
+
_entries: dict[str, _InternalEntry[I]] = field(default_factory=dict, init=False)
|
|
367
|
+
_discarded: bool = field(default=False, init=False)
|
|
368
|
+
_next_submission_index: int = field(default=0, init=False)
|
|
369
|
+
|
|
370
|
+
def submitted(
|
|
371
|
+
self,
|
|
372
|
+
command_id: str,
|
|
373
|
+
input: I, # noqa: A002 - the SS4.13 name
|
|
374
|
+
*,
|
|
375
|
+
display_text: str | None = None,
|
|
376
|
+
anchor_after_item_id: str | None = None,
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Records a submission.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
command_id: The client-minted ``commandId``.
|
|
382
|
+
input: The submitted input, held for composer restoration.
|
|
383
|
+
display_text: Optional composer display text.
|
|
384
|
+
anchor_after_item_id: The last item present in the client's fold
|
|
385
|
+
right now (SS4.13 insertion point).
|
|
386
|
+
"""
|
|
387
|
+
# Replay check FIRST: for an id this set actually retired, "cannot be
|
|
388
|
+
# replayed against a new host" is the precise diagnosis, and the
|
|
389
|
+
# generic set-closed message below would bury it.
|
|
390
|
+
self._assert_not_ephemeral_replay(command_id)
|
|
391
|
+
# Then the whole SET, not just the ids it retired. A brand-new submit
|
|
392
|
+
# after an ephemeral host died would otherwise be accepted and retired
|
|
393
|
+
# terminalUnknown by the next discharge — "we don't know whether this
|
|
394
|
+
# ran" about input provably never sent to any host, which is the
|
|
395
|
+
# fabricated annotation SS2.13.3b exists to forbid.
|
|
396
|
+
self._assert_session_active()
|
|
397
|
+
if command_id in self._entries:
|
|
398
|
+
# A resubmit of the same commandId is the sanctioned exactly-once
|
|
399
|
+
# retry (SS3.1.1); it does not create a second entry and MUST NOT
|
|
400
|
+
# move the original's anchor.
|
|
401
|
+
return
|
|
402
|
+
entry: _InternalEntry[I] = _InternalEntry(
|
|
403
|
+
command_id=command_id,
|
|
404
|
+
input=input,
|
|
405
|
+
anchor_after_item_id=anchor_after_item_id,
|
|
406
|
+
submission_index=self._next_submission_index,
|
|
407
|
+
)
|
|
408
|
+
self._next_submission_index += 1
|
|
409
|
+
if display_text is not None:
|
|
410
|
+
entry.display_text = display_text
|
|
411
|
+
self._entries[command_id] = entry
|
|
412
|
+
|
|
413
|
+
def acked(self, command_id: str, ack: PendingCommandAck) -> None:
|
|
414
|
+
"""Records that the submit (or a replay) was acked."""
|
|
415
|
+
entry = self._entries.get(command_id)
|
|
416
|
+
if entry is None:
|
|
417
|
+
return
|
|
418
|
+
entry.ack = ack
|
|
419
|
+
|
|
420
|
+
def ack_errored(
|
|
421
|
+
self, command_id: str, error: CommandErrorResponse
|
|
422
|
+
) -> PendingRetirement[I] | Held:
|
|
423
|
+
"""The submit (or a replay) answered with an error.
|
|
424
|
+
|
|
425
|
+
Only a durable ``commandRejected`` (``-32030``) is a settlement.
|
|
426
|
+
Every other error admits nothing — the other SS3.1.2 admission
|
|
427
|
+
errors and SS1's envelope errors, ``inputTooLarge`` among them — so
|
|
428
|
+
the entry HOLDS while the client retries with the same ``commandId``
|
|
429
|
+
(SS4.13 "Nothing-admitted errors are not settlements").
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
The retirement when the error settles; ``"held"`` otherwise.
|
|
433
|
+
"""
|
|
434
|
+
entry = self._entries.get(command_id)
|
|
435
|
+
if entry is None:
|
|
436
|
+
return _HELD
|
|
437
|
+
if not is_settlement(error):
|
|
438
|
+
return _HELD
|
|
439
|
+
return self._retire(entry, self._rejection_retirement(entry, error))
|
|
440
|
+
|
|
441
|
+
def stop_retrying(self, command_id: str) -> PendingRetirement[I] | None:
|
|
442
|
+
"""The client has decided to stop retrying a nothing-admitted error.
|
|
443
|
+
|
|
444
|
+
The entry must not linger as a durable-looking echo (SS4.13).
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
The composer-restoring retirement, or ``None`` for no entry.
|
|
448
|
+
"""
|
|
449
|
+
entry = self._entries.get(command_id)
|
|
450
|
+
if entry is None:
|
|
451
|
+
return None
|
|
452
|
+
return self._retire(
|
|
453
|
+
entry,
|
|
454
|
+
RetryAbandonedByClientRetirement(command_id=command_id, input=entry.input),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def observed_user_message(
|
|
458
|
+
self, command_id: str, item_id: str
|
|
459
|
+
) -> PendingRetirement[I] | None:
|
|
460
|
+
"""A ``userMessage`` item carrying this ``commandId`` folded in.
|
|
461
|
+
|
|
462
|
+
The entry materialized and the item replaces it at the item's own
|
|
463
|
+
transcript position (SS4.13 "Materialized").
|
|
464
|
+
|
|
465
|
+
Multi-client echo de-duplication falls out of the same join: a
|
|
466
|
+
``userMessage`` with no matching local entry is another client's
|
|
467
|
+
submission — this returns ``None`` and the caller renders it plainly.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
The materialized retirement, or ``None`` for no matching entry.
|
|
471
|
+
"""
|
|
472
|
+
entry = self._entries.get(command_id)
|
|
473
|
+
if entry is None:
|
|
474
|
+
return None
|
|
475
|
+
return self._retire(
|
|
476
|
+
entry,
|
|
477
|
+
MaterializedRetirement(
|
|
478
|
+
command_id=command_id, matched_by="userMessage", item_id=item_id
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def observed_reclaim(self, command_id: str) -> PendingRetirement[I] | None:
|
|
483
|
+
"""A reclaim this client actually observed.
|
|
484
|
+
|
|
485
|
+
Its own reclaim ack, or any live fold D-024's lane charters. Retire
|
|
486
|
+
immediately — same composer restore, without waiting for a snapshot
|
|
487
|
+
join (SS4.13 "Reclaimed").
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
The reclaimed retirement, or ``None`` for no matching entry.
|
|
491
|
+
"""
|
|
492
|
+
entry = self._entries.get(command_id)
|
|
493
|
+
if entry is None:
|
|
494
|
+
return None
|
|
495
|
+
return self._retire(
|
|
496
|
+
entry, ReclaimedRetirement(command_id=command_id, input=entry.input)
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
def observed_queue_movement(self, turn_id: str) -> tuple[str, ...]:
|
|
500
|
+
"""Queue movement: a turn event for a turn that is NOT the entry's own.
|
|
501
|
+
|
|
502
|
+
SS4.3 carries no view event for a command-intake settlement, so a
|
|
503
|
+
post-ack rejection reaches a live client only by replay: on each such
|
|
504
|
+
event a client holding an acked-QUEUED entry MUST re-verify it by
|
|
505
|
+
replaying its ``commandId``. Queue movement is the trigger precisely
|
|
506
|
+
because a queued turn's fate is decided at its launch boundary — no
|
|
507
|
+
polling loop and no invented timeout (SS3.1.3 stays intact).
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
turn_id: The moved turn (``turn/started`` or ``turn/completed``).
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
The ``commandId``\\ s the caller must replay.
|
|
514
|
+
"""
|
|
515
|
+
out: List[str] = []
|
|
516
|
+
for entry in self._entries.values():
|
|
517
|
+
if entry.ack is None:
|
|
518
|
+
continue
|
|
519
|
+
if entry.ack["disposition"] != _QUEUED_DISPOSITION:
|
|
520
|
+
continue
|
|
521
|
+
if entry.ack["turnId"] == turn_id: # the entry's own turn
|
|
522
|
+
continue
|
|
523
|
+
out.append(entry.command_id)
|
|
524
|
+
return tuple(out)
|
|
525
|
+
|
|
526
|
+
def replay_answered(
|
|
527
|
+
self, command_id: str, answer: ReplayAnswer[I]
|
|
528
|
+
) -> PendingRetirement[I] | Held:
|
|
529
|
+
"""Feeds back a ``commandId`` replay answer (SS3.1.1 — always safe).
|
|
530
|
+
|
|
531
|
+
Three shapes, per SS4.13's rejected/abandoned arms:
|
|
532
|
+
|
|
533
|
+
- a durable rejection: retire (reason ``"abandoned"`` -> Abandoned
|
|
534
|
+
arm, any other reason -> Rejected arm);
|
|
535
|
+
- the original ack, still pending: keep waiting;
|
|
536
|
+
- an ack whose staleness only a snapshot can prove: keep waiting —
|
|
537
|
+
the reclaimed signature needs ``queuedTurns`` in hand
|
|
538
|
+
(``join_snapshot``).
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
The retirement when the answer settles; ``"held"`` otherwise.
|
|
542
|
+
"""
|
|
543
|
+
self._assert_not_ephemeral_replay(command_id)
|
|
544
|
+
entry = self._entries.get(command_id)
|
|
545
|
+
if entry is None:
|
|
546
|
+
return _HELD
|
|
547
|
+
if isinstance(answer, ReplayAck):
|
|
548
|
+
entry.ack = answer.ack
|
|
549
|
+
return _HELD
|
|
550
|
+
if not is_settlement(answer.error):
|
|
551
|
+
return _HELD
|
|
552
|
+
return self._retire(entry, self._rejection_retirement(entry, answer.error))
|
|
553
|
+
|
|
554
|
+
def reconnected_without_snapshot(self) -> PendingJoinPlan[I]:
|
|
555
|
+
"""Reconnect with NO snapshot (the default cursor-resume path).
|
|
556
|
+
|
|
557
|
+
There is no join to run, so reconnect itself is the trigger: replay
|
|
558
|
+
each ACKED entry's ``commandId`` once; resubmit each UNACKED entry's
|
|
559
|
+
same ``commandId`` once before any retire-to-composer.
|
|
560
|
+
|
|
561
|
+
Without ``queuedTurns`` in hand the reclaimed signature is
|
|
562
|
+
undecidable, so a stale ``"queued"`` ack waits for the next snapshot
|
|
563
|
+
join or queue movement — never a locally invented terminal.
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
The plan naming the replays and resubmits the caller owes.
|
|
567
|
+
"""
|
|
568
|
+
# Once-per-plan comes from the single ordered pass (each entry is
|
|
569
|
+
# visited exactly once); a NEW reconnect demands again, which SS3.1.1
|
|
570
|
+
# same-commandId idempotency makes safe — a once-per-lifetime latch
|
|
571
|
+
# would strand an entry whose demand was lost to a second disconnect
|
|
572
|
+
# (SS4.13 forbids).
|
|
573
|
+
must_replay: List[str] = []
|
|
574
|
+
must_resubmit: List[str] = []
|
|
575
|
+
for entry in self._ordered():
|
|
576
|
+
if entry.ack is None:
|
|
577
|
+
must_resubmit.append(entry.command_id)
|
|
578
|
+
else:
|
|
579
|
+
must_replay.append(entry.command_id)
|
|
580
|
+
return PendingJoinPlan(
|
|
581
|
+
retirements=(),
|
|
582
|
+
must_replay=tuple(must_replay),
|
|
583
|
+
must_resubmit=tuple(must_resubmit),
|
|
584
|
+
kept=tuple(e.command_id for e in self._ordered()),
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def join_snapshot(self, facts: SnapshotJoinFacts) -> PendingJoinPlan[I]:
|
|
588
|
+
"""Joins local entries against an authoritative snapshot (SS4.9).
|
|
589
|
+
|
|
590
|
+
By ``commandId``; the five arms, in the order SS4.13 states them.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
facts: The snapshot's join surfaces, caller-assembled.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
The plan: retirements settled by the join, plus the replays and
|
|
597
|
+
resubmits the caller owes.
|
|
598
|
+
"""
|
|
599
|
+
# Once-per-plan comes from the single ordered pass below, exactly as
|
|
600
|
+
# in reconnected_without_snapshot; a NEW join demands again, which
|
|
601
|
+
# SS3.1.1 same-commandId idempotency makes safe (SS4.13 forbids
|
|
602
|
+
# stranding an entry whose demand was lost to a second disconnect).
|
|
603
|
+
retirements: List[PendingRetirement[I]] = []
|
|
604
|
+
must_replay: List[str] = []
|
|
605
|
+
must_resubmit: List[str] = []
|
|
606
|
+
|
|
607
|
+
user_message_item_by_command_id: dict[str, str] = {
|
|
608
|
+
pair.command_id: pair.item_id for pair in facts.user_message_command_ids
|
|
609
|
+
}
|
|
610
|
+
queued_index_by_command_id: dict[str, int] = {
|
|
611
|
+
q.command_id: index for index, q in enumerate(facts.queued_turns)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
for entry in self._ordered():
|
|
615
|
+
command_id = entry.command_id
|
|
616
|
+
|
|
617
|
+
# Arm 1 — materialized: a userMessage item (its own item id is in
|
|
618
|
+
# the join facts), or the activeTurn's commandId (no item id in
|
|
619
|
+
# hand — the retirement says so via matched_by, never aliasing
|
|
620
|
+
# the command id).
|
|
621
|
+
user_message_item_id = user_message_item_by_command_id.get(command_id)
|
|
622
|
+
if user_message_item_id is not None:
|
|
623
|
+
retirements.append(
|
|
624
|
+
self._retire(
|
|
625
|
+
entry,
|
|
626
|
+
MaterializedRetirement(
|
|
627
|
+
command_id=command_id,
|
|
628
|
+
matched_by="userMessage",
|
|
629
|
+
item_id=user_message_item_id,
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
continue
|
|
634
|
+
if facts.active_turn is not None and facts.active_turn.command_id == command_id:
|
|
635
|
+
retirements.append(
|
|
636
|
+
self._retire(
|
|
637
|
+
entry,
|
|
638
|
+
MaterializedRetirement(
|
|
639
|
+
command_id=command_id, matched_by="activeTurn"
|
|
640
|
+
),
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
continue
|
|
644
|
+
|
|
645
|
+
# Arm 2 — server-confirmed queued: keep, reordered to queuedTurns
|
|
646
|
+
# order.
|
|
647
|
+
queued_index = queued_index_by_command_id.get(command_id)
|
|
648
|
+
if queued_index is not None:
|
|
649
|
+
entry.queued_order = queued_index
|
|
650
|
+
# A kept entry re-anchors after the last item of the
|
|
651
|
+
# reconciled fold.
|
|
652
|
+
entry.anchor_after_item_id = facts.last_item_id
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
# Arm 3 — an acked STEER matches neither activeTurn.commandId nor
|
|
656
|
+
# queuedTurns by design: it is pending on the running turn's
|
|
657
|
+
# PendingSteerQueue (SS3.3), not settled. Keep it as
|
|
658
|
+
# server-confirmed pending; retiring here shows "never ran" and
|
|
659
|
+
# then double-sends when the steer delivers. A steer keeps its
|
|
660
|
+
# submission anchor — it has no launch position.
|
|
661
|
+
if (
|
|
662
|
+
entry.ack is not None
|
|
663
|
+
and facts.active_turn is not None
|
|
664
|
+
and entry.ack["turnId"] == facts.active_turn.turn_id
|
|
665
|
+
):
|
|
666
|
+
continue
|
|
667
|
+
|
|
668
|
+
# Arm 4 — acked, matching nothing: resolve by replaying the
|
|
669
|
+
# commandId.
|
|
670
|
+
if entry.ack is not None:
|
|
671
|
+
must_replay.append(command_id)
|
|
672
|
+
continue
|
|
673
|
+
|
|
674
|
+
# Arm 5 — unacked, and the join did not match it. The intake is
|
|
675
|
+
# durable BEFORE the ack (SS3.1.3), and a join miss does not
|
|
676
|
+
# prove the intake was never written (a parked steer is in none
|
|
677
|
+
# of the join surfaces), so demand a SAME-commandId resubmit
|
|
678
|
+
# before any retire-to-composer.
|
|
679
|
+
must_resubmit.append(command_id)
|
|
680
|
+
|
|
681
|
+
return PendingJoinPlan(
|
|
682
|
+
retirements=tuple(retirements),
|
|
683
|
+
must_replay=tuple(must_replay),
|
|
684
|
+
must_resubmit=tuple(must_resubmit),
|
|
685
|
+
kept=tuple(e.command_id for e in self._ordered()),
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
def resolve_replay_at_join(
|
|
689
|
+
self,
|
|
690
|
+
command_id: str,
|
|
691
|
+
answer: ReplayAnswer[I],
|
|
692
|
+
snapshot_queued_command_ids: Iterable[str],
|
|
693
|
+
) -> PendingRetirement[I] | Held:
|
|
694
|
+
"""Resolves a ``must_replay`` answer with the snapshot in hand.
|
|
695
|
+
|
|
696
|
+
The reclaimed signature, decidable only with a snapshot: an
|
|
697
|
+
acked-queued entry ABSENT from the snapshot's ``queuedTurns`` whose
|
|
698
|
+
replay still answers the stale ``"queued"`` ack retires as
|
|
699
|
+
*reclaimed* at the snapshot join (SS4.13 "Reclaimed").
|
|
700
|
+
|
|
701
|
+
Call this with the replay answer for a ``must_replay`` entry produced
|
|
702
|
+
by ``join_snapshot``, passing the same snapshot's ``queuedTurns``
|
|
703
|
+
command ids.
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
The retirement when the answer settles; ``"held"`` otherwise.
|
|
707
|
+
"""
|
|
708
|
+
self._assert_not_ephemeral_replay(command_id)
|
|
709
|
+
entry = self._entries.get(command_id)
|
|
710
|
+
if entry is None:
|
|
711
|
+
return _HELD
|
|
712
|
+
|
|
713
|
+
if isinstance(answer, ReplayError):
|
|
714
|
+
if not is_settlement(answer.error):
|
|
715
|
+
return _HELD
|
|
716
|
+
return self._retire(entry, self._rejection_retirement(entry, answer.error))
|
|
717
|
+
|
|
718
|
+
still_queued_by_server = command_id in set(snapshot_queued_command_ids)
|
|
719
|
+
if answer.ack["disposition"] == _QUEUED_DISPOSITION and not still_queued_by_server:
|
|
720
|
+
# The snapshot just proved this ack stale.
|
|
721
|
+
return self._retire(
|
|
722
|
+
entry, ReclaimedRetirement(command_id=command_id, input=entry.input)
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
# A still-pending "steered" or "started" ack: the entry stays pending
|
|
726
|
+
# and materializes or settles later. Never promote it locally.
|
|
727
|
+
entry.ack = answer.ack
|
|
728
|
+
return _HELD
|
|
729
|
+
|
|
730
|
+
def discard_ephemeral(self) -> tuple[PendingRetirement[I], ...]:
|
|
731
|
+
"""Abnormal death of an EPHEMERAL-profile host (SS2.13, SS4.4.3).
|
|
732
|
+
|
|
733
|
+
Nothing is ever settled, replaying a ``commandId`` at a new host is
|
|
734
|
+
forbidden, and every entry falls under the discard obligation as
|
|
735
|
+
terminal-unknown — a client-side annotation, never a wire fact.
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
One terminal-unknown retirement per discarded entry.
|
|
739
|
+
"""
|
|
740
|
+
out: List[PendingRetirement[I]] = []
|
|
741
|
+
for entry in self._ordered():
|
|
742
|
+
self.discarded_command_ids.add(entry.command_id)
|
|
743
|
+
out.append(
|
|
744
|
+
self._retire(
|
|
745
|
+
entry,
|
|
746
|
+
TerminalUnknownRetirement(
|
|
747
|
+
command_id=entry.command_id, input=entry.input
|
|
748
|
+
),
|
|
749
|
+
)
|
|
750
|
+
)
|
|
751
|
+
self._discarded = True
|
|
752
|
+
return tuple(out)
|
|
753
|
+
|
|
754
|
+
@property
|
|
755
|
+
def discarded(self) -> bool:
|
|
756
|
+
"""Whether the ephemeral discard has closed this set."""
|
|
757
|
+
return self._discarded
|
|
758
|
+
|
|
759
|
+
def list(self) -> tuple[PendingCommandEntry[I], ...]:
|
|
760
|
+
"""Entries in render order.
|
|
761
|
+
|
|
762
|
+
Before any join this is submission order. After a join, entries the
|
|
763
|
+
server confirmed queued are re-anchored to the end of the reconciled
|
|
764
|
+
fold and ordered among themselves by ``queuedTurns`` — so they sort
|
|
765
|
+
after the submission-anchored entries (steers and unacked), which
|
|
766
|
+
keep their own anchors. That is SS4.13's "entries render in
|
|
767
|
+
submission order, and for queued submits the server-confirmed order
|
|
768
|
+
wins whenever it is observed".
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
Immutable entry snapshots, in render order.
|
|
772
|
+
"""
|
|
773
|
+
return tuple(
|
|
774
|
+
PendingCommandEntry(
|
|
775
|
+
command_id=entry.command_id,
|
|
776
|
+
input=entry.input,
|
|
777
|
+
anchor_after_item_id=entry.anchor_after_item_id,
|
|
778
|
+
submission_index=entry.submission_index,
|
|
779
|
+
display_text=entry.display_text,
|
|
780
|
+
ack=entry.ack,
|
|
781
|
+
queued_order=entry.queued_order,
|
|
782
|
+
)
|
|
783
|
+
for entry in self._ordered()
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
def get(self, command_id: str) -> PendingCommandEntry[I] | None:
|
|
787
|
+
"""The entry snapshot for ``command_id``, or ``None``."""
|
|
788
|
+
for entry in self.list():
|
|
789
|
+
if entry.command_id == command_id:
|
|
790
|
+
return entry
|
|
791
|
+
return None
|
|
792
|
+
|
|
793
|
+
def has(self, command_id: str) -> bool:
|
|
794
|
+
"""Whether an entry with ``command_id`` is pending."""
|
|
795
|
+
return command_id in self._entries
|
|
796
|
+
|
|
797
|
+
@property
|
|
798
|
+
def size(self) -> int:
|
|
799
|
+
"""The number of pending entries."""
|
|
800
|
+
return len(self._entries)
|
|
801
|
+
|
|
802
|
+
def _assert_session_active(self) -> None:
|
|
803
|
+
if self._discarded:
|
|
804
|
+
raise MuseSessionDiscardedError(
|
|
805
|
+
"ephemeral session was discarded after abnormal host death; "
|
|
806
|
+
"it accepts no new submissions"
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
def _assert_not_ephemeral_replay(self, command_id: str) -> None:
|
|
810
|
+
if command_id in self.discarded_command_ids:
|
|
811
|
+
raise MuseSessionDiscardedError(
|
|
812
|
+
f"ephemeral host died; commandId `{command_id}` cannot be "
|
|
813
|
+
"replayed against a new host"
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
def _rejection_retirement(
|
|
817
|
+
self, entry: _InternalEntry[I], error: CommandErrorResponse
|
|
818
|
+
) -> PendingRetirement[I]:
|
|
819
|
+
reason = error.reason if error.reason is not None else ""
|
|
820
|
+
if reason == "abandoned":
|
|
821
|
+
return AbandonedRetirement(command_id=entry.command_id, input=entry.input)
|
|
822
|
+
return RejectedRetirement(
|
|
823
|
+
command_id=entry.command_id, reason=reason, input=entry.input
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
def _retire(
|
|
827
|
+
self, entry: _InternalEntry[I], retirement: PendingRetirement[I]
|
|
828
|
+
) -> PendingRetirement[I]:
|
|
829
|
+
del self._entries[entry.command_id]
|
|
830
|
+
return retirement
|
|
831
|
+
|
|
832
|
+
def _ordered(self) -> List[_InternalEntry[I]]:
|
|
833
|
+
submission_anchored: List[_InternalEntry[I]] = []
|
|
834
|
+
launch_ordered: List[_InternalEntry[I]] = []
|
|
835
|
+
for entry in self._entries.values():
|
|
836
|
+
if entry.queued_order is None:
|
|
837
|
+
submission_anchored.append(entry)
|
|
838
|
+
else:
|
|
839
|
+
launch_ordered.append(entry)
|
|
840
|
+
submission_anchored.sort(key=lambda e: e.submission_index)
|
|
841
|
+
launch_ordered.sort(key=lambda e: e.queued_order or 0)
|
|
842
|
+
return [*submission_anchored, *launch_ordered]
|