Wire delegation to A12's trigger, and implement A3's resume rule
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m8s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m8s
Two gaps found by auditing spec against code, the second created by 7b landing the harness without its boot-time half. **The harness was not connected to anything.** Only a live test constructed a Delegation; A12's failure-count trigger still gated to the human, which A13 describes as the fallback rather than the path. Consecutive command failures now hand the sub-task to the delegate, with the failures in the brief so it need not rediscover what the local model learned the hard way. A usable handback lets the local loop carry on; anything else escalates, because a cap breach leaves a half-edited tree. **A3 step 5 was unimplemented.** A crash mid-delegation failed safe — boot gated on "restarted mid-EXECUTING" — but always gated, even on the clean cut the spec says should resume, and left the branch orphaned because _abandon only runs while the process lives. - A delegation now records an intent before the branch exists, and settles it whichever way the delegation ends; an aborted delegation is a finished one, and leaving it pending would make the next boot believe work was still in flight - delegation_verdict implements the clean-cut test as an observable check, never model self-assessment: settled if a completion follows the start, absent if the branch was never created, gate on a tool_call with no recorded result, resume otherwise - recover() sweeps at boot, announcing a resume with a notice rather than a question, since a clean cut needs no decision from the operator, and leaving an unclean one pending so the generic sweep gates on it - resume() continues the same delegation rather than starting a second: no new branch, no second delegation_started, because A10 defines a session as the delegation, not the process lifetime Honest limit, stated in the code: "the branch sits at the expected commit" is approximated by the branch existing and carrying the work, since per-turn shas are not recorded in the log, so a branch rewritten underneath us would go undetected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
608a3a5bfb
commit
ce436262f4
5 files changed
+414
-5
No files matched your search
@@ -11,11 +11,14 @@ from nisse_dev.canonical import (
|
||||
DelegationStarted,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from nisse_dev.delegation import (
|
||||
DELEGATE_TOOLS,
|
||||
DELEGATION_INTENT,
|
||||
Caps,
|
||||
Delegation,
|
||||
delegation_reconciler,
|
||||
)
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.llm import LLMError
|
||||
@@ -302,3 +305,205 @@ def test_failure_may_be_reported_without_doing_work(
|
||||
assert result.status == "failed"
|
||||
assert not result.usable
|
||||
assert len(client.calls) == 1 # believed immediately, not challenged
|
||||
|
||||
|
||||
# --- A3 step 5: the resume rule -----------------------------------------
|
||||
|
||||
|
||||
def pending_delegation_intent(store: StateStore, branch: str = "d") -> Any:
|
||||
store.begin_intent(
|
||||
DELEGATION_INTENT,
|
||||
{"branch": branch, "base": "main", "brief": "finish it"},
|
||||
cause="test",
|
||||
)
|
||||
return store.pending_intents()[-1]
|
||||
|
||||
|
||||
def test_intent_is_recorded_and_settled_by_a_completed_delegation(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
client = ScriptedClient(
|
||||
[[call("write_file", path="a.py", content="x")], [hand_back()]]
|
||||
)
|
||||
build(client, store, ws, repo).run(brief="b", branch="d")
|
||||
# Nothing left in flight: an aborted delegation is a finished one too.
|
||||
assert store.pending_intents() == []
|
||||
|
||||
|
||||
def test_aborted_delegation_also_settles_its_intent(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
client = ScriptedClient([[hand_back(succeeded=False, changed="cannot")]])
|
||||
build(client, store, ws, repo).run(brief="b", branch="d")
|
||||
assert store.pending_intents() == []
|
||||
|
||||
|
||||
def test_clean_cut_after_a_tool_result_is_resumable(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
(repo.root / "partial.py").write_text("x")
|
||||
repo.commit_all("partial work")
|
||||
store.append_event(
|
||||
DelegationStarted(brief="b", role="delegate", model="m", caps={})
|
||||
)
|
||||
store.append_event(ToolCall(call_id="c", tool="write_file", arguments={}))
|
||||
store.append_event(ToolResult(call_id="c", result="wrote", success=True))
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is True
|
||||
|
||||
|
||||
def test_dangling_tool_call_gates(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
# The effect of the call is unknown and not verifiable by inspection.
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(
|
||||
DelegationStarted(brief="b", role="delegate", model="m", caps={})
|
||||
)
|
||||
store.append_event(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is None
|
||||
|
||||
|
||||
def test_missing_branch_means_nothing_started(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
store.append_event(
|
||||
DelegationStarted(brief="b", role="delegate", model="m", caps={})
|
||||
)
|
||||
store.append_event(ToolResult(call_id="c", result="ok", success=True))
|
||||
intent = pending_delegation_intent(store, branch="never-created")
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is False
|
||||
|
||||
|
||||
def test_a_finished_delegation_settles_rather_than_resumes(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(
|
||||
DelegationStarted(brief="b", role="delegate", model="m", caps={})
|
||||
)
|
||||
store.append_event(
|
||||
DelegationCompleted(
|
||||
handback="done",
|
||||
artifacts=[],
|
||||
tokens=0,
|
||||
duration_s=1.0,
|
||||
exit_status="handback",
|
||||
)
|
||||
)
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is True
|
||||
|
||||
|
||||
def test_resume_continues_the_same_delegation(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
# Interrupt after one turn, then resume: no second delegation_started,
|
||||
# because A10 defines a session as the delegation, not the process.
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(
|
||||
DelegationStarted(brief="b", role="delegate", model="m", caps={})
|
||||
)
|
||||
store.append_event(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
client = ScriptedClient(
|
||||
[[call("write_file", path="b.py", content="x")], [hand_back()]]
|
||||
)
|
||||
result = build(client, store, ws, repo).resume(intent)
|
||||
|
||||
assert result.usable
|
||||
starts = [e for e in store.read_events() if isinstance(e.event, DelegationStarted)]
|
||||
assert len(starts) == 1
|
||||
assert store.pending_intents() == []
|
||||
assert (repo.root / "b.py").exists()
|
||||
|
||||
|
||||
def test_resume_onto_a_missing_branch_fails_cleanly(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
intent = pending_delegation_intent(store, branch="gone")
|
||||
result = build(ScriptedClient([]), store, ws, repo).resume(intent)
|
||||
|
||||
assert result.status == "failed"
|
||||
assert not result.usable
|
||||
assert store.pending_intents() == [] # not left dangling for the next boot
|
||||
|
||||
|
||||
# --- boot recovery sweep ------------------------------------------------
|
||||
|
||||
|
||||
def test_recover_resumes_a_clean_cut_with_a_notice(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(
|
||||
DelegationStarted(brief="b", role="delegate", model="m", caps={})
|
||||
)
|
||||
store.append_event(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
pending_delegation_intent(store)
|
||||
notices: list[str] = []
|
||||
|
||||
client = ScriptedClient(
|
||||
[[call("write_file", path="b.py", content="x")], [hand_back()]]
|
||||
)
|
||||
result = build(client, store, ws, repo).recover(notices.append)
|
||||
|
||||
assert result is not None and result.usable
|
||||
# A3: a notice, not a question — a clean cut needs no operator decision.
|
||||
assert notices and "resuming the delegation" in notices[0]
|
||||
|
||||
|
||||
def test_recover_settles_a_finished_delegation_without_resuming(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(
|
||||
DelegationStarted(brief="b", role="delegate", model="m", caps={})
|
||||
)
|
||||
store.append_event(
|
||||
DelegationCompleted(
|
||||
handback="done",
|
||||
artifacts=[],
|
||||
tokens=0,
|
||||
duration_s=1.0,
|
||||
exit_status="handback",
|
||||
)
|
||||
)
|
||||
pending_delegation_intent(store)
|
||||
client = ScriptedClient([])
|
||||
|
||||
assert build(client, store, ws, repo).recover(lambda _m: None) is None
|
||||
assert store.pending_intents() == []
|
||||
assert client.calls == [] # the model was never asked
|
||||
|
||||
|
||||
def test_recover_discards_an_intent_whose_branch_never_existed(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
store.append_event(ToolResult(call_id="c", result="ok", success=True))
|
||||
pending_delegation_intent(store, branch="never-made")
|
||||
|
||||
assert build(ScriptedClient([]), store, ws, repo).recover(lambda _m: None) is None
|
||||
assert store.pending_intents() == []
|
||||
|
||||
|
||||
def test_recover_leaves_an_unclean_cut_for_the_boot_gate(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(
|
||||
DelegationStarted(brief="b", role="delegate", model="m", caps={})
|
||||
)
|
||||
store.append_event(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
pending_delegation_intent(store)
|
||||
|
||||
assert build(ScriptedClient([]), store, ws, repo).recover(lambda _m: None) is None
|
||||
# Still pending, so reconcile_on_boot gates rather than blind-resuming.
|
||||
assert [i.kind for i in store.pending_intents()] == [DELEGATION_INTENT]
|
||||
Reference in new issue
Block a user