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

@@ -239,7 +239,7 @@ graceful_stop() {
if [ "$exitkey" = "Ctrl+C" ] || [ "$exitkey" = "C-c" ]; then if [ "$exitkey" = "Ctrl+C" ] || [ "$exitkey" = "C-c" ]; then
herdr send-keys -t "$SESSION_NAME" C-c 2>/dev/null || true herdr send-keys -t "$SESSION_NAME" C-c 2>/dev/null || true
else 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 fi
_wait_session_gone "$SESSION_NAME" 5 || true _wait_session_gone "$SESSION_NAME" 5 || true
if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
+64
View File
@@ -7,14 +7,18 @@
the sidecars on close; a plain mode=ro open creates missing ones). 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 6. `debug paths` (which writes a line to opencode's log) runs once per
(binary, HOME, XDG_DATA_HOME) in a process. (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 from __future__ import annotations
import os import os
import re import re
import json
import shutil import shutil
import sqlite3 import sqlite3
import subprocess
import sys import sys
from pathlib import Path 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") default = str(tmp_path / "home" / ".local" / "share" / "opencode" / "opencode.db")
assert [adapter._resolve_db(ctx) for _ in range(2)] == [default] * 2 assert [adapter._resolve_db(ctx) for _ in range(2)] == [default] * 2
assert log.read_text().splitlines() == ["debug paths"] * 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