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
+16 -6
View File
@@ -156,20 +156,24 @@ class EditFileTool:
if count > 1 and not replace_all:
return original, None, f"not_unique:{count}"
updated = original.replace(old, new) if replace_all else original.replace(old, new, 1)
attempted.append(True)
with open(path, "w", encoding="utf-8", newline="") as f:
f.write(updated)
return original, updated, "ok"
# In-place rewrite: a failure after truncation may leave partial bytes.
attempted = []
partial = lambda: {"mutation_attempted": True} if attempted else {}
try:
original, updated, status = await asyncio.to_thread(_apply)
except FileNotFoundError:
return {"error": f"edit_file: {path}: not found (use write_file to create it)", "exit_code": 1}
return {"error": f"edit_file: {path}: not found (use write_file to create it)", "exit_code": 1, **partial()}
except (IsADirectoryError, UnicodeDecodeError):
return {"error": f"edit_file: {path}: not an editable text file", "exit_code": 1}
return {"error": f"edit_file: {path}: not an editable text file", "exit_code": 1, **partial()}
except PermissionError:
return {"error": f"edit_file: {path}: permission denied", "exit_code": 1}
return {"error": f"edit_file: {path}: permission denied", "exit_code": 1, **partial()}
except OSError as e:
return {"error": f"edit_file: {path}: {e}", "exit_code": 1}
return {"error": f"edit_file: {path}: {e}", "exit_code": 1, **partial()}
if status == "not_found":
return {"error": f"edit_file: old_string not found in {path}. Read the file and match it exactly.", "exit_code": 1}
@@ -332,6 +336,9 @@ class WriteFileTool:
"exit_code": 1,
"binary_artifact_preserved": target_existed,
}
# This writer truncates in place. Once that stage is reached, a failure
# may leave a partial file; report it so effect evidence stays honest.
attempted = []
try:
def _write():
old = ""
@@ -343,14 +350,17 @@ class WriteFileTool:
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
attempted.append(True)
with open(path, "w", encoding="utf-8") as f:
f.write(body)
return old, len(body)
old_content, size = await asyncio.to_thread(_write)
except PermissionError:
return {"error": f"write_file: {path}: permission denied", "exit_code": 1}
return {"error": f"write_file: {path}: permission denied", "exit_code": 1,
**({"mutation_attempted": True} if attempted else {})}
except OSError as e:
return {"error": f"write_file: {path}: {e}", "exit_code": 1}
return {"error": f"write_file: {path}: {e}", "exit_code": 1,
**({"mutation_attempted": True} if attempted else {})}
diff = _unified_diff(old_content, body, path)
result = {
"output": f"Wrote {size} bytes to {_display_tool_path(path)}",