From 463817ee3484fd43139c192f1e65cd24f8e129b5 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 18 Oct 2026 12:35:27 +0000 Subject: [PATCH] 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 --- .../scripts/stop_session.sh | 2 +- tests/test_issue60_linux_fixes.py | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index d315126..10f5fe8 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -239,7 +239,7 @@ graceful_stop() { if [ "$exitkey" = "Ctrl+C" ] || [ "$exitkey" = "C-c" ]; then herdr send-keys -t "$SESSION_NAME" C-c 2>/dev/null || true else - send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain" + send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: delivery of '$exitkey' unconfirmed (rc=$?) — waiting for the session to exit" fi _wait_session_gone "$SESSION_NAME" 5 || true if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then diff --git a/tests/test_issue60_linux_fixes.py b/tests/test_issue60_linux_fixes.py index 15899d0..1277892 100644 --- a/tests/test_issue60_linux_fixes.py +++ b/tests/test_issue60_linux_fixes.py @@ -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