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.
@@ -0,0 +1,483 @@
1
+ """SS4.8 gap recovery by splice-fill (spec 638 FR-638-019c / T032, carrying
2
+ spec 14990 FR-020; tdd SS4.8). Port of ``clients/sdk-ts/src/facade/gap-fill.ts``.
3
+
4
+ Split out of ``session.py`` for the same reason ``approval.py`` and
5
+ ``turn_submit.py`` were: this is a delivery-plane concern with its own buffer,
6
+ page walk, and failure vocabulary, and ``Session`` was already the package's
7
+ largest module.
8
+
9
+ THE RECIPE, verbatim from tdd SS4.8 and unchanged here: buffer live events at
10
+ cursors at or after ``next`` as they arrive; page forward from ``after`` with
11
+ ``view/page`` until the walk reaches ``next``; discard the paged events the
12
+ buffer already holds; splice the buffer after the paged prefix. D-030's second
13
+ sanctioned path (drop state and re-anchor at the latest compaction snapshot)
14
+ is NOT built here — it is the #208 lane's spec work, and building both would
15
+ be a second concrete path for one current use.
16
+
17
+ CURSORS STAY OPAQUE (tdd SS4.1). Nothing below orders two cursors: "at or
18
+ after ``next``" is delivery ORDER, not a comparison — every live frame that
19
+ arrives after the gap marker is, by the server's own delivery contract, at or
20
+ after ``next``. The walk stops on cursor EQUALITY with the target or on the
21
+ server's own end-of-view, and the overlap is discarded by set membership.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ from dataclasses import dataclass
28
+ from typing import (
29
+ Any,
30
+ Awaitable,
31
+ Callable,
32
+ Generator,
33
+ Generic,
34
+ List,
35
+ Mapping,
36
+ TypeVar,
37
+ )
38
+
39
+ from muse_code_msp import ViewPageParams
40
+
41
+ from ..connection.connection import Connection
42
+ from ..errors import GapFillFailureReason, MuseForeignSessionError, MuseGapFillError
43
+ from ..fold.session_fold import SessionFold
44
+ from ..pending.pending_command_set import PendingRetirement
45
+
46
+ _I = TypeVar("_I")
47
+
48
+ PAGE_LIMIT = 200
49
+ """How many events one ``view/page`` asks for.
50
+
51
+ Any value in the published 1–1000 bound (``ViewPageParams.limit``) is correct
52
+ — the server MAY serve fewer and the walk follows ``nextCursor`` either way —
53
+ so this trades round trips against frame size and is deliberately not a knob:
54
+ a per-session page size would be a public option with no current consumer
55
+ asking for one (Constitution XI).
56
+ """
57
+
58
+ WireFrame = Mapping[str, Any]
59
+ """One wire frame, exactly as ``Session.apply`` accepts it (``method`` +
60
+ ``params``)."""
61
+
62
+
63
+ @dataclass
64
+ class DrainSink(Generic[_I]):
65
+ """Where a drained frame's side effects go.
66
+
67
+ The filler re-feeds frames through ``Session``'s own fold-and-route path,
68
+ so the retirements and the client→server I/O they trigger belong to
69
+ ``Session``, not here.
70
+ """
71
+
72
+ retirements: List[PendingRetirement[_I]]
73
+ tasks: List[Awaitable[tuple[PendingRetirement[_I], ...]]]
74
+
75
+
76
+ class ResolvedIo(Generic[_I]):
77
+ """An already-settled ``io`` that needs no event loop.
78
+
79
+ ``Session.apply`` runs synchronously and is called off a fold with no host
80
+ at all (every fold-only arm in this package), so the
81
+ overwhelming-majority no-I/O case must not require a running loop to build
82
+ its ``io`` — and a bare coroutine would warn "never awaited" for the
83
+ consumers (most of them) that do not await it. This awaitable can be
84
+ awaited any number of times or never, allocates nothing on the loop, and
85
+ always yields its held tuple.
86
+ """
87
+
88
+ __slots__ = ("_value",)
89
+
90
+ def __init__(self, value: tuple[PendingRetirement[_I], ...]) -> None:
91
+ self._value = value
92
+
93
+ def __await__(
94
+ self,
95
+ ) -> Generator[Any, None, tuple[PendingRetirement[_I], ...]]:
96
+ return self._value
97
+ yield # pragma: no cover - unreachable; marks this a generator
98
+
99
+
100
+ GapFillFailureHandler = Callable[[MuseGapFillError], None]
101
+ """Reported, never raised — see :class:`~muse_code.errors.MuseGapFillError`."""
102
+
103
+
104
+ @dataclass
105
+ class GapFillerOptions(Generic[_I]):
106
+ """What the filler composes over.
107
+
108
+ Attributes:
109
+ session_id: The owning session's id, named in every ``view/page``.
110
+ connection: Absent on a fold-only ``Session``: nothing to page through.
111
+ fold: The fold whose ``pending_gap``/``gap_filled`` the walk drives.
112
+ apply: Fold and route ONE frame, collecting what it produced.
113
+ discarded: SS2.13.3b — a discharged session folds nothing, fill or no
114
+ fill.
115
+ """
116
+
117
+ session_id: str
118
+ connection: Connection | None
119
+ fold: SessionFold
120
+ apply: Callable[[WireFrame, DrainSink[_I]], object]
121
+ discarded: Callable[[], bool]
122
+
123
+
124
+ class GapFiller(Generic[_I]):
125
+ """Drives the SS4.8 splice-fill for one session (spec 638 FR-638-019c)."""
126
+
127
+ def __init__(self, options: GapFillerOptions[_I]) -> None:
128
+ """Builds the filler over its session's seams."""
129
+ self._options = options
130
+ # Live frames held while a fill is in flight, in arrival order.
131
+ self._buffer: List[WireFrame] = []
132
+ self._filling = False
133
+ self._cursor = ""
134
+ self._on_failure: GapFillFailureHandler | None = None
135
+ # Cursors the page served at or after a target — the events whose LIVE
136
+ # twin the wire has yet to deliver.
137
+ #
138
+ # THE LATE HALF of the recipe's overlap discard, and only that half. A
139
+ # twin that has already arrived is in the buffer, and ``_drain``
140
+ # discards those against its own local set of EVERY paged cursor. This
141
+ # set covers the other window: the wire promises no order between the
142
+ # marker and the frame at ``next``, so a twin can legally arrive after
143
+ # the fill completed, when the buffer is gone. Folding it then re-runs
144
+ # its routing — for a ``turn/started``, a duplicate SS4.13
145
+ # queue-movement replay on the wire.
146
+ #
147
+ # SELF-DRAINING: an entry is removed the moment its twin arrives, and
148
+ # only cursors at or after a target ever enter (the walk stops at the
149
+ # page that carries it, so at most one page's worth per target).
150
+ #
151
+ # RESIDUAL, stated rather than hidden: an entry whose twin the wire
152
+ # never delivers — because a LATER hole swallowed it — has no arrival
153
+ # to retire it and stays, a short string each, bounded by one page's
154
+ # worth per TARGET.
155
+ #
156
+ # Two prunes were tried on the TS twin and both were defects (PR
157
+ # #24930 review rounds 2–4), so neither is here: clearing per fill
158
+ # drops the refusal for a twin still on its way, and retiring inside
159
+ # the WALK when it meets the cursor before its own target deletes a
160
+ # buffered twin's only refusal (the walk cannot see the buffer).
161
+ # Deciding staleness any other way means ordering two opaque cursors,
162
+ # which tdd SS4.1 forbids.
163
+ self._served_twins: set[str] = set()
164
+
165
+ @property
166
+ def filling(self) -> bool:
167
+ """While ``True``, every live frame but the gap marker itself is held."""
168
+ return self._filling
169
+
170
+ def claim_served_twin(self, frame: WireFrame) -> str | None:
171
+ """Was this frame already applied from a page, as part of the overlap
172
+ the splice discards? Consuming: a twin is refused exactly once.
173
+
174
+ Returns the matched CURSOR rather than a boolean so the caller can
175
+ report which event it refused without re-reading (and re-defaulting) a
176
+ field this method already proved is there.
177
+ """
178
+ cursor = _view_cursor_of(frame)
179
+ if cursor is None or cursor not in self._served_twins:
180
+ return None
181
+ self._served_twins.discard(cursor)
182
+ return cursor
183
+
184
+ def on_error(self, handler: GapFillFailureHandler) -> None:
185
+ """Observe fills that did not complete. See :class:`MuseGapFillError`."""
186
+ self._on_failure = handler
187
+
188
+ def hold(self, frame: WireFrame) -> None:
189
+ """Hold one live frame until the paged prefix is in."""
190
+ self._buffer.append(frame)
191
+
192
+ def start(self) -> Awaitable[tuple[PendingRetirement[_I], ...]] | None:
193
+ """Recover the fold's outstanding hole.
194
+
195
+ Called on every ``view/gap``, including one that lands mid-fill: the
196
+ running walk reads its target from ``fold.pending_gap`` on each pass,
197
+ so a coalesced hole extends the walk in flight rather than starting a
198
+ second one racing it for the same cursor stream.
199
+
200
+ Returns ``None`` when this call starts no walk — nothing outstanding,
201
+ a walk already in flight, or a fold-only session (whose
202
+ ``noConnection`` failure is reported synchronously before returning).
203
+ ``None`` rather than an already-settled awaitable, deliberately: the
204
+ caller feeds the return into the ``io`` channel, and an entry there
205
+ forces ``_settled_io`` onto its gather arm, which needs a running
206
+ loop a fold-only ``apply`` may not have (PR #32249 review round 1 —
207
+ the same shape as ``ApprovalRouter.requested``).
208
+ """
209
+ gap = self._options.fold.pending_gap
210
+ # Only reachable if a caller invokes this with nothing outstanding; the
211
+ # one production caller folds the marker first, which always sets it.
212
+ if gap is None:
213
+ return None
214
+ if self._filling:
215
+ return None
216
+ connection = self._options.connection
217
+ if connection is None:
218
+ # Nothing is buffered on this path: no fill is coming to release
219
+ # it, and holding the live tail forever would turn one reported
220
+ # hole into a silent second one.
221
+ self._report(MuseGapFillError("noConnection", gap["after"], gap["next"]))
222
+ return None
223
+ self._filling = True
224
+ self._cursor = gap["after"]
225
+ return asyncio.ensure_future(self._run(connection))
226
+
227
+ async def _run(
228
+ self, connection: Connection
229
+ ) -> tuple[PendingRetirement[_I], ...]:
230
+ paged: List[WireFrame] = []
231
+ try:
232
+ await self._walk(connection, paged)
233
+ except Exception as error: # noqa: BLE001 - reported, never escapes
234
+ self._report_failure(error)
235
+ # ALWAYS drained, success or failure: the buffered tail is made of
236
+ # events this client really did receive, and swallowing them would add
237
+ # a second hole to the one just reported.
238
+ #
239
+ # The TS twin re-checks ``pendingGap`` after this and restarts a walk
240
+ # (its "clear window": TS ``#drain`` lowers the flag a microtask after
241
+ # ``#walk`` cleared the hole, and a ``view/gap`` folding in between
242
+ # would strand). That window CANNOT exist here: the walk's final
243
+ # ``gap_filled`` and this ``_drain`` — which lowers ``_filling`` — run
244
+ # in one synchronous task step with no await between them, so no frame
245
+ # can fold inside it, and a ``view/gap`` arriving after this task step
246
+ # finds ``_filling`` already false and starts its own walk from
247
+ # ``apply``. A Python restart block would be unreachable defense
248
+ # (PR #32249 review round 1 proved it dead by mutation).
249
+ return await self._drain(paged)
250
+
251
+ async def _walk(self, connection: Connection, paged: List[WireFrame]) -> None:
252
+ """Page until the fold's outstanding hole is closed.
253
+
254
+ The outer loop is what makes a mid-walk ``view/gap`` safe:
255
+ ``gap_filled`` refuses to clear a target the fold has since extended,
256
+ so the walk simply keeps going toward the newer ``next`` on the SAME
257
+ cursor stream.
258
+ """
259
+ while True:
260
+ # A session discharged mid-walk stops here, BEFORE ``gap_filled``:
261
+ # there is nothing left to be current with, and clearing the hole
262
+ # would have the fold report a discarded transcript complete. The
263
+ # host is gone, so a further page would also wait on an answer
264
+ # that is never coming — which is a hang, not a fill.
265
+ if self._options.discarded():
266
+ return
267
+ gap = self._options.fold.pending_gap
268
+ if gap is None:
269
+ return
270
+ target = gap["next"]
271
+ await self._walk_to(connection, target, gap["after"], paged)
272
+ if self._options.discarded():
273
+ return
274
+ if self._options.fold.gap_filled(target):
275
+ return
276
+
277
+ async def _walk_to(
278
+ self,
279
+ connection: Connection,
280
+ target: str,
281
+ after: str,
282
+ paged: List[WireFrame],
283
+ ) -> None:
284
+ while True:
285
+ if self._options.discarded():
286
+ return
287
+ result = await self._page(connection, self._cursor)
288
+ reached = False
289
+ events = result["events"]
290
+ for event in events:
291
+ self._require_own_session(event, after, target)
292
+ cursor = str(event["params"]["viewCursor"])
293
+ # An EARLIER fill already served — and applied — this durable
294
+ # event. A later walk may legally page through the same range,
295
+ # and re-applying it here is the same double fold the discard
296
+ # exists to prevent.
297
+ already_applied = cursor in self._served_twins
298
+ # Equality, never a relational compare: ``next`` is a cursor,
299
+ # and cursors are opaque (tdd SS4.1). Set BEFORE the skip
300
+ # below, or a target the walk skips is a target the walk never
301
+ # reaches, and it pages straight past its own stopping point.
302
+ if cursor == target:
303
+ reached = True
304
+ if already_applied:
305
+ # SKIPPED AND NOTHING ELSE. Retiring the entry here when
306
+ # the walk meets it before its own target deletes a
307
+ # buffered twin's only refusal — the walk cannot see the
308
+ # buffer, and a coalescing gap moves the target past a
309
+ # cursor whose twin is sitting in it.
310
+ continue
311
+ paged.append(event)
312
+ # At or after ``next`` — by page ORDER, not by comparing two
313
+ # cursors. These are the events the live tail also delivers,
314
+ # so their twins are the overlap the splice discards.
315
+ if reached:
316
+ self._served_twins.add(cursor)
317
+ # The server's own end of the view. ``next`` names a LIVE cursor
318
+ # and ``view/page`` serves durable-sourced events only (tdd
319
+ # SS4.7.3), so a hole whose tail was ephemeral ends here and never
320
+ # at ``target``. An ABSENT ``nextCursor`` is a different fact — the
321
+ # member is "never omitted", so it falls through to the stall arm.
322
+ if "nextCursor" in result and result["nextCursor"] is None:
323
+ return
324
+ next_cursor = result.get("nextCursor")
325
+ # PROGRESS is the only bound on this walk, and it has to be,
326
+ # because a legitimate fill is unbounded in length: an attempt cap
327
+ # would silently truncate one. A page that carries no event,
328
+ # repeats the cursor it was given, or answers with no cursor at
329
+ # all has not advanced, and paging again would ask the identical
330
+ # question forever.
331
+ if (
332
+ len(events) == 0
333
+ or not isinstance(next_cursor, str)
334
+ or next_cursor == self._cursor
335
+ ):
336
+ raise MuseGapFillError("pageStalled", after, target)
337
+ self._cursor = next_cursor
338
+ if reached:
339
+ return
340
+
341
+ async def _page(
342
+ self, connection: Connection, cursor: str
343
+ ) -> Mapping[str, Any]:
344
+ params: ViewPageParams = {
345
+ "cursor": cursor,
346
+ "limit": PAGE_LIMIT,
347
+ "sessionId": self._options.session_id,
348
+ }
349
+ return await connection.request("view/page", dict(params))
350
+
351
+ def _require_own_session(
352
+ self, event: Mapping[str, Any], after: str, target: str
353
+ ) -> None:
354
+ """A page that serves another session's events aborts the fill.
355
+
356
+ The same check ``Session.apply`` makes on a live frame, at the one
357
+ other place a frame can enter the fold. Folding it would corrupt this
358
+ transcript with another session's history, and the request that asked
359
+ for it named this ``sessionId``, so there is no reading under which
360
+ the answer is right.
361
+ """
362
+ params = event.get("params")
363
+ named = params.get("sessionId") if isinstance(params, dict) else None
364
+ if isinstance(named, str) and named != self._options.session_id:
365
+ raise MuseGapFillError(
366
+ "pageFailed",
367
+ after,
368
+ target,
369
+ MuseForeignSessionError(
370
+ f"view/page for {self._options.session_id} served an event "
371
+ f"for session {named}"
372
+ ),
373
+ )
374
+
375
+ def _drain(
376
+ self, paged: List[WireFrame]
377
+ ) -> Awaitable[tuple[PendingRetirement[_I], ...]]:
378
+ """Splice: the paged prefix, then the buffered tail minus the overlap.
379
+
380
+ Synchronous up to its return, deliberately. Clearing the buffer and
381
+ the flag in the same run of code that re-feeds them is what makes the
382
+ handover atomic — an await in the middle would let a live frame arrive
383
+ after the flag dropped and fold ahead of the tail still waiting in the
384
+ buffer.
385
+
386
+ It is outside the walk's ``try`` because the buffered tail must be
387
+ released on FAILURE too, and it needs no ``try`` of its own: every
388
+ frame it re-feeds has already passed the one check ``Session.apply``
389
+ can raise on — the live half at buffer time, the paged half in the
390
+ walk — so this cannot be the rejection ``SessionApplyOutcome.io``
391
+ promises never to be.
392
+ """
393
+ buffered = list(self._buffer)
394
+ self._buffer.clear()
395
+ self._filling = False
396
+ # SS2.13.3b outranks the fill: a session discharged while the walk was
397
+ # in flight folds nothing more, and neither half of the splice is
398
+ # exempt.
399
+ if self._options.discarded():
400
+ return ResolvedIo(())
401
+ sink: DrainSink[_I] = DrainSink(retirements=[], tasks=[])
402
+ # EVERY paged cursor, not just the ones at or after a target. A
403
+ # coalesced gap restarts the walk toward the NEW target with
404
+ # ``reached`` false, so paged events between the two targets never
405
+ # enter ``_served_twins`` — yet their twins ARE in this buffer,
406
+ # because a cursor at or after the first target was delivered live
407
+ # before the second hole opened. Claiming on the persistent set alone
408
+ # folded those twice.
409
+ served: set[str] = set()
410
+ for frame in paged:
411
+ cursor = _view_cursor_of(frame)
412
+ if cursor is not None:
413
+ served.add(cursor)
414
+ self._options.apply(frame, sink)
415
+ for frame in buffered:
416
+ # The overlap the recipe discards: the page already served this
417
+ # cursor, so folding the buffered copy would route the same event
418
+ # twice. BOTH sets: ``served`` is what THIS fill applied;
419
+ # ``_served_twins`` carries what EARLIER fills served, including
420
+ # the cursors this walk declined to re-apply — nothing deletes
421
+ # those inside the walk, precisely so this branch can still find
422
+ # them. A twin of either can be sitting in this buffer, because
423
+ # ``Session.apply`` buffers every live frame while a fill runs,
424
+ # before the late-window claim can see it, so this branch is the
425
+ # only thing left to refuse it.
426
+ cursor = _view_cursor_of(frame)
427
+ if cursor is not None and (
428
+ cursor in served or cursor in self._served_twins
429
+ ):
430
+ # Consume the persistent entry too, so a twin refused here is
431
+ # not refused a second time if the wire also delivers it later.
432
+ self._served_twins.discard(cursor)
433
+ continue
434
+ self._options.apply(frame, sink)
435
+ if not sink.tasks:
436
+ return ResolvedIo(tuple(sink.retirements))
437
+
438
+ async def run() -> tuple[PendingRetirement[_I], ...]:
439
+ batches = await asyncio.gather(*sink.tasks)
440
+ out: List[PendingRetirement[_I]] = list(sink.retirements)
441
+ for batch in batches:
442
+ out.extend(batch)
443
+ return tuple(out)
444
+
445
+ return asyncio.ensure_future(run())
446
+
447
+ def _report_failure(self, error: BaseException) -> None:
448
+ if isinstance(error, MuseGapFillError):
449
+ self._report(error)
450
+ return
451
+ # Anything the round trip itself raised — an ``MspError`` the host
452
+ # authored, a ``ProtocolError``, a dead transport — is the same fact
453
+ # from a consumer's point of view: the hole is still there. The cause
454
+ # is carried rather than flattened so the repair stays diagnosable.
455
+ gap = self._options.fold.pending_gap
456
+ reason: GapFillFailureReason = "pageFailed"
457
+ self._report(
458
+ MuseGapFillError(
459
+ reason,
460
+ gap["after"] if gap is not None else "",
461
+ gap["next"] if gap is not None else "",
462
+ error,
463
+ )
464
+ )
465
+
466
+ def _report(self, failure: MuseGapFillError) -> None:
467
+ handler = self._on_failure
468
+ if handler is None:
469
+ return
470
+ try:
471
+ handler(failure)
472
+ except Exception: # noqa: BLE001, S110
473
+ # The consumer's failure OBSERVER raised. Swallowed for the reason
474
+ # ``ApprovalRouter._report`` states: this rides
475
+ # ``SessionApplyOutcome.io``, whose contract is "IT NEVER
476
+ # REJECTS", and most consumers never await it.
477
+ pass
478
+
479
+
480
+ def _view_cursor_of(frame: WireFrame) -> str | None:
481
+ params = frame.get("params")
482
+ cursor = params.get("viewCursor") if isinstance(params, dict) else None
483
+ return cursor if isinstance(cursor, str) else None