launchhelm 0.1.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.
launchhelm/runtime.py ADDED
@@ -0,0 +1,1348 @@
1
+ """Cooperative LaunchHelm runtime: telemetry spool plus safe-point commands.
2
+
3
+ Contract:
4
+ * ``log`` admits a point into a bounded in-memory queue (volatile).
5
+ * ``flush(delivered=False)`` waits until the writer has persisted the
6
+ queue to SQLite (local durability).
7
+ * ``flush()`` waits until the uploader has a query-visible receipt.
8
+ * ``close`` persists the local queue but does not promise server delivery.
9
+ * Memory and spool limits are serialized-payload budgets.
10
+ * Command tombstones use a separate journal budget; exhaustion fails
11
+ closed via ``BufferFull``.
12
+ * Unknown effects are never automatically re-executed.
13
+
14
+ Threading:
15
+ Shared mutable state lives behind ``self._lock`` (an ``RLock``).
16
+ ``self._space`` is a ``Condition`` on that same lock and is used for
17
+ memory-budget wait/notify. ``StateStore`` has its own ``_mutex``.
18
+ Lock ordering: never acquire ``Runtime._lock`` while holding
19
+ ``StateStore._mutex``. ``log`` holds ``Runtime._lock`` only for queue
20
+ admission and never calls the store. Background threads call the store
21
+ without the runtime lock.
22
+
23
+ Protected by ``Runtime._lock``: ``_session`` (epoch / state revision),
24
+ ``_fatal``, ``_accepting``, ``_closed``, ``_memory_used``, ``_handlers``,
25
+ ``_active``, ``_safe_points``, ``_safe_points_dirty``, ``dropped``,
26
+ ``last_transport_error``.
27
+ ``queue.Queue`` instances are themselves thread-safe.
28
+ ``httpx.Client`` request methods are thread-safe; the runtime issues
29
+ them from the caller, control, and uploader threads.
30
+
31
+ Failure: ``log`` / ``flush`` / ``safe_point`` raise the first fatal
32
+ background error, preferring ``RuntimeFenced`` if a fence is observed.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import copy
38
+ import queue
39
+ import re
40
+ import threading
41
+ import time
42
+ import uuid
43
+ import warnings
44
+ from collections.abc import Callable, Sequence
45
+ from pathlib import Path
46
+ from typing import TYPE_CHECKING, Literal, NoReturn
47
+
48
+ from jsonschema import Draft202012Validator, FormatChecker
49
+ from referencing import Registry
50
+ from referencing.exceptions import NoSuchResource
51
+
52
+ from .client import RuntimeClient
53
+ from .errors import BufferFull, LaunchHelmError, RuntimeFenced
54
+ from .models import Capability, CommandContext, Document, Reference, Scope, timestamp
55
+ from .store import OversizePoint, StateStore, TelemetryBatch
56
+ from .values import (
57
+ JSONObject,
58
+ JSONSchema,
59
+ JSONValue,
60
+ encode,
61
+ fingerprint,
62
+ json_object,
63
+ string_object,
64
+ )
65
+
66
+ if TYPE_CHECKING:
67
+ from .rich import PendingBlob
68
+
69
+ OverflowPolicy = Literal["drop_new", "block", "raise"]
70
+ CapabilityHandler = Callable[[JSONValue, CommandContext], JSONValue]
71
+ CapabilityValidator = Callable[[JSONValue], None]
72
+
73
+ MIN_MEMORY_BYTES = 1 << 20
74
+ MAX_VOLATILE_POINT_BYTES = (1 << 19) - 128
75
+ HEARTBEAT_INTERVAL_SECONDS = 15.0
76
+ CONTROL_POLL_SECONDS = 0.25
77
+ CLAIM_WAIT_MS = 19_000
78
+ WRITER_POLL_SECONDS = 0.1
79
+ WRITER_BATCH_MAX = 512
80
+ WRITER_BATCH_BYTES = 1 << 20
81
+ UPLOAD_IDLE_SECONDS = 0.1
82
+ UPLOAD_MAX_BACKOFF_SECONDS = 10.0
83
+ FLUSH_POLL_SECONDS = 0.02
84
+ FENCE_WAIT_SECONDS = 1.0
85
+ DEFAULT_FLUSH_TIMEOUT_SECONDS = 30.0
86
+ DEFAULT_CLOSE_TIMEOUT_SECONDS = 35.0
87
+ CONTROL_MAX_BACKOFF_SECONDS = 10.0
88
+ MAX_SAFE_POINTS = 64
89
+ _SAFE_POINT_NAME = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
90
+ _DECIMAL = re.compile(r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?$")
91
+
92
+ _COORDINATES: JSONSchema = {
93
+ "type": "array",
94
+ "minItems": 1,
95
+ "maxItems": 32,
96
+ "items": {
97
+ "type": "object",
98
+ "required": ["axis", "value"],
99
+ "properties": {
100
+ "axis": {"type": "string", "pattern": "^[a-z][a-z0-9_.:-]{0,127}$"},
101
+ "value": {
102
+ "type": "string",
103
+ "maxLength": 128,
104
+ "pattern": r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?$",
105
+ },
106
+ "unit": {"type": "string", "maxLength": 64},
107
+ },
108
+ "additionalProperties": False,
109
+ },
110
+ }
111
+
112
+
113
+ def _no_remote(uri: str) -> NoReturn:
114
+ # referencing.NoSuchResource is an attrs frozen exception; the runtime
115
+ # stubs do not expose the ``ref`` keyword even though the library API does.
116
+ raise NoSuchResource(ref=uri) # type: ignore[call-arg]
117
+
118
+
119
+ def _validate(schema: JSONSchema, value: object) -> None:
120
+ # Registry stores retrieve= as a private attrs field ``_retrieve``.
121
+ registry = Registry(retrieve=_no_remote) # type: ignore[call-arg]
122
+ Draft202012Validator(schema, registry=registry, format_checker=FormatChecker()).validate(value)
123
+
124
+
125
+ def _require_semantic(schema: JSONValue, what: str) -> None:
126
+ root = schema.get("root") if isinstance(schema, dict) else None
127
+ if not isinstance(root, dict) or not isinstance(root.get("kind"), str):
128
+ raise ValueError(f"{what} must be a semantic schema")
129
+ structure = root.get("structure")
130
+ if isinstance(structure, dict):
131
+ Draft202012Validator.check_schema(structure)
132
+
133
+
134
+ def _validate_semantic(schema: JSONValue, value: object) -> None:
135
+ root = schema.get("root") if isinstance(schema, dict) else None
136
+ if not isinstance(root, dict):
137
+ raise ValueError("input schema must be a semantic schema")
138
+ _check_semantic(root, value)
139
+
140
+
141
+ def _check_semantic(node: JSONObject, value: object) -> None:
142
+ kind = node.get("kind")
143
+ if kind == "opaque":
144
+ return
145
+ if kind == "scalar":
146
+ _check_scalar(node, value)
147
+ return
148
+ if kind == "record":
149
+ if not isinstance(value, dict):
150
+ raise ValueError("record value must be an object")
151
+ fields = node.get("fields") or []
152
+ if not isinstance(fields, list):
153
+ raise ValueError("record fields must be a list")
154
+ names: set[str] = set()
155
+ for raw in fields:
156
+ if not isinstance(raw, dict):
157
+ raise ValueError("record field is invalid")
158
+ name = raw.get("name")
159
+ if not isinstance(name, str):
160
+ raise ValueError("record field is invalid")
161
+ names.add(name)
162
+ child = raw.get("type")
163
+ if name not in value:
164
+ if raw.get("optional") is True:
165
+ continue
166
+ raise ValueError(f"field {name} is required")
167
+ if not isinstance(child, dict):
168
+ raise ValueError(f"field {name} has no type")
169
+ _check_semantic(child, value[name])
170
+ for key in value:
171
+ if key not in names:
172
+ raise ValueError(f"field {key} is not in the type")
173
+ return
174
+ if kind == "list":
175
+ if not isinstance(value, list):
176
+ raise ValueError("list value must be an array")
177
+ item = node.get("item")
178
+ if not isinstance(item, dict):
179
+ raise ValueError("list requires an item type")
180
+ for entry in value:
181
+ _check_semantic(item, entry)
182
+ return
183
+ if kind == "enum":
184
+ allowed = node.get("values")
185
+ if not isinstance(allowed, list) or any(not isinstance(item, str) for item in allowed):
186
+ raise ValueError("enum type requires string values")
187
+ if not isinstance(value, str) or value not in allowed:
188
+ raise ValueError("value is not in the enum")
189
+ return
190
+ if kind == "schedule":
191
+ points = value.get("points") if isinstance(value, dict) else None
192
+ if not isinstance(points, list):
193
+ raise ValueError("schedule value must contain points")
194
+ for point in points:
195
+ if not isinstance(point, dict):
196
+ raise ValueError("schedule point must be an object")
197
+ at = point.get("at")
198
+ raw = point.get("value")
199
+ if not isinstance(at, str) or not _DECIMAL.fullmatch(at):
200
+ raise ValueError("point axis must be a decimal")
201
+ if not isinstance(raw, str) or not _DECIMAL.fullmatch(raw):
202
+ raise ValueError("point value must be a decimal")
203
+ return
204
+ raise ValueError(f"unsupported semantic kind {kind!r}")
205
+
206
+
207
+ def _check_scalar(node: JSONObject, value: object) -> None:
208
+ scalar_kind = node.get("scalar")
209
+ if scalar_kind == "number" and isinstance(value, int | float) and not isinstance(value, bool):
210
+ _check_bounds(node, value)
211
+ return
212
+ if scalar_kind == "integer" and isinstance(value, int) and not isinstance(value, bool):
213
+ _check_bounds(node, value)
214
+ return
215
+ if scalar_kind == "string" and isinstance(value, str):
216
+ return
217
+ if scalar_kind == "boolean" and isinstance(value, bool):
218
+ return
219
+ raise ValueError("value does not match the scalar type")
220
+
221
+
222
+ def _check_bounds(node: JSONObject, value: int | float) -> None:
223
+ bounds = node.get("bounds")
224
+ if not isinstance(bounds, dict):
225
+ return
226
+ if "min" in bounds and float(value) < float(str(bounds["min"])):
227
+ raise ValueError("value is below the minimum")
228
+ if "max" in bounds and float(value) > float(str(bounds["max"])):
229
+ raise ValueError("value is above the maximum")
230
+
231
+
232
+ class Runtime:
233
+ """Cooperative participant runtime.
234
+
235
+ Contract: see the module docstring for ``log`` / ``flush`` / ``close``
236
+ semantics, budgets, and the command journal. Construct with an explicit
237
+ overflow policy and positive bounded queue budgets.
238
+
239
+ Failure: ``ValueError`` if budgets or overflow are invalid, or if the
240
+ participant resource is the wrong kind. Background fatals surface on
241
+ the next ``log`` / ``flush`` / ``safe_point``.
242
+ """
243
+
244
+ def __init__(
245
+ self,
246
+ client: RuntimeClient,
247
+ participant_id: str,
248
+ *,
249
+ state_dir: str | Path,
250
+ overflow: OverflowPolicy,
251
+ max_spool_bytes: int = 64 << 20,
252
+ media_spool_bytes: int = 256 << 20,
253
+ memory_records: int = 1024,
254
+ max_memory_bytes: int = 8 << 20,
255
+ safe_points: tuple[str, ...] = (),
256
+ attempt_id: str = "",
257
+ serve_commands: bool = True,
258
+ ) -> None:
259
+ if (
260
+ overflow not in {"drop_new", "block", "raise"}
261
+ or memory_records < 1
262
+ or max_memory_bytes < MIN_MEMORY_BYTES
263
+ or media_spool_bytes < 1
264
+ ):
265
+ raise ValueError("explicit overflow policy and positive bounded queue budgets required")
266
+ self.client = client
267
+ self.last_result: JSONValue | None = None
268
+ self.participant = client.get_document(participant_id)
269
+ if self.participant.kind != "participant":
270
+ raise ValueError("runtime requires a participant")
271
+ self.scope = self.participant.scope
272
+ self.overflow = overflow
273
+ self.store = StateStore(
274
+ state_dir,
275
+ f"{client.base_url}|{client.organization_id}|{participant_id}",
276
+ max_spool_bytes,
277
+ )
278
+ self._queue: queue.Queue[tuple[str, str, str, bytes]] = queue.Queue(memory_records)
279
+ self._commands: queue.Queue[JSONObject] = queue.Queue(1)
280
+ self._handlers: dict[
281
+ str, tuple[Capability, CapabilityHandler, CapabilityValidator | None, str]
282
+ ] = {}
283
+ self._stop = threading.Event()
284
+ self._lock = threading.RLock()
285
+ self._space = threading.Condition(self._lock)
286
+ self._media_space = threading.Condition(self._lock)
287
+ self._media_wake = threading.Event()
288
+ self._media_max = media_spool_bytes
289
+ self._memory_used = 0
290
+ self._max_memory_bytes = max_memory_bytes
291
+ self._active: JSONObject | None = None
292
+ self._fatal: BaseException | None = None
293
+ self._accepting = True
294
+ self._closed = False
295
+ self._ended = False
296
+ self._session: JSONObject = {}
297
+ self._instance_id = str(uuid.uuid4())
298
+ self._safe_points: list[str] = []
299
+ self._safe_points_dirty = False
300
+ self.dropped = 0
301
+ self.last_transport_error: str | None = None
302
+ self._envelope_supported = True
303
+ self._serve_commands = serve_commands
304
+ try:
305
+ for name in safe_points:
306
+ self._add_safe_point(name)
307
+ self._session = dict(
308
+ client.connect(
309
+ participant_id,
310
+ self._instance_id,
311
+ safe_points=tuple(self._safe_points),
312
+ attempt_id=attempt_id,
313
+ )
314
+ )
315
+ self._safe_points_dirty = False
316
+ self._recover()
317
+ except BaseException:
318
+ self.store.close()
319
+ raise
320
+ self._threads = [
321
+ threading.Thread(target=target, daemon=True, name=name)
322
+ for target, name in [
323
+ (self._writer, "launchhelm-spool"),
324
+ (self._uploader, "launchhelm-upload"),
325
+ (self._media_worker, "launchhelm-media"),
326
+ (self._control, "launchhelm-control"),
327
+ ]
328
+ ]
329
+ for thread in self._threads:
330
+ thread.start()
331
+
332
+ @property
333
+ def session(self) -> JSONObject:
334
+ """Copy of the current execution session (epoch and state revision).
335
+
336
+ Contract: the copy is consistent with the last connect/heartbeat
337
+ installed under the runtime lock.
338
+
339
+ Failure: none.
340
+ """
341
+ with self._lock:
342
+ return dict(self._session)
343
+
344
+ def __enter__(self) -> Runtime:
345
+ """Return ``self`` for ``with Runtime(...)`` usage.
346
+
347
+ Contract: ``__exit__`` calls ``close``.
348
+
349
+ Failure: none.
350
+ """
351
+ return self
352
+
353
+ def __exit__(self, *args: object) -> None:
354
+ """Close the runtime, ignoring the block exception.
355
+
356
+ Contract: does not suppress exceptions from the ``with`` body.
357
+
358
+ Failure: propagates ``close`` errors.
359
+ """
360
+ self.close()
361
+
362
+ def register(
363
+ self,
364
+ capability: Capability,
365
+ handler: CapabilityHandler,
366
+ *,
367
+ validate: CapabilityValidator | None = None,
368
+ ) -> Document:
369
+ """Publish ``capability`` and bind ``handler`` to its resource id.
370
+
371
+ Contract: schemas are checked locally before the server create. The
372
+ handler runs only at a matching ``safe_point``, never automatically
373
+ on restart.
374
+
375
+ Failure: ``jsonschema.SchemaError`` for an invalid schema.
376
+ ``LaunchHelmError`` from the server.
377
+ """
378
+ capability = copy.deepcopy(capability)
379
+ _require_semantic(capability.input_schema, "input_schema")
380
+ _require_semantic(capability.output_schema, "output_schema")
381
+ _require_semantic(capability.presentation, "presentation")
382
+ document = self.client.create_document(
383
+ "capability", self.scope, capability.wire(self.participant.id)
384
+ )
385
+ added = False
386
+ with self._lock:
387
+ self._serve_commands = True
388
+ self._handlers[document.id] = (
389
+ capability,
390
+ handler,
391
+ validate,
392
+ document.revision,
393
+ )
394
+ for name in capability.safe_points:
395
+ added = self._add_safe_point(name) or added
396
+ if added:
397
+ self._advertise_safe_points()
398
+ return document
399
+
400
+ def stream(
401
+ self,
402
+ name: str,
403
+ *,
404
+ value_schema: JSONSchema,
405
+ axes: tuple[str, ...] = ("step",),
406
+ unit: str = "",
407
+ ) -> Document:
408
+ """Create a telemetry stream owned by this participant.
409
+
410
+ Contract: ``value_schema`` is checked locally. Points are admitted
411
+ with ``log``.
412
+
413
+ Failure: ``jsonschema.SchemaError`` for an invalid schema.
414
+ ``LaunchHelmError`` from the server.
415
+ """
416
+ Draft202012Validator.check_schema(value_schema)
417
+ spec: JSONObject = {
418
+ "name": name,
419
+ "participant_id": self.participant.id,
420
+ "value_schema": value_schema,
421
+ "axes": list(axes),
422
+ "labels": {},
423
+ }
424
+ if unit:
425
+ spec["unit"] = unit
426
+ return self.client.create_document("stream", self.scope, spec)
427
+
428
+ def log(
429
+ self,
430
+ stream: Document,
431
+ value: JSONValue,
432
+ *,
433
+ coordinates: list[dict[str, str]],
434
+ observed_at: str | None = None,
435
+ timeout: float | None = None,
436
+ ) -> bool:
437
+ """Admit one telemetry point into the volatile memory queue.
438
+
439
+ Contract: success means the point is in process memory, not durable
440
+ and not query-visible. ``True`` means accepted; ``False`` means
441
+ dropped under ``overflow="drop_new"``. The session epoch is
442
+ snapshotted under the runtime lock at enqueue so a fence cannot
443
+ attach a stale epoch. The point is canonicalized exactly once here
444
+ (``encode``); later stages store and concatenate those bytes.
445
+
446
+ Failure:
447
+ ValueError: stream identity/scope mismatch, or the encoded point
448
+ is too large.
449
+ RuntimeError: the runtime is closed.
450
+ BufferFull: memory budget exhausted with overflow ``raise``, or
451
+ ``block`` timed out.
452
+ RuntimeFenced / LaunchHelmError / other fatal: a background
453
+ thread has failed closed.
454
+ """
455
+ if (
456
+ stream.kind != "stream"
457
+ or stream.spec.get("participant_id") != self.participant.id
458
+ or stream.scope != self.scope
459
+ ):
460
+ raise ValueError("stream belongs to another participant or scope")
461
+ coords: list[JSONValue] = [string_object(item) for item in coordinates]
462
+ payload: JSONObject = {
463
+ "observed_at": observed_at or timestamp(),
464
+ "coordinates": coords,
465
+ "value": value,
466
+ }
467
+ raw = encode(payload)
468
+ if len(raw) > MAX_VOLATILE_POINT_BYTES:
469
+ raise ValueError("point is too large; use an artifact")
470
+ deadline = time.monotonic() + timeout if timeout is not None else None
471
+ with self._space:
472
+ while True:
473
+ if not self._accepting:
474
+ raise RuntimeError("runtime is closed")
475
+ if self._fatal is not None:
476
+ raise self._fatal
477
+ epoch = str(self._session["epoch"])
478
+ if (
479
+ not self._queue.full()
480
+ and self._memory_used + len(raw) <= self._max_memory_bytes
481
+ ):
482
+ self._queue.put_nowait((stream.id, stream.revision, epoch, raw))
483
+ self._memory_used += len(raw)
484
+ return True
485
+ if self.overflow == "drop_new":
486
+ self._dropped()
487
+ return False
488
+ if self.overflow == "raise":
489
+ raise BufferFull()
490
+ remaining = None if deadline is None else deadline - time.monotonic()
491
+ if remaining is not None and remaining <= 0:
492
+ raise BufferFull()
493
+ self._space.wait(remaining)
494
+
495
+ def log_media(
496
+ self,
497
+ stream: Document,
498
+ value: JSONValue,
499
+ *,
500
+ coordinates: list[dict[str, str]],
501
+ blobs: Sequence[PendingBlob],
502
+ timeout: float | None = None,
503
+ ) -> bool:
504
+ """Admit a point whose media items are still local blobs.
505
+
506
+ Contract: each ``PendingBlob`` in ``blobs`` is written
507
+ content-addressed into the state directory and the encoded point
508
+ is recorded in the media spool — durable locally before this
509
+ returns, so ``flush(delivered=False)`` has nothing further to do.
510
+ The background media worker uploads each blob as a ``media``
511
+ artifact (idempotent on run and content hash) and admits the
512
+ point only after every reference resolves. Pending media bytes
513
+ are bounded by ``media_spool_bytes`` under the runtime overflow
514
+ policy: ``drop_new`` returns ``False``, ``raise`` raises
515
+ ``BufferFull``, ``block`` waits. ``True`` means accepted.
516
+
517
+ Failure: same as ``log`` plus ``BufferFull`` for the media budget
518
+ under ``raise``, or ``block`` timing out.
519
+ """
520
+ if (
521
+ stream.kind != "stream"
522
+ or stream.spec.get("participant_id") != self.participant.id
523
+ or stream.scope != self.scope
524
+ ):
525
+ raise ValueError("stream belongs to another participant or scope")
526
+ coords: list[JSONValue] = [string_object(item) for item in coordinates]
527
+ payload: JSONObject = {
528
+ "observed_at": timestamp(),
529
+ "coordinates": coords,
530
+ "value": value,
531
+ }
532
+ raw = encode(payload)
533
+ if len(raw) > MAX_VOLATILE_POINT_BYTES:
534
+ raise ValueError("point is too large; use an artifact")
535
+ sized: dict[str, tuple[str, bytes]] = {}
536
+ for blob in blobs:
537
+ hash = str(blob.hash)
538
+ media_type = str(blob.media_type)
539
+ data = bytes(blob.data)
540
+ sized[hash] = (media_type, data)
541
+ deadline = time.monotonic() + timeout if timeout is not None else None
542
+ with self._media_space:
543
+ while True:
544
+ if not self._accepting:
545
+ raise RuntimeError("runtime is closed")
546
+ if self._fatal is not None:
547
+ raise self._fatal
548
+ epoch = str(self._session["epoch"])
549
+ added = self.store.media_new_bytes(
550
+ [(hash, len(data)) for hash, (_mt, data) in sized.items()]
551
+ )
552
+ if self.store.media_used() + added + len(raw) <= self._media_max:
553
+ break
554
+ if self.overflow == "drop_new":
555
+ self._dropped()
556
+ return False
557
+ if self.overflow == "raise":
558
+ raise BufferFull()
559
+ remaining = None if deadline is None else deadline - time.monotonic()
560
+ if remaining is not None and remaining <= 0:
561
+ raise BufferFull()
562
+ self._media_space.wait(remaining)
563
+ self.store.enqueue_media(
564
+ stream.id,
565
+ stream.revision,
566
+ epoch,
567
+ raw,
568
+ [(hash, media_type, data) for hash, (media_type, data) in sized.items()],
569
+ )
570
+ self._media_wake.set()
571
+ return True
572
+
573
+ def _dropped(self) -> None:
574
+ with self._lock:
575
+ self.dropped += 1
576
+ count = self.dropped
577
+ if count == 1 or count % 100 == 0:
578
+ warnings.warn(
579
+ f"LaunchHelm telemetry overflow: {count} volatile records dropped",
580
+ RuntimeWarning,
581
+ stacklevel=2,
582
+ )
583
+
584
+ def _set_fatal(self, error: BaseException) -> None:
585
+ with self._space:
586
+ if isinstance(self._fatal, RuntimeFenced):
587
+ self._space.notify_all()
588
+ return
589
+ if self._fatal is None or isinstance(error, RuntimeFenced):
590
+ self._fatal = error
591
+ self._space.notify_all()
592
+
593
+ def _set_transport_error(self, code: str | None) -> None:
594
+ with self._lock:
595
+ self.last_transport_error = code
596
+
597
+ def _raise_if_fatal(self) -> None:
598
+ with self._lock:
599
+ if self._fatal is not None:
600
+ raise self._fatal
601
+
602
+ def _add_safe_point(self, name: str) -> bool:
603
+ if not _SAFE_POINT_NAME.fullmatch(name):
604
+ raise ValueError("invalid safe point name")
605
+ with self._lock:
606
+ if name in self._safe_points:
607
+ return False
608
+ if len(self._safe_points) >= MAX_SAFE_POINTS:
609
+ raise ValueError("at most 64 safe points may be declared")
610
+ self._safe_points.append(name)
611
+ self._safe_points_dirty = True
612
+ return True
613
+
614
+ def _advertise_safe_points(self) -> None:
615
+ # The control loop cannot heartbeat while its claim is parked, so a
616
+ # newly declared point would stay invisible to the server until the
617
+ # wait elapsed. Advertise it now; a claim already parked re-reads the
618
+ # session each poll slice and claims the command within that slice.
619
+ with self._lock:
620
+ if self._ended or isinstance(self._fatal, RuntimeFenced):
621
+ return
622
+ points = tuple(self._safe_points)
623
+ epoch = str(self._session["epoch"])
624
+ try:
625
+ session = self.client.heartbeat(self.participant.id, epoch, safe_points=points)
626
+ except LaunchHelmError as error:
627
+ self._set_transport_error(error.code)
628
+ self._heartbeat_fenced(error)
629
+ return
630
+ with self._lock:
631
+ if not isinstance(self._fatal, RuntimeFenced):
632
+ self._session = dict(session)
633
+ self._safe_points_dirty = False
634
+
635
+ def _writer(self) -> None:
636
+ while not self._stop.is_set() or not self._queue.empty():
637
+ try:
638
+ first = self._queue.get(timeout=WRITER_POLL_SECONDS)
639
+ except queue.Empty:
640
+ continue
641
+ batch = [first]
642
+ size = len(first[3])
643
+ while len(batch) < WRITER_BATCH_MAX and size < WRITER_BATCH_BYTES:
644
+ try:
645
+ item = self._queue.get_nowait()
646
+ except queue.Empty:
647
+ break
648
+ batch.append(item)
649
+ size += len(item[3])
650
+ self._persist(batch)
651
+
652
+ def _persist(self, batch: list[tuple[str, str, str, bytes]]) -> None:
653
+ """Persist a drained batch, applying the per-point overflow contract.
654
+
655
+ ``enqueue_many`` admits a prefix inside one transaction. The point
656
+ after it is handled exactly as the serial writer handled a
657
+ ``BufferFull`` for that point: ``drop_new`` drops it and moves on;
658
+ ``raise`` records a fatal and keeps retrying; ``block`` retries
659
+ until the spool has room; on stop the point is dropped after a
660
+ fatal is set. A point that fails the per-point size cap records a
661
+ fatal and is dropped; the rest of the batch still persists.
662
+ """
663
+ pending = batch
664
+ while pending:
665
+ try:
666
+ admitted = self.store.enqueue_many(pending)
667
+ except OversizePoint as error:
668
+ self._release(pending[: error.index])
669
+ self._set_fatal(error)
670
+ self._dropped()
671
+ self._release(pending[error.index : error.index + 1])
672
+ pending = pending[error.index + 1 :]
673
+ continue
674
+ except Exception as error:
675
+ self._set_fatal(error)
676
+ self._dropped()
677
+ self._release(pending[:1])
678
+ pending = pending[1:]
679
+ continue
680
+ self._release(pending[:admitted])
681
+ pending = pending[admitted:]
682
+ if not pending:
683
+ return
684
+ if self.overflow == "drop_new":
685
+ self._dropped()
686
+ self._release(pending[:1])
687
+ pending = pending[1:]
688
+ continue
689
+ if self.overflow == "raise":
690
+ self._set_fatal(BufferFull())
691
+ if self._stop.is_set():
692
+ self._set_fatal(BufferFull())
693
+ self._dropped()
694
+ self._release(pending[:1])
695
+ pending = pending[1:]
696
+ continue
697
+ self._stop.wait(WRITER_POLL_SECONDS)
698
+
699
+ def _release(self, items: Sequence[tuple[str, str, str, bytes]]) -> None:
700
+ """Account for dequeued points: ``task_done`` and memory release.
701
+
702
+ Contract: every item the writer dequeued is released exactly once,
703
+ after it is persisted or dropped, so ``flush(delivered=False)``
704
+ keeps its local-durability contract.
705
+ """
706
+ freed = 0
707
+ for _stream, _revision, _epoch, raw in items:
708
+ self._queue.task_done()
709
+ freed += len(raw)
710
+ with self._space:
711
+ self._memory_used -= freed
712
+ self._space.notify_all()
713
+
714
+ def _uploader(self) -> None:
715
+ delay = UPLOAD_IDLE_SECONDS
716
+ while not self._stop.is_set():
717
+ try:
718
+ if self._upload_grouped():
719
+ delay = UPLOAD_IDLE_SECONDS
720
+ continue
721
+ batch = self.store.batch(self.participant.id)
722
+ if batch is None:
723
+ self._stop.wait(UPLOAD_IDLE_SECONDS)
724
+ continue
725
+ receipt = self.client.ingest(batch)
726
+ if (
727
+ receipt.get("batch_id") != batch.batch_id
728
+ or receipt.get("durability") != "query_visible"
729
+ ):
730
+ raise LaunchHelmError(
731
+ "invalid_receipt", "Server did not confirm batch durability"
732
+ )
733
+ self.store.acknowledge(batch.batch_id)
734
+ self._set_transport_error(None)
735
+ delay = UPLOAD_IDLE_SECONDS
736
+ except LaunchHelmError as error:
737
+ self._set_transport_error(error.code)
738
+ if not error.retryable:
739
+ self._set_fatal(error)
740
+ self._stop.wait(delay)
741
+ delay = min(delay * 2, UPLOAD_MAX_BACKOFF_SECONDS)
742
+ except Exception as error:
743
+ self._set_fatal(error)
744
+ self._stop.wait(1)
745
+
746
+ def _media_worker(self) -> None:
747
+ """Upload pending media blobs and release their points.
748
+
749
+ Each loop claims one un-uploaded blob; uploads are idempotent on
750
+ ``media:<run>:<hash>`` so a crash mid-upload resumes to the same
751
+ artifact, and ``resolve_media`` admits every point whose last
752
+ reference resolved. Resolved-but-budget-blocked points are
753
+ retried through ``flush_ready_media`` each pass.
754
+ """
755
+ delay = UPLOAD_IDLE_SECONDS
756
+ while not self._stop.is_set():
757
+ try:
758
+ if self._media_once():
759
+ delay = UPLOAD_IDLE_SECONDS
760
+ continue
761
+ self._media_wake.wait(UPLOAD_IDLE_SECONDS)
762
+ self._media_wake.clear()
763
+ except LaunchHelmError as error:
764
+ self._set_transport_error(error.code)
765
+ if not error.retryable:
766
+ self._set_fatal(error)
767
+ self._stop.wait(delay)
768
+ delay = min(delay * 2, UPLOAD_MAX_BACKOFF_SECONDS)
769
+ except Exception as error:
770
+ self._set_fatal(error)
771
+ self._stop.wait(1)
772
+
773
+ def _media_once(self) -> bool:
774
+ """Make one unit of media progress; True when any work happened."""
775
+ progressed = False
776
+ admitted, freed = self.store.flush_ready_media()
777
+ if admitted > 0:
778
+ progressed = True
779
+ for hash in freed:
780
+ self.store.release_media_blob_file(hash)
781
+ pending = self.store.next_pending_media()
782
+ if pending is None:
783
+ return progressed
784
+ hash, media_type = pending
785
+ upload = getattr(self.client, "upload_file", None)
786
+ if not callable(upload):
787
+ raise LaunchHelmError("protocol", "client cannot upload media")
788
+ document = upload(
789
+ self.store.media_blob_path(hash),
790
+ self.scope,
791
+ idempotency_key=f"media:{self.scope.run_id}:{hash}",
792
+ media_type=media_type,
793
+ purpose="media",
794
+ )
795
+ if not isinstance(document, Document):
796
+ raise LaunchHelmError("protocol", "media upload did not return an artifact document")
797
+ for blob_hash in self.store.resolve_media(hash, document.id):
798
+ self.store.release_media_blob_file(blob_hash)
799
+ with self._media_space:
800
+ self._media_space.notify_all()
801
+ return True
802
+
803
+ def _upload_grouped(self) -> bool:
804
+ """Upload pending batches through the grouped endpoint.
805
+
806
+ Returns True when the envelope path handled this tick (including a
807
+ discovered fallback to single-batch ingest). Returns False when the
808
+ caller should use the single-batch endpoint: the client lacks
809
+ ``ingest_many`` or an earlier probe found the endpoint missing.
810
+ """
811
+ if not self._envelope_supported:
812
+ return False
813
+ ingest_many = getattr(self.client, "ingest_many", None)
814
+ if not callable(ingest_many):
815
+ self._envelope_supported = False
816
+ return False
817
+ batches = self.store.batches(self.participant.id)
818
+ if not batches:
819
+ self._stop.wait(UPLOAD_IDLE_SECONDS)
820
+ return True
821
+ try:
822
+ response = ingest_many(batches)
823
+ except LaunchHelmError as error:
824
+ if error.code != "not_found" or error.status != 404:
825
+ raise
826
+ # The endpoint may not exist on this server. Distinguish a
827
+ # missing route from a missing organization by sending the first
828
+ # batch through the original endpoint: if it also misses, the
829
+ # envelope failure was not about routing.
830
+ single = self.client.ingest(batches[0])
831
+ if single.get("batch_id") != batches[0].batch_id:
832
+ raise error from None
833
+ if single.get("durability") != "query_visible":
834
+ raise LaunchHelmError(
835
+ "invalid_receipt", "Server did not confirm batch durability"
836
+ ) from None
837
+ self.store.acknowledge(batches[0].batch_id)
838
+ self._envelope_supported = False
839
+ return True
840
+ self._reconcile_receipts(batches, response)
841
+ self._set_transport_error(None)
842
+ return True
843
+
844
+ def _reconcile_receipts(self, batches: list[TelemetryBatch], response: JSONObject) -> None:
845
+ """Apply per-batch outcomes from a grouped ingest response.
846
+
847
+ Contract: every receipt must name a submitted ``batch_id`` and every
848
+ submitted batch must be acknowledged, reported duplicate, or
849
+ rejected. Violations are protocol errors. Accepted and duplicate
850
+ batches leave the spool; rejected batches stay spooled so a fatal
851
+ stop preserves them for inspection, and a retryable rejection is
852
+ re-sent with the next envelope.
853
+ """
854
+ receipts = response.get("receipts")
855
+ if not isinstance(receipts, list) or len(receipts) != len(batches):
856
+ raise LaunchHelmError("invalid_receipt", "Server did not return one receipt per batch")
857
+ pending = {batch.batch_id: batch for batch in batches}
858
+ first_rejected: LaunchHelmError | None = None
859
+ for entry in receipts:
860
+ receipt = json_object(entry, what="ingestion receipt")
861
+ batch_id = receipt.get("batch_id")
862
+ batch = pending.pop(batch_id, None) if isinstance(batch_id, str) else None
863
+ if batch is None:
864
+ raise LaunchHelmError(
865
+ "invalid_receipt", "Server returned a receipt for an unknown batch"
866
+ )
867
+ status = receipt.get("status")
868
+ if status in ("accepted", "duplicate"):
869
+ if receipt.get("durability") != "query_visible":
870
+ raise LaunchHelmError(
871
+ "invalid_receipt", "Server did not confirm batch durability"
872
+ )
873
+ self.store.acknowledge(batch.batch_id)
874
+ elif status == "rejected":
875
+ if first_rejected is None:
876
+ code = receipt.get("code")
877
+ message = receipt.get("message")
878
+ first_rejected = LaunchHelmError(
879
+ code if isinstance(code, str) else "rejected",
880
+ message if isinstance(message, str) else "Server rejected the batch",
881
+ retryable=bool(receipt.get("retryable")),
882
+ )
883
+ else:
884
+ raise LaunchHelmError(
885
+ "invalid_receipt", "Server returned an unknown receipt status"
886
+ )
887
+ if pending:
888
+ raise LaunchHelmError(
889
+ "invalid_receipt", "Server omitted receipts for submitted batches"
890
+ )
891
+ if first_rejected is not None:
892
+ raise first_rejected
893
+
894
+ def _result(
895
+ self,
896
+ command: JSONObject,
897
+ outcome: str,
898
+ result: JSONValue = None,
899
+ coordinates: list[dict[str, str]] | None = None,
900
+ failure: str = "",
901
+ ) -> JSONObject:
902
+ applied: list[JSONValue] = []
903
+ for item in coordinates or []:
904
+ applied.append(string_object(item))
905
+ request = json_object(command.get("request") or {}, what="command request")
906
+ return {
907
+ "lease_id": command["lease_id"],
908
+ "epoch": request["epoch"],
909
+ "outcome": outcome,
910
+ "result": result,
911
+ "applied_at": applied,
912
+ "failure_code": failure,
913
+ }
914
+
915
+ def _publish_pending(self) -> None:
916
+ for command, phase, result in self.store.recoverable():
917
+ if phase != "completed" or result is None:
918
+ continue
919
+ try:
920
+ reported = self.client.report(command, result)
921
+ except LaunchHelmError as error:
922
+ if error.code != "conflict" or result["outcome"] != "rejected":
923
+ raise
924
+ reported = self.client.get_command(str(command["id"]))
925
+ if reported["execution"] not in {"rejected", "cancelled"}:
926
+ raise
927
+ self.store.journal_phase(str(command["id"]), "reported")
928
+ revision = reported.get("resulting_state_revision")
929
+ if revision:
930
+ with self._lock:
931
+ session = dict(self._session)
932
+ session["state_revision"] = revision
933
+ self._session = session
934
+ with self._lock:
935
+ active = self._active
936
+ if active and active["id"] == command["id"]:
937
+ self._active = None
938
+
939
+ def _recover(self) -> None:
940
+ for command, phase, result in self.store.recoverable():
941
+ if phase == "received":
942
+ current = self.client.get_command(str(command["id"]))
943
+ if current["execution"] in {
944
+ "rejected",
945
+ "cancelled",
946
+ "applied",
947
+ "failed",
948
+ }:
949
+ self.store.journal_phase(str(command["id"]), "reported")
950
+ continue
951
+ if result is None:
952
+ outcome = "rejected" if phase == "received" else "outcome_unknown"
953
+ self.store.journal_phase(
954
+ str(command["id"]),
955
+ "completed",
956
+ self._result(command, outcome, failure="runtime_restart"),
957
+ )
958
+ try:
959
+ self._publish_pending()
960
+ except LaunchHelmError:
961
+ self._set_transport_error("reconciliation_required")
962
+
963
+ def end_session(self, *, close_run: str = "never") -> JSONObject | None:
964
+ """End the participant's live session epoch on the server.
965
+
966
+ Contract: heartbeats and claims stop at once; a heartbeat or claim
967
+ already in flight when the end lands is expected to conflict and is
968
+ not reported as fencing. Returns the server response, or ``None``
969
+ when the client does not implement ``end_session`` — the session
970
+ then simply expires. The epoch sent is the last one the server
971
+ confirmed.
972
+
973
+ Failure: ``LaunchHelmError`` from the server, including ``conflict``
974
+ when the recorded epoch is no longer live.
975
+ """
976
+ with self._lock:
977
+ self._ended = True
978
+ epoch = str(self._session.get("epoch") or "")
979
+ end = getattr(self.client, "end_session", None)
980
+ if not callable(end) or not epoch:
981
+ return None
982
+ return json_object(
983
+ end(self.participant.id, epoch, close_run=close_run),
984
+ what="session end",
985
+ )
986
+
987
+ def _heartbeat_fenced(self, error: LaunchHelmError) -> None:
988
+ if error.code != "conflict":
989
+ return
990
+ with self._lock:
991
+ ended = self._ended
992
+ if not ended:
993
+ self._set_fatal(RuntimeFenced())
994
+
995
+ def _control(self) -> None:
996
+ heartbeat_at = 0.0
997
+ delay = CONTROL_POLL_SECONDS
998
+ while not self._stop.is_set():
999
+ parked = False
1000
+ try:
1001
+ self._publish_pending()
1002
+ with self._lock:
1003
+ if self._ended:
1004
+ return
1005
+ fenced = isinstance(self._fatal, RuntimeFenced)
1006
+ if fenced:
1007
+ self._stop.wait(FENCE_WAIT_SECONDS)
1008
+ continue
1009
+ heartbeated = False
1010
+ with self._lock:
1011
+ dirty = self._safe_points_dirty
1012
+ points = tuple(self._safe_points)
1013
+ epoch = str(self._session["epoch"])
1014
+ if dirty:
1015
+ try:
1016
+ session = self.client.heartbeat(
1017
+ self.participant.id, epoch, safe_points=points
1018
+ )
1019
+ except LaunchHelmError as error:
1020
+ self._heartbeat_fenced(error)
1021
+ raise
1022
+ with self._lock:
1023
+ if not isinstance(self._fatal, RuntimeFenced):
1024
+ self._session = dict(session)
1025
+ self._safe_points_dirty = False
1026
+ heartbeat_at = time.monotonic() + HEARTBEAT_INTERVAL_SECONDS
1027
+ heartbeated = True
1028
+ elif time.monotonic() >= heartbeat_at:
1029
+ try:
1030
+ session = self.client.heartbeat(self.participant.id, epoch)
1031
+ except LaunchHelmError as error:
1032
+ self._heartbeat_fenced(error)
1033
+ raise
1034
+ with self._lock:
1035
+ if not isinstance(self._fatal, RuntimeFenced):
1036
+ self._session = dict(session)
1037
+ heartbeat_at = time.monotonic() + HEARTBEAT_INTERVAL_SECONDS
1038
+ heartbeated = True
1039
+ if heartbeated:
1040
+ with self._lock:
1041
+ active = self._active
1042
+ if active:
1043
+ try:
1044
+ self.client.renew(active)
1045
+ except LaunchHelmError as error:
1046
+ if error.code != "conflict":
1047
+ raise
1048
+ self._set_transport_error("lease_reconciliation")
1049
+ with self._lock:
1050
+ available = (
1051
+ self._active is None and self._fatal is None and self._serve_commands
1052
+ )
1053
+ epoch = str(self._session["epoch"])
1054
+ if available and self._commands.empty():
1055
+ command = self.client.claim(self.participant.id, epoch, wait_ms=CLAIM_WAIT_MS)
1056
+ parked = True
1057
+ if command:
1058
+ phase = self.store.remember(command)
1059
+ if phase != "received":
1060
+ self._set_transport_error("reconciliation_required")
1061
+ else:
1062
+ with self._lock:
1063
+ self._active = command
1064
+ self._commands.put_nowait(command)
1065
+ delay = 0 if parked else CONTROL_POLL_SECONDS
1066
+ except LaunchHelmError as error:
1067
+ with self._lock:
1068
+ ended = self._ended
1069
+ if self._stop.is_set() or ended:
1070
+ return
1071
+ self._set_transport_error(error.code)
1072
+ if error.code in {"unauthorized", "forbidden"}:
1073
+ self._set_fatal(error)
1074
+ delay = min(max(1, delay * 2), CONTROL_MAX_BACKOFF_SECONDS)
1075
+ except Exception as error:
1076
+ if self._stop.is_set():
1077
+ return
1078
+ self._set_fatal(error)
1079
+ delay = 1
1080
+ if delay:
1081
+ self._stop.wait(delay)
1082
+
1083
+ def package_capture(
1084
+ self,
1085
+ path: str | Path,
1086
+ scope: Scope,
1087
+ *,
1088
+ idempotency_key: str,
1089
+ completeness: str = "partial",
1090
+ provenance: JSONObject | None = None,
1091
+ ) -> JSONObject:
1092
+ """Upload one capture artifact and return the command result body.
1093
+
1094
+ Contract: called from a safe-point handler. The returned object is the
1095
+ capture command result: artifact reference, completeness, and the
1096
+ digest the server must match before commit. ``completeness`` is
1097
+ ``partial`` or ``complete``.
1098
+
1099
+ Failure: ``ValueError`` for an unknown completeness. ``LaunchHelmError``
1100
+ when the client cannot upload or the artifact has no sha256.
1101
+ """
1102
+ if completeness not in {"partial", "complete"}:
1103
+ raise ValueError("completeness must be partial or complete")
1104
+ upload = getattr(self.client, "upload_file", None)
1105
+ if not callable(upload):
1106
+ raise LaunchHelmError("protocol", "runtime client cannot upload artifacts")
1107
+ artifact = upload(path, scope, idempotency_key=idempotency_key, provenance=provenance)
1108
+ if not isinstance(artifact, Document):
1109
+ raise LaunchHelmError("protocol", "upload did not return an artifact document")
1110
+ digest = str(artifact.spec.get("sha256") or "")
1111
+ if len(digest) != 64:
1112
+ raise LaunchHelmError("protocol", "artifact digest missing")
1113
+ return {
1114
+ "artifact": {
1115
+ "id": artifact.id,
1116
+ "kind": "artifact",
1117
+ "revision": artifact.revision,
1118
+ },
1119
+ "completeness": completeness,
1120
+ "sha256": digest,
1121
+ }
1122
+
1123
+ def pending(self, name: str) -> bool:
1124
+ """Return ``True`` when a claimed command waits locally for ``name``.
1125
+
1126
+ Contract: reads the local claimed-command queue only; no network, no
1127
+ state change. A ``True`` result means the next ``safe_point(name)``
1128
+ call has work. ``False`` means nothing claimed for ``name`` has
1129
+ arrived locally — the control thread may still be claiming.
1130
+
1131
+ Failure: none.
1132
+ """
1133
+ with self._commands.mutex:
1134
+ for command in self._commands.queue:
1135
+ request = command.get("request")
1136
+ if isinstance(request, dict) and request.get("safe_point") == name:
1137
+ return True
1138
+ return False
1139
+
1140
+ def safe_point(self, name: str, *, coordinates: list[dict[str, str]]) -> int:
1141
+ """Run at most one claimed command whose safe-point name is ``name``.
1142
+
1143
+ Contract: returns ``1`` if a handler ran, ``0`` if no matching
1144
+ command was waiting or the command was rejected locally. The first
1145
+ call for ``name`` advertises it on a heartbeat immediately (Connect
1146
+ still declares the constructor set) so a parked Claim can deliver
1147
+ commands for that point without waiting out its park. A claimed
1148
+ command whose
1149
+ ``safe_point`` does not match ``name`` is a protocol violation: the
1150
+ command is journaled as rejected and the runtime fails closed. A
1151
+ handler exception is recorded as ``outcome_unknown`` and is never
1152
+ retried automatically. Begin failures that are not definitive also
1153
+ become ``outcome_unknown``.
1154
+
1155
+ Failure: ``ValueError`` for invalid or duplicate coordinates, an
1156
+ invalid safe-point name, or a fingerprint mismatch. ``LaunchHelmError``
1157
+ with code ``protocol`` when the claimed command does not match
1158
+ ``name``. ``LaunchHelmError`` from a definitive ``begin`` rejection
1159
+ is raised after journaling. Background fatals are raised before
1160
+ touching the command queue.
1161
+ """
1162
+ _validate(_COORDINATES, coordinates)
1163
+ if len({item["axis"] for item in coordinates}) != len(coordinates):
1164
+ raise ValueError("duplicate coordinate axis")
1165
+ with self._lock:
1166
+ self._serve_commands = True
1167
+ if self._add_safe_point(name):
1168
+ self._advertise_safe_points()
1169
+ self._raise_if_fatal()
1170
+ try:
1171
+ command = self._commands.get_nowait()
1172
+ except queue.Empty:
1173
+ return 0
1174
+ request = json_object(command.get("request") or {}, what="command request")
1175
+ if request.get("safe_point") != name:
1176
+ encoded = self._result(command, "rejected", failure="undeclared_safe_point")
1177
+ self.store.journal_phase(str(command["id"]), "completed", encoded)
1178
+ error = LaunchHelmError(
1179
+ "protocol",
1180
+ f"claimed command safe_point {request.get('safe_point')!r} does not match {name!r}",
1181
+ )
1182
+ self._set_fatal(error)
1183
+ raise error
1184
+ if fingerprint(request) != command["fingerprint"]:
1185
+ self.store.journal_phase(
1186
+ str(command["id"]),
1187
+ "completed",
1188
+ self._result(command, "rejected", failure="fingerprint_mismatch"),
1189
+ )
1190
+ raise ValueError("command fingerprint does not match payload")
1191
+ capability_ref = json_object(request.get("capability") or {}, what="capability ref")
1192
+ with self._lock:
1193
+ registered = self._handlers.get(str(capability_ref.get("id")))
1194
+ if registered is None or capability_ref.get("revision") != registered[3]:
1195
+ self.store.journal_phase(
1196
+ str(command["id"]),
1197
+ "completed",
1198
+ self._result(command, "rejected", failure="handler_unavailable_or_changed"),
1199
+ )
1200
+ return 0
1201
+ capability, handler, validate, _ = registered
1202
+ try:
1203
+ payload = request["input"]
1204
+ _validate_semantic(capability.input_schema, payload)
1205
+ if validate is not None:
1206
+ validate(payload)
1207
+ except Exception:
1208
+ self.store.journal_phase(
1209
+ str(command["id"]),
1210
+ "completed",
1211
+ self._result(command, "rejected", failure="local_validation"),
1212
+ )
1213
+ return 0
1214
+ self.store.journal_phase(str(command["id"]), "starting")
1215
+ try:
1216
+ self.client.begin(command)
1217
+ except LaunchHelmError as error:
1218
+ definitive = error.code in {
1219
+ "validation",
1220
+ "conflict",
1221
+ "not_found",
1222
+ "forbidden",
1223
+ "unauthorized",
1224
+ }
1225
+ self.store.journal_phase(
1226
+ str(command["id"]),
1227
+ "completed",
1228
+ self._result(
1229
+ command,
1230
+ "rejected" if definitive else "outcome_unknown",
1231
+ failure="begin_rejected" if definitive else "begin_uncertain",
1232
+ ),
1233
+ )
1234
+ raise
1235
+ self.store.journal_phase(str(command["id"]), "applying")
1236
+ targets_raw = request.get("targets")
1237
+ targets_list = targets_raw if isinstance(targets_raw, list) else []
1238
+ context = CommandContext(
1239
+ str(command["id"]),
1240
+ tuple(
1241
+ Reference(str(item["id"]), str(item["kind"]), str(item.get("revision") or ""))
1242
+ for item in targets_list
1243
+ if isinstance(item, dict)
1244
+ ),
1245
+ str(request["epoch"]),
1246
+ tuple(coordinates),
1247
+ )
1248
+ try:
1249
+ result = handler(payload, context)
1250
+ _validate(capability.output_schema, result)
1251
+ self.last_result = result
1252
+ encoded = self._result(command, "applied", result, coordinates)
1253
+ encode(encoded)
1254
+ except Exception as error:
1255
+ encoded = self._result(command, "outcome_unknown", failure=type(error).__name__[:128])
1256
+ self.store.journal_phase(str(command["id"]), "completed", encoded)
1257
+ return 1
1258
+
1259
+ def flush(
1260
+ self, *, timeout: float = DEFAULT_FLUSH_TIMEOUT_SECONDS, delivered: bool = True
1261
+ ) -> None:
1262
+ """Wait until admitted points reach the requested durability.
1263
+
1264
+ Contract: ``delivered=False`` is local SQLite durability (the writer
1265
+ has dequeued every volatile record; pending media is already durable
1266
+ at ``log_media`` so there is nothing further to wait for).
1267
+ ``delivered=True`` additionally waits until media uploads resolve
1268
+ and the spool has no unacknowledged points (query-visible).
1269
+ Does not promise delivery after ``close``.
1270
+
1271
+ The media count is probed before the point count: a media point
1272
+ enters ``points`` atomically with its media row's removal, so a
1273
+ ``pending_count`` read can pass just before that commit and a
1274
+ ``media_pending_count`` read just after — reading them in this
1275
+ order would let ``flush`` return with the point still unsent.
1276
+ Points admitted concurrently with the wait itself are outside the
1277
+ contract; ``stop_accepting`` fences them for callers like
1278
+ ``Run.finish`` that need a fixed drain set.
1279
+
1280
+ Failure: raises the background fatal if one is set, including when
1281
+ the queues are empty. ``TimeoutError`` if the deadline expires with
1282
+ work remaining.
1283
+ """
1284
+ deadline = time.monotonic() + timeout
1285
+ while True:
1286
+ self._raise_if_fatal()
1287
+ if not self._queue.unfinished_tasks and (
1288
+ not delivered
1289
+ or (not self.store.media_pending_count() and not self.store.pending_count())
1290
+ ):
1291
+ return
1292
+ if time.monotonic() >= deadline:
1293
+ raise TimeoutError(
1294
+ "LaunchHelm flush timed out; unacknowledged durable records remain in the spool"
1295
+ )
1296
+ time.sleep(FLUSH_POLL_SECONDS)
1297
+
1298
+ def stop_accepting(self) -> None:
1299
+ """Stop point admission without stopping delivery.
1300
+
1301
+ Contract: idempotent. ``log`` and ``log_media`` calls — including
1302
+ ones already waiting on memory or media budget — raise
1303
+ ``RuntimeError`` instead of admitting another point. Uploads,
1304
+ media resolution, heartbeats, and the spool keep running so a
1305
+ following ``flush(delivered=True)`` drains a fixed set; ``close``
1306
+ performs the full stop.
1307
+
1308
+ Failure: none.
1309
+ """
1310
+ with self._space:
1311
+ self._accepting = False
1312
+ self._space.notify_all()
1313
+ self._media_space.notify_all()
1314
+ self._media_wake.set()
1315
+
1316
+ def close(self, *, timeout: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> None:
1317
+ """Stop admission, persist the volatile queue, and join workers.
1318
+
1319
+ Contract: local durability is attempted via
1320
+ ``flush(delivered=False)``. Server delivery is not promised. Idempotent.
1321
+
1322
+ Failure: ``TimeoutError`` if background threads outlive ``timeout``.
1323
+ Re-raises a flush error after joining threads and closing the store.
1324
+ """
1325
+ with self._space:
1326
+ if self._closed:
1327
+ return
1328
+ self.stop_accepting()
1329
+ failure: BaseException | None = None
1330
+ deadline = time.monotonic() + timeout
1331
+ try:
1332
+ self.flush(timeout=timeout, delivered=False)
1333
+ except BaseException as error:
1334
+ failure = error
1335
+ finally:
1336
+ self._stop.set()
1337
+ abort = getattr(self.client, "abort", None)
1338
+ if callable(abort):
1339
+ abort()
1340
+ for thread in self._threads:
1341
+ thread.join(max(0, deadline - time.monotonic()))
1342
+ if any(thread.is_alive() for thread in self._threads):
1343
+ raise TimeoutError("LaunchHelm background operations have not stopped")
1344
+ self.store.close()
1345
+ with self._lock:
1346
+ self._closed = True
1347
+ if failure is not None:
1348
+ raise failure