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
+18 -2
View File
@@ -54,6 +54,7 @@ _SHA256 = re.compile(r"[a-f0-9]{64}")
class ResourceKind(str, Enum):
FILESYSTEM = "filesystem"
PROCESS = "process"
PROCESS_LAUNCH = "process_launch"
BACKGROUND_JOB = "background_job"
OWNED = "owned"
EXTERNAL = "external"
@@ -108,6 +109,11 @@ class ResourceRef:
"""
if self.kind is not other.kind:
return False
if self.kind is ResourceKind.OWNED:
# A collection binding ("*") covers every record it can create,
# list or change; specific records only overlap themselves.
return self.location[:-1] == other.location[:-1] and (
self.location[-1] == other.location[-1] or "*" in (self.location[-1], other.location[-1]))
if self.kind is not ResourceKind.FILESYSTEM:
return self.location == other.location
if self.location[:-1] != other.location[:-1]:
@@ -153,6 +159,13 @@ def resource_ref(resource: Any, role: str) -> ResourceRef:
location = ("process", resource.namespace, resource.owner, resource.request_id, resource.thread_id,
str(ident.pid), ident.start_token, resource.role)
return ResourceRef(ResourceKind.PROCESS, role, location, ident.start_token, _sha(resource.to_dict()))
if isinstance(resource, wave3.ProcessLaunchResource):
# The reservation generation is the exact launch -> job linkage that
# Wave 3 validates in ``job_from_record``.
location = ("process_launch", resource.namespace, resource.owner, resource.request_id,
resource.thread_id, resource.generation)
return ResourceRef(ResourceKind.PROCESS_LAUNCH, role, location, resource.generation,
_sha(resource.to_dict()))
if isinstance(resource, wave3.BackgroundJobResource):
location = ("background_job", resource.namespace, resource.owner, resource.request_id,
resource.thread_id, resource.job_id, resource.generation)
@@ -378,11 +391,13 @@ class ProducerFacts:
job_state: str = ""
remote_acknowledged: bool = False
external: bool = False
# The producer reached its mutation stage before reporting failure.
mutation_attempted: bool = False
def __post_init__(self) -> None:
if self.exit_code is not None and type(self.exit_code) is not int:
raise ValueError("Malformed producer exit code")
for name in ("timed_out", "output_truncated", "remote_acknowledged", "external"):
for name in ("timed_out", "output_truncated", "remote_acknowledged", "external", "mutation_attempted"):
if type(getattr(self, name)) is not bool:
raise ValueError("Malformed producer flag")
for name in ("failure_kind", "job_state"):
@@ -395,7 +410,7 @@ class ProducerFacts:
return {"exit_code": self.exit_code, "timed_out": self.timed_out,
"output_truncated": self.output_truncated, "failure_kind": self.failure_kind,
"job_state": self.job_state, "remote_acknowledged": self.remote_acknowledged,
"external": self.external}
"external": self.external, "mutation_attempted": self.mutation_attempted}
@classmethod
def from_dict(cls, value: Any) -> "ProducerFacts":
@@ -428,6 +443,7 @@ def producer_facts(result: Any) -> ProducerFacts:
failure_kind=_label(result.get("failure_kind")),
job_state=_label(job),
external=external,
mutation_attempted=result.get("mutation_attempted") is True,
)