Merge pull request 'Wire delegation to A12's trigger, and implement A3's resume rule' (#25) from delegation-resume into main
CI / checks (push) Successful in 1m18s
CI / checks (push) Successful in 1m18s
Reviewed-on: dongar/nisse#25 Reviewed-by: Don Garrett <dgarrett@acm.org>
This commit was merged in pull request #25.
This commit is contained in:
commit
6331fcafd9
5 files changed
+414
-5
No files matched your search
@@ -163,6 +163,7 @@ class Orchestrator:
|
|||||||
publisher: "MilestonePublisher | None" = None,
|
publisher: "MilestonePublisher | None" = None,
|
||||||
gitea_operators: Iterable[str] = (),
|
gitea_operators: Iterable[str] = (),
|
||||||
board: "PlanBoard | None" = None,
|
board: "PlanBoard | None" = None,
|
||||||
|
delegate: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._store = store
|
self._store = store
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
@@ -176,6 +177,7 @@ class Orchestrator:
|
|||||||
# the other.
|
# the other.
|
||||||
self._gitea_operators = set(gitea_operators)
|
self._gitea_operators = set(gitea_operators)
|
||||||
self._board = board
|
self._board = board
|
||||||
|
self._delegate = delegate
|
||||||
# Transport and the comment poller deliver on separate threads;
|
# Transport and the comment poller deliver on separate threads;
|
||||||
# state transitions must not interleave.
|
# state transitions must not interleave.
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
@@ -184,6 +186,11 @@ class Orchestrator:
|
|||||||
transport.on_message(self._handle)
|
transport.on_message(self._handle)
|
||||||
|
|
||||||
def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None:
|
def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None:
|
||||||
|
# A3 step 5 runs before the generic sweep: an interrupted delegation is
|
||||||
|
# resumed or discarded by inspecting the branch and the log, and only a
|
||||||
|
# genuinely unclean cut is left for the sweep below to gate on.
|
||||||
|
if self._delegate is not None:
|
||||||
|
self._delegate.recover(self._transport.send_message)
|
||||||
# The publisher owns the external-truth query for its own intents, so
|
# The publisher owns the external-truth query for its own intents, so
|
||||||
# a configured publisher always contributes its reconciler (A3).
|
# a configured publisher always contributes its reconciler (A3).
|
||||||
registered: dict[str, Reconciler] = dict(reconcilers or {})
|
registered: dict[str, Reconciler] = dict(reconcilers or {})
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ from nisse_dev.canonical import (
|
|||||||
)
|
)
|
||||||
from nisse_dev.git import GitError, GitRepo
|
from nisse_dev.git import GitError, GitRepo
|
||||||
from nisse_dev.llm import LLMClient, LLMError
|
from nisse_dev.llm import LLMClient, LLMError
|
||||||
from nisse_dev.state import StateStore
|
from nisse_dev.reconcile import Reconciler
|
||||||
|
from nisse_dev.state import Intent, StateStore
|
||||||
from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, Workspace
|
from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, Workspace
|
||||||
|
|
||||||
DELEGATION_INTENT = "delegation"
|
DELEGATION_INTENT = "delegation"
|
||||||
@@ -169,6 +170,19 @@ class Delegation:
|
|||||||
) -> DelegationResult:
|
) -> DelegationResult:
|
||||||
started_at = self._now()
|
started_at = self._now()
|
||||||
base_branch = self._git.current_branch()
|
base_branch = self._git.current_branch()
|
||||||
|
# Recorded before the branch exists, so a crash anywhere after this
|
||||||
|
# leaves a durable statement of what was in flight (A2/A3).
|
||||||
|
intent_id = self._store.begin_intent(
|
||||||
|
DELEGATION_INTENT,
|
||||||
|
{
|
||||||
|
"branch": branch,
|
||||||
|
"base": base_branch,
|
||||||
|
"brief": brief,
|
||||||
|
"role": role,
|
||||||
|
"caps": self._caps.as_payload(),
|
||||||
|
},
|
||||||
|
cause=f"delegating to {self._model}",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
# Commit whatever the caller had in flight onto their own branch
|
# Commit whatever the caller had in flight onto their own branch
|
||||||
# first. Otherwise it would ride along onto the delegation branch
|
# first. Otherwise it would ride along onto the delegation branch
|
||||||
@@ -176,6 +190,7 @@ class Delegation:
|
|||||||
self._git.commit_all("Checkpoint before delegating")
|
self._git.commit_all("Checkpoint before delegating")
|
||||||
self._git.checkout_new_branch(branch)
|
self._git.checkout_new_branch(branch)
|
||||||
except GitError as exc:
|
except GitError as exc:
|
||||||
|
self._store.abort_intent(intent_id, cause=f"could not branch: {exc}")
|
||||||
return DelegationResult("failed", f"could not branch for delegation: {exc}")
|
return DelegationResult("failed", f"could not branch for delegation: {exc}")
|
||||||
|
|
||||||
self._store.append_event(
|
self._store.append_event(
|
||||||
@@ -201,8 +216,77 @@ class Delegation:
|
|||||||
# Abort discards the branch: the caller must never resume on top
|
# Abort discards the branch: the caller must never resume on top
|
||||||
# of a partially-edited tree (A13).
|
# of a partially-edited tree (A13).
|
||||||
self._abandon(branch, base_branch)
|
self._abandon(branch, base_branch)
|
||||||
|
# Settled either way: an aborted delegation is a *finished* one, and
|
||||||
|
# leaving the intent pending would make the next boot think work was
|
||||||
|
# still in flight.
|
||||||
|
self._store.commit_intent(intent_id, cause=f"delegation {result.status}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def resume(self, intent: Intent) -> DelegationResult:
|
||||||
|
"""Continue a delegation the process died in the middle of (A3 step 5).
|
||||||
|
|
||||||
|
The transcript is already in the canonical log, so the loop re-renders
|
||||||
|
it and carries on. No new branch and no second ``delegation_started``:
|
||||||
|
this is the same delegation, which is why its vendor-opaque reasoning
|
||||||
|
may still be replayed (A10 defines a session as the delegation, not the
|
||||||
|
process lifetime).
|
||||||
|
"""
|
||||||
|
payload = intent.payload
|
||||||
|
branch = str(payload["branch"])
|
||||||
|
started_at = self._now()
|
||||||
|
try:
|
||||||
|
self._git.checkout_existing_branch(branch)
|
||||||
|
except GitError as exc:
|
||||||
|
self._store.commit_intent(
|
||||||
|
intent.id, cause=f"delegation branch unusable: {exc}"
|
||||||
|
)
|
||||||
|
return DelegationResult("failed", f"could not resume on {branch}: {exc}")
|
||||||
|
|
||||||
|
result = self._loop(str(payload.get("brief", "")), branch, started_at)
|
||||||
|
result.duration_s = self._now() - started_at
|
||||||
|
self._store.append_event(
|
||||||
|
DelegationCompleted(
|
||||||
|
handback=result.handback.render() if result.handback else result.note,
|
||||||
|
artifacts=result.artifacts,
|
||||||
|
tokens=result.tokens,
|
||||||
|
duration_s=result.duration_s,
|
||||||
|
exit_status=result.status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.usable:
|
||||||
|
self._abandon(branch, str(payload.get("base", "main")))
|
||||||
|
self._store.commit_intent(intent.id, cause=f"delegation {result.status}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def recover(self, notify: Callable[[str], None]) -> DelegationResult | None:
|
||||||
|
"""Sweep pending delegation intents at boot (A3 step 5).
|
||||||
|
|
||||||
|
Settles what is already finished, discards what never started, resumes
|
||||||
|
a clean cut — announcing it with a *notice*, not a question, because a
|
||||||
|
clean cut needs no decision from the operator — and leaves an unclean
|
||||||
|
one pending so the generic boot sweep gates on it.
|
||||||
|
"""
|
||||||
|
for intent in self._store.pending_intents():
|
||||||
|
if intent.kind != DELEGATION_INTENT:
|
||||||
|
continue
|
||||||
|
verdict = delegation_verdict(self._store, self._git, intent)
|
||||||
|
branch = intent.payload.get("branch")
|
||||||
|
if verdict == "settled":
|
||||||
|
self._store.commit_intent(
|
||||||
|
intent.id, cause="delegation had already completed"
|
||||||
|
)
|
||||||
|
elif verdict == "absent":
|
||||||
|
self._store.abort_intent(
|
||||||
|
intent.id, cause=f"delegation branch {branch} never created"
|
||||||
|
)
|
||||||
|
elif verdict == "resume":
|
||||||
|
notify(
|
||||||
|
f"resuming the delegation interrupted on {branch}; "
|
||||||
|
"the transcript and branch are intact"
|
||||||
|
)
|
||||||
|
return self.resume(intent)
|
||||||
|
return None
|
||||||
|
|
||||||
def _loop(self, brief: str, branch: str, started_at: float) -> DelegationResult:
|
def _loop(self, brief: str, branch: str, started_at: float) -> DelegationResult:
|
||||||
system = (
|
system = (
|
||||||
f"{SYSTEM_PROMPT}\n\n{self._workspace.describe()}"
|
f"{SYSTEM_PROMPT}\n\n{self._workspace.describe()}"
|
||||||
@@ -361,3 +445,64 @@ def _parse_handback(arguments: dict[str, Any]) -> Handback | None:
|
|||||||
next=str(arguments["next"]).strip(),
|
next=str(arguments["next"]).strip(),
|
||||||
succeeded=succeeded,
|
succeeded=succeeded,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delegation_reconciler(store: StateStore, git_repo: GitRepo) -> Reconciler:
|
||||||
|
"""A3 step 5: decide whether an interrupted delegation may be resumed.
|
||||||
|
|
||||||
|
The judgement is an observable check, never model self-assessment — the
|
||||||
|
same reasoning as A12. Returns:
|
||||||
|
|
||||||
|
- ``True`` the delegation is settled or cleanly resumable
|
||||||
|
- ``False`` it never really started, so there is nothing to resume
|
||||||
|
- ``None`` the cut is unclean, so boot gates to the operator
|
||||||
|
|
||||||
|
"The branch sits at the expected commit" is approximated by the branch
|
||||||
|
existing and carrying the delegation's commits: per-turn shas are not
|
||||||
|
recorded in the log today, so a branch rewritten out from under us would
|
||||||
|
not be detected. Stated rather than glossed, because the check is weaker
|
||||||
|
than A3's wording implies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def check(intent: Intent) -> bool | None:
|
||||||
|
verdict = delegation_verdict(store, git_repo, intent)
|
||||||
|
if verdict in ("resume", "settled"):
|
||||||
|
return True
|
||||||
|
return False if verdict == "absent" else None
|
||||||
|
|
||||||
|
return check
|
||||||
|
|
||||||
|
|
||||||
|
def delegation_verdict(
|
||||||
|
store: StateStore, git_repo: GitRepo, intent: Intent
|
||||||
|
) -> Literal["resume", "settled", "absent", "gate"]:
|
||||||
|
"""The A3 step 5 decision, separated from the Reconciler contract because
|
||||||
|
boot must *act* on it — resuming is not the same as judging settled."""
|
||||||
|
branch = intent.payload.get("branch")
|
||||||
|
if not branch:
|
||||||
|
return "gate"
|
||||||
|
if _completed_after_start(store):
|
||||||
|
return "settled" # it finished; the intent merely outlived its commit
|
||||||
|
if not git_repo.branch_exists(str(branch)):
|
||||||
|
return "absent" # the crash landed before any work existed
|
||||||
|
events = store.read_events()
|
||||||
|
if not events:
|
||||||
|
return "gate"
|
||||||
|
last = events[-1].event
|
||||||
|
if isinstance(last, ToolCall):
|
||||||
|
# A call with no recorded result: its effect is unknown and cannot be
|
||||||
|
# verified by inspection, so gate (A3).
|
||||||
|
return "gate"
|
||||||
|
if not isinstance(last, AssistantText | ToolResult):
|
||||||
|
return "gate"
|
||||||
|
return "resume"
|
||||||
|
|
||||||
|
|
||||||
|
def _completed_after_start(store: StateStore) -> bool:
|
||||||
|
"""True when the last delegation event in the log is a completion."""
|
||||||
|
for stored in reversed(store.read_events()):
|
||||||
|
if isinstance(stored.event, DelegationCompleted):
|
||||||
|
return True
|
||||||
|
if isinstance(stored.event, DelegationStarted):
|
||||||
|
return False
|
||||||
|
return False
|
||||||
@@ -14,6 +14,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from nisse_dev.canonical import ToolCall, ToolResult
|
from nisse_dev.canonical import ToolCall, ToolResult
|
||||||
|
from nisse_dev.delegation import Delegation
|
||||||
from nisse_dev.expert import ASK_EXPERT_TOOL, BudgetExhausted, ExpertDesk
|
from nisse_dev.expert import ASK_EXPERT_TOOL, BudgetExhausted, ExpertDesk
|
||||||
from nisse_dev.llm import LLMClient, LLMError, RoleMap
|
from nisse_dev.llm import LLMClient, LLMError, RoleMap
|
||||||
from nisse_dev.state import StateStore
|
from nisse_dev.state import StateStore
|
||||||
@@ -58,6 +59,7 @@ class LLMExecutor:
|
|||||||
max_turns: int = DEFAULT_MAX_TURNS,
|
max_turns: int = DEFAULT_MAX_TURNS,
|
||||||
failure_limit: int = DEFAULT_FAILURE_LIMIT,
|
failure_limit: int = DEFAULT_FAILURE_LIMIT,
|
||||||
expert: ExpertDesk | None = None,
|
expert: ExpertDesk | None = None,
|
||||||
|
delegate: Delegation | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
self._store = store
|
self._store = store
|
||||||
@@ -66,6 +68,39 @@ class LLMExecutor:
|
|||||||
self._max_turns = max_turns
|
self._max_turns = max_turns
|
||||||
self._failure_limit = failure_limit
|
self._failure_limit = failure_limit
|
||||||
self._expert = expert
|
self._expert = expert
|
||||||
|
self._delegate = delegate
|
||||||
|
|
||||||
|
def _escalate(
|
||||||
|
self, milestone: int, failures: int, last_failure: str
|
||||||
|
) -> ExecutorOutcome | None:
|
||||||
|
"""Hand the sub-task to a stronger model, per A12.
|
||||||
|
|
||||||
|
Returns None when the delegation handed back usable work and the local
|
||||||
|
loop should carry on; an ExecutorOutcome when the caller must stop.
|
||||||
|
"""
|
||||||
|
note = f"{failures} consecutive command failures; last: {last_failure[:500]}"
|
||||||
|
if self._delegate is None:
|
||||||
|
return ExecutorOutcome("escalate", note)
|
||||||
|
|
||||||
|
# A12 says the failures go in the brief: the delegate must not have to
|
||||||
|
# rediscover what the local model already learned the hard way.
|
||||||
|
brief = (
|
||||||
|
f"Milestone {milestone} is stuck. The local executor tried and its "
|
||||||
|
f"commands failed {failures} times in a row.\n\n"
|
||||||
|
f"Most recent failure:\n{last_failure[:2000]}\n\n"
|
||||||
|
"The approved plan and the full transcript are in the conversation "
|
||||||
|
"above. Finish this sub-task, staying inside the approved plan."
|
||||||
|
)
|
||||||
|
result = self._delegate.run(
|
||||||
|
brief=brief, branch=f"delegation/milestone-{milestone}-{failures}"
|
||||||
|
)
|
||||||
|
if result.usable:
|
||||||
|
return None
|
||||||
|
# A13: a cap breach or failure leaves a half-edited tree, so the local
|
||||||
|
# loop must not resume on top of it.
|
||||||
|
return ExecutorOutcome(
|
||||||
|
"escalate", f"{note}; delegation {result.status}: {result.note}"
|
||||||
|
)
|
||||||
|
|
||||||
def _tools(self) -> list[dict[str, Any]]:
|
def _tools(self) -> list[dict[str, Any]]:
|
||||||
# A12: ask_expert exists in addition to the observable escalation
|
# A12: ask_expert exists in addition to the observable escalation
|
||||||
@@ -152,11 +187,18 @@ class LLMExecutor:
|
|||||||
0 if outcome.success else consecutive_failures + 1
|
0 if outcome.success else consecutive_failures + 1
|
||||||
)
|
)
|
||||||
if consecutive_failures >= self._failure_limit:
|
if consecutive_failures >= self._failure_limit:
|
||||||
return ExecutorOutcome(
|
# A12: this is the trigger delegation exists for.
|
||||||
"escalate",
|
# Escalating to the human is the fallback when there
|
||||||
f"{consecutive_failures} consecutive command failures; "
|
# is no delegate, not the intended path.
|
||||||
f"last: {outcome.result[:500]}",
|
escalation = self._escalate(
|
||||||
|
milestone,
|
||||||
|
consecutive_failures,
|
||||||
|
outcome.result,
|
||||||
)
|
)
|
||||||
|
if escalation is not None:
|
||||||
|
return escalation
|
||||||
|
consecutive_failures = 0
|
||||||
|
did_work = True
|
||||||
|
|
||||||
return ExecutorOutcome(
|
return ExecutorOutcome(
|
||||||
"capped", f"hit the {self._max_turns}-turn cap for milestone {milestone}"
|
"capped", f"hit the {self._max_turns}-turn cap for milestone {milestone}"
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ class GitRepo:
|
|||||||
def checkout_new_branch(self, name: str) -> None:
|
def checkout_new_branch(self, name: str) -> None:
|
||||||
self._run("checkout", "-q", "-b", name)
|
self._run("checkout", "-q", "-b", name)
|
||||||
|
|
||||||
|
def checkout_existing_branch(self, name: str) -> None:
|
||||||
|
self._run("checkout", "-q", name)
|
||||||
|
|
||||||
|
def branch_exists(self, name: str) -> bool:
|
||||||
|
try:
|
||||||
|
self._run("rev-parse", "--verify", "--quiet", f"refs/heads/{name}")
|
||||||
|
except GitError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def commit_all(self, message: str) -> str | None:
|
def commit_all(self, message: str) -> str | None:
|
||||||
"""Returns the new commit sha, or None when there was nothing to
|
"""Returns the new commit sha, or None when there was nothing to
|
||||||
commit — an empty milestone is a fact to report, not an error."""
|
commit — an empty milestone is a fact to report, not an error."""
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ from nisse_dev.canonical import (
|
|||||||
DelegationStarted,
|
DelegationStarted,
|
||||||
StoredEvent,
|
StoredEvent,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
|
ToolResult,
|
||||||
)
|
)
|
||||||
from nisse_dev.delegation import (
|
from nisse_dev.delegation import (
|
||||||
DELEGATE_TOOLS,
|
DELEGATE_TOOLS,
|
||||||
|
DELEGATION_INTENT,
|
||||||
Caps,
|
Caps,
|
||||||
Delegation,
|
Delegation,
|
||||||
|
delegation_reconciler,
|
||||||
)
|
)
|
||||||
from nisse_dev.git import GitRepo
|
from nisse_dev.git import GitRepo
|
||||||
from nisse_dev.llm import LLMError
|
from nisse_dev.llm import LLMError
|
||||||
@@ -302,3 +305,205 @@ def test_failure_may_be_reported_without_doing_work(
|
|||||||
assert result.status == "failed"
|
assert result.status == "failed"
|
||||||
assert not result.usable
|
assert not result.usable
|
||||||
assert len(client.calls) == 1 # believed immediately, not challenged
|
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