From c4726d5673b11d19694aded4e5d7fdeb449cc66b Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Thu, 8 Oct 2026 14:28:59 +0000 Subject: [PATCH] Say a local commit publishes nothing (#129) The local driver sometimes ran `git commit` itself instead of calling finish_milestone, then told the operator a PR was waiting when none had been opened. A commit still runs (it's contained, and publishing takes whatever is there), but when the driver's command commits anywhere in its line, the harness adds to its result: committed locally only; nothing is published until finish_milestone, and there is no PR until then. Structural, at the moment the model would draw the wrong conclusion (P6), rather than left to it noticing. Closes #129 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE --- nisse-dev/src/nisse_dev/loop/actions.py | 18 +++++++++ nisse-dev/src/nisse_dev/loop/agent.py | 11 +++++- nisse-dev/src/nisse_dev/loop/driver.py | 3 ++ nisse-dev/tests/test_loop_actions.py | 51 ++++++++++++++++++++++++- 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/nisse-dev/src/nisse_dev/loop/actions.py b/nisse-dev/src/nisse_dev/loop/actions.py index 71e44b0..42d9963 100644 --- a/nisse-dev/src/nisse_dev/loop/actions.py +++ b/nisse-dev/src/nisse_dev/loop/actions.py @@ -35,6 +35,8 @@ from nisse_core.actions import ( classify_shell, inside, resolve, + split_shell, + unwrap, worst, ) @@ -87,6 +89,22 @@ _BRANCHES = ( ) +def commits(action: Action) -> bool: + """Whether the call runs a `git commit` anywhere in its line (#129).""" + if action.tool != "run_command": + return False + for argv, _ in split_shell(str(action.arguments.get("command", ""))) or []: + argv = unwrap(argv) + if argv[:1] != ["git"]: + continue + args = argv[1:] + while args and args[0] in ("-C", "-c"): + args = args[2:] + if args[:1] == ["commit"]: + return True + return False + + def classify(action: Action, root: str) -> Verdict: """The fast path's verdict on one execution tool call in the workspace at ``root``.""" diff --git a/nisse-dev/src/nisse_dev/loop/agent.py b/nisse-dev/src/nisse_dev/loop/agent.py index ab4457c..5ba30c2 100644 --- a/nisse-dev/src/nisse_dev/loop/agent.py +++ b/nisse-dev/src/nisse_dev/loop/agent.py @@ -44,7 +44,7 @@ from nisse_dev.expert import BudgetExhausted, ExpertDesk from nisse_dev.git import GitError from nisse_dev.gitea import GiteaClient, GiteaError, Issue, PullRequest from nisse_dev.llm import LLMError, RoleMap -from nisse_dev.loop.actions import classify +from nisse_dev.loop.actions import classify, commits from nisse_dev.loop.classifier import IN_SCOPE, ActionClassifier, Scope from nisse_dev.loop.driver import ASK_ALLOW, Driver, RunOutcome, Screening from nisse_dev.loop.help import ASK_EXPERT, DELEGATE, Helpers @@ -92,6 +92,12 @@ _NEXT = re.compile(rf"(?:do|work on) {_ISSUE} (?:next|first|now)") # A command that names its item applies to it, wherever it is said (L5). _NAMED = re.compile(r"(go|approve|abandon|stop|allow) (?:([a-z0-9][a-z0-9-]*))?#(\d+)") # At top level these need an item; without one they are ambiguous (L5). +# Added to a command's result when it commits (#129). +_COMMITTED = ( + "committed locally only: nothing is published until you call " + "finish_milestone, which pushes the milestone's branch and opens or " + "updates its PR. There is no PR until then." +) _NEEDS_ITEM = frozenset({"go", "approve", "abandon", "allow"}) # Near-misses for abandon: asked about, never taken for it or for a reply. _NEAR_ABANDON = frozenset( @@ -1717,6 +1723,9 @@ class LoopAgent: screening = Screening("ask", self._ask_text(item, action, why), key) else: decision, screening = "ran", Screening("run") + if screening.decision == "run" and commits(action): + # #129: a local commit looked like publishing to the model. + screening = replace(screening, note=_COMMITTED) self._verdict(item_id, "driver", call, verdict, decision, judged) return screening diff --git a/nisse-dev/src/nisse_dev/loop/driver.py b/nisse-dev/src/nisse_dev/loop/driver.py index 38bbbae..52d5d4c 100644 --- a/nisse-dev/src/nisse_dev/loop/driver.py +++ b/nisse-dev/src/nisse_dev/loop/driver.py @@ -50,6 +50,7 @@ class Screening: decision: Literal["run", "refuse", "ask"] text: str = "" # the call's result when not run; for ask, the question key: str = "" # for ask: what an allow would bind to (L3) + note: str = "" # for run: what the harness adds to the call's result class Driver: @@ -198,6 +199,8 @@ class Driver: break outcome = workspace.dispatch(call.tool, call.arguments) result, ok = outcome.result, outcome.success + if screening.note: + result = f"{result}\n\n[harness] {screening.note}" transcript.append( ToolResult(call_id=call.call_id, result=result, success=ok) ) diff --git a/nisse-dev/tests/test_loop_actions.py b/nisse-dev/tests/test_loop_actions.py index 82381a4..f77b4ad 100644 --- a/nisse-dev/tests/test_loop_actions.py +++ b/nisse-dev/tests/test_loop_actions.py @@ -12,8 +12,8 @@ from test_loop_milestones import item as the_item from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture from nisse_core.actions import Action -from nisse_dev.canonical import ActionVerdict, ToolResult -from nisse_dev.loop.actions import classify +from nisse_dev.canonical import ActionVerdict, ToolCall, ToolResult +from nisse_dev.loop.actions import classify, commits from nisse_dev.loop.items import ANSWER, loop_of ROOT = "/srv/work/demo" @@ -355,3 +355,50 @@ def test_a_workspace_under_a_host_directory_is_still_the_workspace() -> None: Action("run_command", {"command": "rm -rf dist"}), "/home/ci/work/demo" ) assert home.effect == "contained" + + +# --- #129: a local commit is said to be one ------------------------------------ + + +@pytest.mark.parametrize( + ("command", "commit"), + [ + ("git commit -m wip", True), + ("git add -A && git commit -qm 'fix'", True), + ("git -C sub commit -am x", True), + ("cd src && sudo git commit --amend --no-edit", True), + ("git status", False), + ("git log --grep commit", False), + ("echo git commit", False), + ], +) +def test_a_commit_is_recognised_anywhere_in_a_line(command: str, commit: bool) -> None: + assert commits(Action("run_command", {"command": command})) is commit + + +def test_a_local_commit_is_said_to_publish_nothing(world: dict[str, Any]) -> None: + model = started( + [ + call("write_file", path="w.py", content="W = 1\n"), + call("run_command", command="git add -A && git commit -qm wip"), + call("run_command", command="git status --short"), + call("ask_human", question="?"), + ] + ) + agent, transport = loop_agent(world, model) + agent.intake() + say(transport, world, "go") + events = world["store"].transcript(the_item(world).id, "driver").read() + calls = { + e.event.call_id: e.event.arguments.get("command", "") + for e in events + if isinstance(e.event, ToolCall) and e.event.tool == "run_command" + } + results = { + calls[e.event.call_id]: e.event.result + for e in events + if isinstance(e.event, ToolResult) and e.event.call_id in calls + } + assert "committed locally only" in results["git add -A && git commit -qm wip"] + assert "There is no PR until then" in results["git add -A && git commit -qm wip"] + assert "committed locally" not in results["git status --short"] -- 2.54.0