From 76d8e36f43e40b23658318470bbecd3cd6d63e66 Mon Sep 17 00:00:00 2001 From: Don Garrett Date: Sat, 17 Oct 2026 16:00:31 +0000 Subject: [PATCH] nisse-dev: a VM lost as a helper's work is taken back ends the run The property walk (seed 468, a CI run's moving seed) found it: when the build VM went out of reach while _delegate moved the helper's work back onto the milestone's branch, git's HostUnavailable came back as a GitError, was noted as a fact, and the driver's run went on, calling the model with its VM gone (no_model_on_a_lost_vm). Now a GitError caused by a lost VM raises VMUnreachable once the delegation's intents are settled, so the run ends and the VM is blocked, as for any other lost-VM call (#137). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk --- nisse-dev/src/nisse_dev/loop/agent.py | 5 ++++ nisse-dev/tests/test_loop_vm.py | 39 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/nisse-dev/src/nisse_dev/loop/agent.py b/nisse-dev/src/nisse_dev/loop/agent.py index 969a40c..10ab567 100644 --- a/nisse-dev/src/nisse_dev/loop/agent.py +++ b/nisse-dev/src/nisse_dev/loop/agent.py @@ -2111,6 +2111,7 @@ class LoopAgent: result = delegation.run( self._store.transcript(item.id, "helper"), brief=brief, branch=branch ) + lost: GitError | None = None try: # The milestone takes the helper's work (or, on failure, only # its own back) as uncommitted changes: harness branches never @@ -2122,6 +2123,8 @@ class LoopAgent: git._git("branch", "-q", "-D", branch) except GitError as exc: self._fact(item, f"after helper {number}: {exc}") + if isinstance(exc.__cause__, HostUnavailable): + lost = exc # the VM went: the driver's run ends too (#137) for intent in self._store.pending_intents(): # Cut off rather than finished: discarded all the same, never # resumed blindly (L8, L9). @@ -2130,6 +2133,8 @@ class LoopAgent: and intent.payload.get("branch") == branch ): self._store.abort_intent(intent.id, cause=f"delegation {result.status}") + if lost is not None: + raise VMUnreachable(str(lost)) from lost if result.usable and result.handback is not None: self._fact(item, f"helper {number} finished: {result.handback.render()}") return f"the helper finished:\n{result.handback.render()}", True diff --git a/nisse-dev/tests/test_loop_vm.py b/nisse-dev/tests/test_loop_vm.py index e9e1fb4..756724b 100644 --- a/nisse-dev/tests/test_loop_vm.py +++ b/nisse-dev/tests/test_loop_vm.py @@ -338,3 +338,42 @@ def test_a_vm_lost_just_before_the_step_blocks_before_any_model_call( agent, _ = agent_for(world, project, model, clock=Clock()) agent.intake() assert list(blocks(world).values()) == ["unreachable"] + + +def test_a_vm_lost_as_a_helper_hands_back_ends_the_drivers_run( + world: dict[str, Any], project: str, monkeypatch: pytest.MonkeyPatch +) -> None: + # Found by the property walk (seed 468): taking the helper's work back + # onto the milestone's branch failed for the lost VM, was noted as a git + # error, and the driver's model was called with the VM gone. + from test_loop_help import Spy, hand_back, helpers + + faults = Faults(monkeypatch, world["host"]) + + class Leaving(Spy): + def complete(self, events, tools, model, system=None) -> list[Any]: + out = super().complete(events, tools, model, system) + if not self.turns: # gone as its work is taken back + faults.drop_on = "checkout -q -f -B" + return out + + helper = Leaving( + [call("write_file", path="helped.py", content="H = 1\n")], [hand_back()] + ) + model = ScriptedModel( + ready(), + RESTATE, + [ + call("start_milestone", name="m"), + call("delegate", task="write helped.py"), + ], + [call("run_command", command="echo never")], # never asked for + ) + agent, transport = agent_for( + world, project, model, clock=Clock(), helpers=helpers(helper) + ) + agent.intake() + say(transport, world, "go") + assert list(blocks(world).values()) == ["unreachable"] + assert the_item(world).position == READY + assert len(model.turns) == 1 # the driver's model wasn't called again -- 2.54.0