feat(runtime): claim effects before dispatch and gate completion on them

Dispatcher seam: mark_dispatch, which runs inside the live Wave 3 binding
scope immediately before backend invocation, now durably claims a possible
effect before execution_id is assigned. If the claim cannot be persisted the
action stays undispatched and the dispatcher returns BLOCKED; dispatched()
closes the never-awaited coroutine. record_action appends the outcome
(including cancellation/interruption) and admitted-read observations before
the receipt reduction drops producer facts.

Adapters consume only the bound operations the dispatcher admitted:
filesystem bindings give exact scope and predicates (write_file content digest
after fence unwrapping, apply_patch add/delete, edit existence); bash/python
launches have unknown scope with the launch generation as lineage; job kills
scope the exact job and its processes; owned operations scope their exact
revisioned records; external backends are claimed as external and never
verified by acknowledgement; browser session_info yields session lifecycle
observations only, and a page binding is never effect scope. Complete
read_file re-reads the exact bound source to digest it; offset/limit,
truncation, extraction and listings are partial. Background launches stay
RUNNING until an admitted read of the exact job generation (via a durable
launch index, across continuation runs) reports settlement.

Producer seams: typed job lifecycle facts on manage_bg_jobs reads/kills, a
structured timed_out flag on containment timeouts, and mutation_attempted on
in-place write_file/edit_file failures after truncation.

Completion: the existing EvidenceLedger consumes effect assessments through a
single helper used for the decision, ask_user and prose filtering. A required
artifact is unsettled by a later unresolved effect that may have touched it,
a fresh contradicting readback fails the decision, and partial reads no longer
count as artifact validation. Ordinary conversation and read-only turns are
unchanged; no second completion policy is introduced.
This commit is contained in:
Alexandre Teixeira
2026-10-02 20:09:35 +01:00
parent 9c4ed24296
commit 8402c388b4
11 changed files with 1231 additions and 17 deletions
+61
View File
@@ -17,6 +17,7 @@ from pathlib import Path
import re
import stat
import threading
import weakref
from typing import Any
from src.constants import DATA_DIR
@@ -41,11 +42,17 @@ def effects_dir() -> Path:
class EffectLog:
# Logs still owned by a live run in this process. A later turn appends to
# the same object rather than a second copy with its own sequence counter.
_LIVE: "weakref.WeakValueDictionary[tuple[str, str], EffectLog]" = weakref.WeakValueDictionary()
def __init__(self, run_id: str, *, durable: bool = True, directory: str | os.PathLike | None = None) -> None:
if not isinstance(run_id, str) or not _RUN_ID.fullmatch(run_id):
raise ValueError("Effect log requires a server-generated run identifier")
self.run_id = run_id
self.path = (Path(directory) if directory is not None else effects_dir()) / f"{run_id}.jsonl" if durable else None
if self.path is not None:
self._LIVE[(str(self.path.parent), run_id)] = self
self._claims: list[EffectClaim] = []
self._outcomes: list[EffectOutcome] = []
self._observations: list[Observation] = []
@@ -167,6 +174,60 @@ class EffectLog:
default=0)
return log
@classmethod
def open(cls, run_id: str, *, directory: str | os.PathLike | None = None) -> "EffectLog":
"""The live log for a run, or its replayed durable history.
A log that is not live belongs to a finished or crashed run, so its
unsettled claims are recovered as interrupted before any append.
"""
base = Path(directory) if directory is not None else effects_dir()
live = cls._LIVE.get((str(base), run_id))
if live is not None:
return live
log = cls.load(run_id, directory=base)
log.recover_interrupted()
return log
# -- background launch lineage ------------------------------------------
def index_launch(self, generation: str, effect_id: str) -> None:
"""Durably map an exact Wave 3 launch generation to its claim."""
if self.path is None:
return
if not _RUN_ID.fullmatch(generation or ""):
raise ValueError("Malformed launch generation")
target = self.path.parent / f"launch-{generation}.json"
temporary = target.with_suffix(".tmp")
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(temporary, flags, 0o600)
try:
os.write(descriptor, data)
os.fsync(descriptor)
finally:
os.close(descriptor)
os.replace(temporary, target)
@staticmethod
def launch_owner(generation: str, *, directory: str | os.PathLike | None = None) -> tuple[str, str] | None:
if not _RUN_ID.fullmatch(generation or ""):
return None
base = Path(directory) if directory is not None else effects_dir()
try:
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
with os.fdopen(descriptor, "rb") as stream:
if os.fstat(stream.fileno()).st_nlink != 1:
return None
value = json.loads(stream.read(4096))
except (OSError, ValueError):
return None
if (not isinstance(value, dict) or set(value) != {"run_id", "effect_id"}
or not isinstance(value["run_id"], str) or not _RUN_ID.fullmatch(value["run_id"])
or not isinstance(value["effect_id"], str)):
return None
return value["run_id"], value["effect_id"]
def recover_interrupted(self) -> tuple[EffectOutcome, ...]:
"""Append INTERRUPTED outcomes for claims that never settled."""
with self._lock: