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
@@ -20,9 +20,19 @@ from src.agent_evidence import (
requirements_from_runtime_context, _execution_obligation, _unquoted_statements,
_ARTIFACT_PATH,
)
from .effect_log import EffectLog
from .journal import ActionJournal, bind_journal, current_journal
def _ledger(journal: ActionJournal, requirements) -> EvidenceLedger:
"""The single evidence view used for the decision and the prose filter."""
ledger = EvidenceLedger.from_tool_events(journal.evidence_events(), requirements)
ledger.record_effects(journal.effect_entries(),
{action.action_id: index for index, action in enumerate(journal.actions, 1)},
journal.partial_reads())
return ledger
_TEST_CLAIM = re.compile(
r'\b(?:(?:all\s+)?(?:tests?|checks?|verification|suite)\s+(?:have\s+|has\s+|now\s+|are\s+|is\s+)*(?:passed|passing|successful|green)|'
r'(?:passed|passing)\s+(?:all\s+)?(?:the\s+)?tests?|\d+\s+passed)\b', re.I)
@@ -192,6 +202,10 @@ def with_completion_gate(func):
journal = ActionJournal(
workspace=requirements.workspace_root, observed_artifacts=requirements.required_artifacts,
parent_run_id=bound.get('_parent_run_id') or (parent.run_id if parent is not None else None))
# One durable effect log per run lineage gives child effects and parent
# observations a single total order for invalidation.
journal.effects = (parent.effects if parent is not None and parent.effects is not None
else EffectLog(journal.run_id))
answer_events: list[dict] = []
metrics_events: list[dict] = []
answer = ''
@@ -241,7 +255,7 @@ def with_completion_gate(func):
awaiting = True
payload = data.get('data') or {}
if isinstance(payload.get('question'), str):
current = EvidenceLedger.from_tool_events(journal.evidence_events(), requirements)
current = _ledger(journal, requirements)
question, why = completion_answer(payload['question'], current, current.evaluate(awaiting_user=True))
if why:
data = {**data, 'data': {**payload, 'question': question}}
@@ -290,7 +304,7 @@ def with_completion_gate(func):
presentation_replaced = True
answer = terminal_answer
answer_events = [event for event in answer_events if event.get('thinking') is True]
ledger = EvidenceLedger.from_tool_events(journal.evidence_events(), requirements)
ledger = _ledger(journal, requirements)
decision = ledger.evaluate(exhausted=exhausted, awaiting_user=awaiting)
if provider_error:
decision = replace(decision, status=CompletionStatus.FAILED,
@@ -334,6 +348,8 @@ def with_completion_gate(func):
metadata.update(completion_decision=decision.to_dict(), evidence_events=ledger.to_list(),
action_receipts=journal.to_list(), completion_requirements=requirements.to_dict(),
run_id=journal.run_id, parent_run_id=journal.parent_run_id)
if ledger.effects:
metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects]
metadata['completion_gate'] = {
'buffer_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
'first_visible_answer_seconds': released_at - started,