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
@@ -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}"
|
||||
|
||||
Reference in new issue
Block a user