fix(stop): an unconfirmed /exit no longer claims a kill-chain fallback (Issue #60, Linux item 2)

An agent that exits on /exit never turns `working`, so send_keys_safe
returns 4 although the stop then ends cleanly. The line now says the
delivery is unconfirmed and that the stop waits for the session to exit;
the kill steps keep their own lines, printed only when they run. Exit
judgement and the kill chain are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-18 12:35:27 +00:00
1 parent 614ab1420f
commit 463817ee34
2 files changed
+65 -1

No files matched your search

+64
View File
@@ -7,14 +7,18 @@
the sidecars on close; a plain mode=ro open creates missing ones).
6. `debug paths` (which writes a line to opencode's log) runs once per
(binary, HOME, XDG_DATA_HOME) in a process.
2. A graceful stop whose `/exit` worked says so: the agent exits instead of
turning `working`, so the delivery is unconfirmed, and no kill step ran.
"""
from __future__ import annotations
import os
import re
import json
import shutil
import sqlite3
import subprocess
import sys
from pathlib import Path
@@ -213,3 +217,63 @@ def test_a_failed_debug_paths_is_not_remembered(tmp_path, monkeypatch):
default = str(tmp_path / "home" / ".local" / "share" / "opencode" / "opencode.db")
assert [adapter._resolve_db(ctx) for _ in range(2)] == [default] * 2
assert log.read_text().splitlines() == ["debug paths"] * 2
# ---------------------------------------------------------------------------
# 2. the stop's words match what happened
# ---------------------------------------------------------------------------
SESSION = "i60-stop-opencode"
# A herdr in front of the conftest mock that, like a real opencode 2.x pane
# (Linux check 3-3), ends the agent on `/exit`: the prompt's wait for
# `working` then fails because the agent is gone.
EXITING_HERDR = """#!/usr/bin/env python3
import json, os, sys
args = sys.argv[1:]
if "prompt" in args and "/exit" in args:
with open(@STATE@) as f:
state = json.load(f)
state["agents"].pop(@NAME@, None)
state.setdefault("calls", []).append("herdr " + " ".join(args))
with open(@STATE@, "w") as f:
json.dump(state, f)
sys.stderr.write(json.dumps({"error": {"code": "agent_exited", "message": "agent exited"}}) + "\\n")
sys.exit(1)
os.execv(@MOCK@, [@MOCK@] + args)
"""
@pytest.mark.parametrize("exits", [True, False])
def test_the_graceful_stop_reports_what_happened(mam_sandbox, mock_herdr, mock_agents, monkeypatch, exits):
state = json.loads(Path(mock_herdr).read_text())
state["agents"][SESSION] = {"status": "running", "agent": "opencode", "agent_status": "idle",
"cwd": str(mam_sandbox), "pid": 12345, "pane_id": "w1:p1",
"command": "opencode", "buffer": "Ask anything"}
Path(mock_herdr).write_text(json.dumps(state, indent=2))
lib = mam_sandbox / ".agents" / "skills" / "lib.sh"
row = ("d['herdr_sessions'] = [{'name': '%s', 'agent': 'opencode', 'status': 'running', "
"'pane': {'cwd': '%s', 'pid': 12345, 'cmd': 'opencode'}}]" % (SESSION, mam_sandbox))
res = subprocess.run(["bash", "-c", f"source {lib} && atomic_dump_yaml {mam_sandbox / '.mam' / 'agent-sessions.yaml'}"],
input=row, capture_output=True, text=True)
assert res.returncode == 0, res.stderr
if exits:
wrap = mam_sandbox / "exitbin" / "herdr"
wrap.parent.mkdir()
wrap.write_text(EXITING_HERDR.replace("@STATE@", repr(str(mock_herdr))).replace("@NAME@", repr(SESSION))
.replace("@MOCK@", repr(shutil.which("herdr"))))
wrap.chmod(0o755)
monkeypatch.setenv("PATH", f"{wrap.parent}:{os.environ['PATH']}")
stop = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(stop), "--session", SESSION, "--agent", "opencode"],
capture_output=True, text=True, timeout=180)
out = res.stdout + res.stderr
assert res.returncode == 0, out
calls = json.loads(Path(mock_herdr).read_text()).get("calls", [])
assert any("prompt" in c and "/exit" in c for c in calls), calls
assert "falling back to kill chain" not in out
if exits:
assert "unconfirmed" in out and "graceful: exited cleanly" in out, out
assert "kill-session" not in out and not any("kill-session" in c for c in calls), out
else:
# The kill step runs, so it is named.
assert "still alive → kill-session" in out, out