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
@@ -37,7 +37,8 @@ from nisse_dev.canonical import (
|
||||
)
|
||||
from nisse_dev.git import GitError, GitRepo
|
||||
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
|
||||
|
||||
DELEGATION_INTENT = "delegation"
|
||||
@@ -169,6 +170,19 @@ class Delegation:
|
||||
) -> DelegationResult:
|
||||
started_at = self._now()
|
||||
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:
|
||||
# Commit whatever the caller had in flight onto their own 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.checkout_new_branch(branch)
|
||||
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}")
|
||||
|
||||
self._store.append_event(
|
||||
@@ -201,8 +216,77 @@ class Delegation:
|
||||
# Abort discards the branch: the caller must never resume on top
|
||||
# of a partially-edited tree (A13).
|
||||
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
|
||||
|
||||
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:
|
||||
system = (
|
||||
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(),
|
||||
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
|
||||
Reference in new issue
Block a user