Wire delegation to A12's trigger, and implement A3's resume rule
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:
dongarandClaude Opus 5 committed 2026-09-08 20:03:46 +00:00
1 parent 608a3a5bfb
commit ce436262f4
5 files changed
+414 -5

No files matched your search

+46 -4
View File
@@ -14,6 +14,7 @@ from dataclasses import dataclass
from typing import Any, Literal
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.llm import LLMClient, LLMError, RoleMap
from nisse_dev.state import StateStore
@@ -58,6 +59,7 @@ class LLMExecutor:
max_turns: int = DEFAULT_MAX_TURNS,
failure_limit: int = DEFAULT_FAILURE_LIMIT,
expert: ExpertDesk | None = None,
delegate: Delegation | None = None,
) -> None:
self._client = client
self._store = store
@@ -66,6 +68,39 @@ class LLMExecutor:
self._max_turns = max_turns
self._failure_limit = failure_limit
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]]:
# A12: ask_expert exists in addition to the observable escalation
@@ -152,11 +187,18 @@ class LLMExecutor:
0 if outcome.success else consecutive_failures + 1
)
if consecutive_failures >= self._failure_limit:
return ExecutorOutcome(
"escalate",
f"{consecutive_failures} consecutive command failures; "
f"last: {outcome.result[:500]}",
# A12: this is the trigger delegation exists for.
# Escalating to the human is the fallback when there
# is no delegate, not the intended path.
escalation = self._escalate(
milestone,
consecutive_failures,
outcome.result,
)
if escalation is not None:
return escalation
consecutive_failures = 0
did_work = True
return ExecutorOutcome(
"capped", f"hit the {self._max_turns}-turn cap for milestone {milestone}"