Files
multi-agent-mux/tests/test_issue60_opencode_v2.py
T
GodopuandClaude Opus 5.5 feccfd5e03 fix(agents): pin post-discovered session ids on a verified completed; agy never takes another workspace's or session's conversation (Issue #40)
- BaseAgentAdapter: id_lifecycle (pre_assigned: claude, grok / post_discovered)
  and capture_active_session_id - exactly one verified candidate newer than the
  session that no other row records, else a reason (no-candidate, ambiguous, ...).
- agy: verify_artifact (discover) rejects an id last_conversations.json maps to
  another workspace and always excludes ids other rows record (a cache hit no
  longer bypasses it). capture uses the cache only as a proposal: the
  conversation's presence lock or DB must be held by the row's pane process or
  a descendant (lsof/ps); anything unprovable is not pinned.
- lib_py/agents/autopin.py: handle_completed, run by the side that verified the
  job's completed - the delegate-job wrapper (direct), create --onboard and the
  loop's wait_for_job (recovery, as a subprocess: G9). Local herdr jobs only;
  one (job, dispatch token, attempt) is handled once (job-scoped flock + marker
  in the job dir); writes go through atomic_dump_yaml; a pin records
  session_id_source=discovered / session_id_verified=true; a failure leaves the
  reason on the row and never changes the job's result.
- reconcile.sh drift C for agy, hermes, opencode and codex calls the same
  capture (the agy *.db glob is gone); _pin_and_verify_resume uses pin_row.
- Tests: tests/test_issue40_auto_pin.py; issue64 :407 now expects False; the
  static reconcile-source checks follow the move to the adapters; the two tier2
  drift-C component tests create with --no-onboard (onboarding now pins).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:40:03 +09:00

355 lines
16 KiB
Python

"""Issue #60: opencode 2.x.
Measured on opencode 2.0.32 (macOS): the TUI rejects `--agent`, the database
keeps sessions in session_v2 (a fresh 2.x database has no `session` table; a
migrated one keeps the 1.x tables but no longer writes them), `db path` is
gone (`debug paths` prints the db), and `session delete` removes a session
with its child sessions from the 2.x tables only.
The unit tests use the fake opencode of tests/_fake_opencode.py. The last
test runs a real opencode 2.x, when one is installed, in a home and tmp dir
of its own with --standalone (no background service is started or reached).
"""
from __future__ import annotations
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
sys.path.insert(0, str(SKILLS))
from lib_py.agents.registry import get_adapter # noqa: E402
from lib_py.agents.base import DiscoveryContext # noqa: E402
import _fake_opencode # noqa: E402
V2_DDL = ("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)",
"CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)",
"CREATE TABLE event_sequence (aggregate_id TEXT PRIMARY KEY, seq INTEGER)")
@pytest.fixture
def oc(tmp_path, monkeypatch):
"""The opencode adapter on a home of its own, with a fake opencode that
logs every call."""
home = tmp_path / "home"
ws = tmp_path / "ws"
ws.mkdir(parents=True)
log = tmp_path / "calls.log"
fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode-real")
wrapper = tmp_path / "fakebin" / "opencode"
wrapper.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{fake}" "$@"\n')
wrapper.chmod(0o755)
adapter = get_adapter("opencode")
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(wrapper))
ctx = DiscoveryContext(workspace=str(ws), agent_name="opencode", home_dir=str(home))
db = home / ".local" / "share" / "opencode" / "opencode.db"
db.parent.mkdir(parents=True)
conn = sqlite3.connect(str(db))
for stmt in V2_DDL:
conn.execute(stmt)
conn.commit()
conn.close()
def calls():
return log.read_text().splitlines() if log.exists() else []
return adapter, ctx, db, ws, calls
def _add(db, sid, ws, ms, parent=None):
conn = sqlite3.connect(str(db))
conn.execute("INSERT INTO session_v2 VALUES (?, ?, ?, ?)", (sid, parent, str(ws), ms))
conn.execute("INSERT INTO session_message VALUES (?, ?)", (f"msg-{sid}", sid))
conn.execute("INSERT INTO event_sequence VALUES (?, 1)", (sid,))
conn.commit()
conn.close()
def _rows(db, sid):
conn = sqlite3.connect(str(db))
try:
return (conn.execute("SELECT count(*) FROM session_v2 WHERE id=?", (sid,)).fetchone()[0]
+ conn.execute("SELECT count(*) FROM session_message WHERE session_id=?", (sid,)).fetchone()[0]
+ conn.execute("SELECT count(*) FROM event_sequence WHERE aggregate_id=?", (sid,)).fetchone()[0])
finally:
conn.close()
def test_the_db_comes_from_debug_paths(oc):
adapter, ctx, db, ws, calls = oc
assert adapter._resolve_db(ctx) == str(db)
assert calls() == ["debug paths"]
def test_discover_and_verify_read_session_v2_and_skip_child_sessions(oc):
adapter, ctx, db, ws, calls = oc
_add(db, "ses_main", ws, 2_000_000)
_add(db, "ses_child", ws, 3_000_000, parent="ses_main") # a subagent's session: newer, same dir
_add(db, "ses_old", ws, 500_000)
ctx.epoch = 1000
assert adapter.discover(ctx) == ["ses_main"]
assert adapter.verify_artifact("ses_main", ctx) is True
assert adapter.verify_artifact("ses_old", ctx) is False
def test_a_fresh_2x_database_has_no_session_table_and_still_works(oc):
adapter, ctx, db, ws, calls = oc
_add(db, "ses_main", ws, 2_000_000)
conn = sqlite3.connect(str(db))
assert conn.execute("SELECT count(*) FROM sqlite_master WHERE name='session'").fetchone()[0] == 0
conn.close()
assert adapter.discover(ctx) == ["ses_main"]
def test_purge_asks_opencode_to_delete_the_session_and_its_children(oc):
adapter, ctx, db, ws, calls = oc
_add(db, "ses_main", ws, 2_000_000)
_add(db, "ses_child", ws, 3_000_000, parent="ses_main")
_add(db, "ses_other", ws, 2_500_000)
purged = adapter.purge_artifacts("ses_main", ctx)
assert purged == ["opencode session (with its child sessions): ses_main"] # no 1.x tables: nothing else
assert "session delete --standalone -- ses_main" in calls()
assert _rows(db, "ses_main") == 0 and _rows(db, "ses_child") == 0
assert _rows(db, "ses_other") == 3
assert adapter.residual_artifacts("ses_main", ctx) == []
def test_purge_does_not_run_opencode_for_a_session_it_does_not_hold(oc):
adapter, ctx, db, ws, calls = oc
assert adapter.purge_artifacts("ses_missing", ctx) == []
assert not any(c.startswith("session delete") for c in calls())
@pytest.mark.parametrize("broken", ["exit", "missing"])
def test_a_failed_delete_is_warned_and_reported(oc, monkeypatch, capsys, broken):
adapter, ctx, db, ws, calls = oc
_add(db, "ses_main", ws, 2_000_000)
if broken == "missing":
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
want = "no opencode binary"
else:
failing = Path(ctx.home_dir).parent / "fakebin" / "failing"
failing.write_text('#!/bin/sh\n[ "$1" = debug ] && exec "$(dirname "$0")/opencode-real" "$@"\necho boom >&2\nexit 3\n')
failing.chmod(0o755)
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(failing))
want = "exited 3: boom"
assert adapter.purge_artifacts("ses_main", ctx) == []
assert want in capsys.readouterr().err
assert f"{adapter._resolve_db(ctx)} (session_v2.id: 1 rows)" in adapter.residual_artifacts("ses_main", ctx)
def test_purge_takes_the_1x_rows_of_the_child_sessions_too(oc):
"""A migrated database: the child sessions opencode deletes with their
parent keep 1.x rows that only the purge removes (review 614b6263)."""
adapter, ctx, db, ws, calls = oc
conn = sqlite3.connect(str(db))
conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT)")
conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)")
conn.execute("CREATE TABLE todo (id TEXT, session_id TEXT)")
conn.commit()
conn.close()
tree = [("ses_main", None), ("ses_child", "ses_main"), ("ses_grand", "ses_child"), ("ses_other", None)]
for sid, parent in tree:
_add(db, sid, ws, 2_000_000, parent=parent)
conn = sqlite3.connect(str(db))
conn.execute("INSERT INTO session VALUES (?, ?)", (sid, parent))
conn.execute("INSERT INTO message VALUES (?, ?)", (f"m-{sid}", sid))
conn.execute("INSERT INTO todo VALUES ('t', ?)", (sid,))
conn.commit()
conn.close()
assert any(r.endswith("[child session ses_grand]") for r in adapter.residual_artifacts("ses_main", ctx))
purged = adapter.purge_artifacts("ses_main", ctx)
assert purged == ["opencode session (with its child sessions): ses_main",
"sqlite rows for session: ses_main and 2 child session(s)"]
for sid in ("ses_main", "ses_child", "ses_grand"):
assert adapter.residual_artifacts(sid, ctx) == [], sid
# the unrelated session is untouched: session_v2, session_message, event_sequence, session, message, todo
assert len(adapter.residual_artifacts("ses_other", ctx)) == 6
def test_a_failed_delete_reports_the_child_sessions_left(oc, monkeypatch):
adapter, ctx, db, ws, calls = oc
_add(db, "ses_main", ws, 2_000_000)
_add(db, "ses_child", ws, 2_000_000, parent="ses_main")
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
adapter.purge_artifacts("ses_main", ctx)
residual = adapter.residual_artifacts("ses_main", ctx)
assert f"{db} (session_v2.id: 1 rows) [child session ses_child]" in residual
assert f"{db} (session_v2.id: 1 rows)" in residual
def _holder(tmp_path, db, *argv):
"""A live process holding `db` open, with `argv` on its command line."""
ready = tmp_path / f"ready-{len(argv)}"
code = ("import sys, time; f = open(sys.argv[1]); open(sys.argv[2], 'w').close(); time.sleep(60)")
p = subprocess.Popen([sys.executable, "-c", code, str(db), str(ready), *argv])
for _ in range(100):
if ready.exists():
break
time.sleep(0.05)
return p
@pytest.mark.skipif(shutil.which("lsof") is None, reason="no lsof")
def test_the_background_service_alone_does_not_hold_a_session(oc, tmp_path):
"""The per-user service keeps the shared database open for every session
(2.x); MAM runs none of its sessions there."""
adapter, ctx, db, ws, calls = oc
_add(db, "ses_main", ws, 2_000_000)
assert adapter.conversation_in_use("ses_main", ctx) is False
service = _holder(tmp_path, db, "serve", "--service")
try:
assert adapter.conversation_in_use("ses_main", ctx) is False
other = _holder(tmp_path, db, "--auto", "--standalone") # another session's TUI or its server
try:
assert adapter.conversation_in_use("ses_main", ctx) is True
finally:
other.kill()
other.wait()
finally:
service.kill()
service.wait()
named = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)", "--session", "ses_main"])
try:
time.sleep(0.2)
assert adapter.conversation_in_use("ses_main", ctx) is True
finally:
named.kill()
named.wait()
def test_the_data_dir_follows_the_callers_xdg_data_home_only_for_its_own_home(tmp_path, monkeypatch):
adapter = get_adapter("opencode")
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
own, other, xdg = tmp_path / "own", tmp_path / "other", tmp_path / "xdg"
monkeypatch.setenv("HOME", str(own))
monkeypatch.setenv("XDG_DATA_HOME", str(xdg))
ws = str(tmp_path)
assert adapter._resolve_db(DiscoveryContext(workspace=ws, agent_name="opencode", home_dir=str(own))) \
== f"{xdg}/opencode/opencode.db"
assert adapter._resolve_db(DiscoveryContext(workspace=ws, agent_name="opencode", home_dir=str(other))) \
== f"{other}/.local/share/opencode/opencode.db"
monkeypatch.delenv("XDG_DATA_HOME")
assert adapter._resolve_db(DiscoveryContext(workspace=ws, agent_name="opencode", home_dir=str(own))) \
== f"{own}/.local/share/opencode/opencode.db"
def test_the_standalone_server_is_a_resident_child_but_its_tools_are_work():
"""--standalone keeps `opencode serve --stdio` under the TUI while idle;
the stall watcher skips it but still sees what runs under it."""
from lib_py.stall_watch import background_children
resident = get_adapter("opencode").resident_children
tui = subprocess.Popen(["bash", "-c", "(exec -a opencode sleep 300) & exec sleep 300"], start_new_session=True)
busy = subprocess.Popen(["bash", "-c", "(exec -a opencode bash -c 'sleep 300; :') & exec sleep 300"],
start_new_session=True)
try:
time.sleep(0.5)
assert background_children(tui.pid, ()) != []
assert background_children(tui.pid, resident) == []
assert background_children(busy.pid, resident) != []
finally:
for p in (tui, busy):
os.killpg(p.pid, 9) # the whole group: children and grandchildren
p.wait()
def test_spawn_and_resume_never_pass_agent_and_run_standalone():
"""The shell copies of the opencode command line match the adapter."""
adapter = get_adapter("opencode")
assert adapter.spawn_spec("opencode") == "opencode --auto --standalone"
assert adapter.resume_spec("opencode", "ses_x", materialized=True) == "opencode --session ses_x --auto --standalone"
for rel in ("multi-agent-mux-create/scripts/create_session.sh",
"multi-agent-mux-resume/scripts/resume_session.sh",
"multi-agent-mux-resume/scripts/update_yaml_resumed.sh"):
text = (SKILLS / rel).read_text()
lines = [l for l in text.splitlines() if "opencode" in l and "--auto" in l]
assert lines, rel
for l in lines:
assert "--agent" not in l and "--standalone" in l, (rel, l)
def test_status_and_reconcile_read_session_v2():
for rel in ("multi-agent-mux-status/scripts/status.sh", "multi-agent-mux-monitor/scripts/reconcile.sh"):
text = (SKILLS / rel).read_text()
assert not re.search(r"FROM session\b(?!_)|table_info\(session\)", text), rel
# Issue #40: reconcile's drift C reads session_v2 through the adapter
assert ("session_v2" in text) if "status" in rel else ("autopin.capture(" in text), rel
# ---------------------------------------------------------------------------
# a real opencode 2.x, in a home of its own
# ---------------------------------------------------------------------------
def _real_opencode():
path = shutil.which("opencode") or os.path.expanduser("~/.opencode/bin/opencode")
if not os.path.isfile(path):
return None
import tempfile
try:
# Even --version writes opencode's log under its data dir: give it a
# home of its own (review 8a485fa0 N2).
with tempfile.TemporaryDirectory() as tmp:
env = {"PATH": os.environ.get("PATH", ""), "HOME": tmp, "TMPDIR": tmp}
out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20, env=env).stdout
except Exception:
return None
m = re.search(r"(\d+)\.\d+\.\d+", out)
return path if m and int(m.group(1)) >= 2 else None
REAL = _real_opencode()
@pytest.mark.skipif(REAL is None, reason="no opencode 2.x installed")
def test_real_opencode_2x_contract(tmp_path, monkeypatch):
home = tmp_path / "home"
ws = tmp_path / "ws"
for d in (home, ws, tmp_path / "tmp"):
d.mkdir()
ws_real = os.path.realpath(str(ws))
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("TMPDIR", str(tmp_path / "tmp"))
for k in ("XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "HOME_DIR"):
monkeypatch.delenv(k, raising=False)
env = {**os.environ, "TERM": "dumb"}
def cli(*args, check=True):
res = subprocess.run([REAL, *args], capture_output=True, text=True, timeout=120, cwd=ws_real, env=env)
if check:
assert res.returncode == 0, (args, res.stdout, res.stderr)
return res
help_text = cli("--help").stdout
assert "--standalone" in help_text and "--session" in help_text
assert not re.search(r"^\s*--agent\b", help_text, re.M), "the TUI takes --agent again: revisit spawn_spec"
import json
parent = json.loads(cli("api", "--standalone", "POST", "/api/session", "-d", '{"title":"mam test"}').stdout)["data"]["id"]
child = json.loads(cli("api", "--standalone", "POST", "/api/session", "-d",
json.dumps({"title": "mam child", "parentID": parent})).stdout)["data"]["id"]
adapter = get_adapter("opencode")
# The binary was found with the real HOME; under the test's HOME only a
# PATH entry would find it (review f4cedbae N1).
monkeypatch.setattr(adapter, "_bin", lambda ctx: REAL)
ctx = DiscoveryContext(workspace=ws_real, agent_name="opencode", home_dir=str(home))
db = adapter._resolve_db(ctx)
assert db == str(home / ".local" / "share" / "opencode" / "opencode.db")
assert adapter.discover(ctx) == [parent]
assert adapter.verify_artifact(child, ctx) is True
purged = adapter.purge_artifacts(parent, ctx)
assert purged == [f"opencode session (with its child sessions): {parent}"]
assert adapter.residual_artifacts(parent, ctx) == []
assert adapter.residual_artifacts(child, ctx) == []
gone = cli("session", "delete", "--standalone", "--", parent, check=False)
assert gone.returncode == 1 and "Session not found" in gone.stdout + gone.stderr