fix(session_ids,id_order): optional --session resolution, fresh-marker retention, and canonical cwd matching (Issue #63)
This commit is contained in:
1 parent
8c9a68cf1c
commit
c747374aa6
3 files changed
+273
-23
No files matched your search
@@ -785,5 +785,196 @@ def test_portability_python39_ast_and_bash_syntax():
|
||||
assert res.returncode == 0, f"{rel}: {res.stderr}"
|
||||
|
||||
|
||||
def test_resolve_session_id_without_session_arg_returns_workspace_id(mam_sandbox):
|
||||
"""resolve_session_id.sh and decide_resume with name='' return the workspace's stopped row id."""
|
||||
from lib_py.agents.session_ids import decide_resume
|
||||
|
||||
ws = mam_sandbox / "opt-sess-ws"
|
||||
ws.mkdir(parents=True, exist_ok=True)
|
||||
epoch = int(time.time()) - 100
|
||||
_agy_full_artifacts(mam_sandbox, UA)
|
||||
_agy_cache(mam_sandbox, {ws: UA})
|
||||
_claude_transcript(mam_sandbox / ".claude" / "projects", ws, UB)
|
||||
|
||||
rows = [
|
||||
_row("opt-sess-ws-creator-agy", "agy", ws, epoch, status="stopped", agy_conversation_id_own=UA),
|
||||
_row("opt-sess-ws-creator-claude", "claude", ws, epoch, status="stopped", claude_session_id_own=UB),
|
||||
]
|
||||
_write_rows(mam_sandbox, rows)
|
||||
state = {"herdr_sessions": rows}
|
||||
|
||||
assert decide_resume(state, "", "agy", str(ws), home=str(mam_sandbox))["uuid"] == UA
|
||||
assert decide_resume(
|
||||
state, "", "claude", str(ws), home=str(mam_sandbox),
|
||||
claude_dir=str(mam_sandbox / ".claude" / "projects"),
|
||||
)["uuid"] == UB
|
||||
|
||||
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
|
||||
res_agy = subprocess.run(
|
||||
["bash", str(script), "--workspace", str(ws), "--agent", "agy"],
|
||||
cwd=str(mam_sandbox), capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert res_agy.returncode == 0
|
||||
assert res_agy.stdout.strip() == UA
|
||||
|
||||
res_claude = subprocess.run(
|
||||
["bash", str(script), "--workspace", str(ws), "--agent", "claude"],
|
||||
cwd=str(mam_sandbox), capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert res_claude.returncode == 0
|
||||
assert res_claude.stdout.strip() == UB
|
||||
|
||||
|
||||
def test_id_order_old_stopped_row_with_own_id_does_not_release_fresh_startup_marker(tmp_path, monkeypatch):
|
||||
"""A pre-admission stopped row's own_id must not release a live startup marker
|
||||
for a fresh resume (stale own_id) or a create reusing an old stopped session name."""
|
||||
import threading
|
||||
from lib_py.agents import id_order
|
||||
from lib_py.agents.session_ids import decide_resume
|
||||
|
||||
home = tmp_path / "home"
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir(parents=True)
|
||||
yaml_path = tmp_path / "agent-sessions.yaml"
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
|
||||
|
||||
for mode, old_own in (("stale_resume", UX), ("recreate_stopped", UA)):
|
||||
if mode == "recreate_stopped":
|
||||
_agy_conv(home, UA, time.time() - 50)
|
||||
old_epoch = int(time.time()) - 100
|
||||
old_stop = int(time.time()) - 10
|
||||
row_a = _row(
|
||||
"same-ws-creator-agy", "agy", ws, old_epoch, status="stopped",
|
||||
agy_conversation_id_own=old_own, stopped_at_epoch=old_stop,
|
||||
)
|
||||
state = {"herdr_sessions": [row_a]}
|
||||
yaml_path.write_text(yaml.safe_dump(state))
|
||||
|
||||
if mode == "stale_resume":
|
||||
dec = decide_resume(state, "same-ws-creator-agy", "agy", str(ws), home=str(home))
|
||||
assert dec["uuid"] == "" and dec["drop_stale_id"] is True
|
||||
|
||||
proc_a = subprocess.Popen(["sleep", "120"])
|
||||
proc_b = subprocess.Popen(["sleep", "120"])
|
||||
try:
|
||||
# Admit A (fresh startup, uuid=''): writes live marker while old stopped row is in state
|
||||
assert id_order.wait(
|
||||
str(ws), "agy", "same-ws-creator-agy", proc_a.pid, uuid="",
|
||||
yaml_path=str(yaml_path), home=str(home), wait_s_val=0,
|
||||
) == 0
|
||||
|
||||
# Immediate check at cap 0: B must be blocked even before A updates its YAML row
|
||||
assert id_order.wait(
|
||||
str(ws), "agy", "same-ws-reviewer-agy", proc_b.pid, uuid="",
|
||||
yaml_path=str(yaml_path), home=str(home), wait_s_val=0,
|
||||
) == 1
|
||||
|
||||
# Concurrent check: B waits while A transitions old-stopped -> running-unpinned -> running-pinned
|
||||
b_rc: list = []
|
||||
|
||||
def _run_b():
|
||||
rc = id_order.wait(
|
||||
str(ws), "agy", "same-ws-reviewer-agy", proc_b.pid, uuid="",
|
||||
yaml_path=str(yaml_path), home=str(home), wait_s_val=10, poll=0.1,
|
||||
)
|
||||
b_rc.append(rc)
|
||||
|
||||
t = threading.Thread(target=_run_b)
|
||||
t.start()
|
||||
time.sleep(0.35)
|
||||
assert b_rc == [], f"B unblocked prematurely while A was still old stopped row ({mode})"
|
||||
|
||||
# A registers its fresh running row without an own_id
|
||||
running_unpinned = _row(
|
||||
"same-ws-creator-agy", "agy", ws, old_epoch + 100,
|
||||
pid=proc_a.pid, status="running", agy_conversation_id_own=None,
|
||||
)
|
||||
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [running_unpinned]}))
|
||||
time.sleep(0.35)
|
||||
assert b_rc == [], f"B unblocked prematurely while A was running unpinned ({mode})"
|
||||
|
||||
# A records its new conversation id UB -> B unblocks
|
||||
running_pinned = _row(
|
||||
"same-ws-creator-agy", "agy", ws, old_epoch + 100,
|
||||
pid=proc_a.pid, status="running", agy_conversation_id_own=UB,
|
||||
)
|
||||
yaml_path.write_text(yaml.safe_dump({"herdr_sessions": [running_pinned]}))
|
||||
t.join(timeout=5)
|
||||
assert b_rc == [0], f"B did not unblock after A recorded its new id ({mode})"
|
||||
finally:
|
||||
proc_a.kill()
|
||||
proc_b.kill()
|
||||
proc_a.wait()
|
||||
proc_b.wait()
|
||||
|
||||
|
||||
def test_canonical_cwd_distinguishes_underscore_hyphen_and_nested_paths_with_symlink_control(tmp_path, monkeypatch):
|
||||
"""Distinct directories whose lossy workspace_key collides (project_a vs project-a,
|
||||
nested/sub vs nested-sub) do not block or merge rows; a symlink alias to the same
|
||||
directory does match."""
|
||||
from lib_py.agents import id_order
|
||||
from lib_py.agents.session_ids import decide_resume, same_type_rows
|
||||
|
||||
home = tmp_path / "home"
|
||||
ws_under = tmp_path / "project_a"
|
||||
ws_hyphen = tmp_path / "project-a"
|
||||
ws_nested = tmp_path / "nested" / "sub"
|
||||
ws_flat = tmp_path / "nested-sub"
|
||||
for p in (ws_under, ws_hyphen, ws_nested, ws_flat):
|
||||
p.mkdir(parents=True)
|
||||
ws_alias = tmp_path / "project_a_link"
|
||||
ws_alias.symlink_to(ws_under)
|
||||
|
||||
yaml_path = tmp_path / "agent-sessions.yaml"
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
|
||||
|
||||
_agy_conv(home, UA, time.time() - 10)
|
||||
_agy_conv(home, UB, time.time() - 5)
|
||||
_agy_cache(home, {ws_under: UA, ws_hyphen: UB})
|
||||
|
||||
proc = subprocess.Popen(["sleep", "120"])
|
||||
try:
|
||||
state_live = {
|
||||
"herdr_sessions": [
|
||||
_row("under-agy", "agy", ws_under, 100, pid=proc.pid, status="running"),
|
||||
_row("nested-agy", "agy", ws_nested, 100, pid=proc.pid, status="running"),
|
||||
]
|
||||
}
|
||||
yaml_path.write_text(yaml.safe_dump(state_live))
|
||||
|
||||
# Colliding workspace_key directories do NOT block each other
|
||||
assert id_order.wait(
|
||||
str(ws_hyphen), "agy", "hyphen-agy", os.getpid(),
|
||||
yaml_path=str(yaml_path), home=str(home), wait_s_val=0,
|
||||
) == 0
|
||||
assert id_order.wait(
|
||||
str(ws_flat), "agy", "flat-agy", os.getpid(),
|
||||
yaml_path=str(yaml_path), home=str(home), wait_s_val=0,
|
||||
) == 0
|
||||
# Symlink alias to ws_under DOES see live unpinned under-agy and blocks
|
||||
assert id_order.wait(
|
||||
str(ws_alias), "agy", "alias-agy", os.getpid(),
|
||||
yaml_path=str(yaml_path), home=str(home), wait_s_val=0,
|
||||
) == 1
|
||||
finally:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
state_stopped = {
|
||||
"herdr_sessions": [
|
||||
_row("under-agy", "agy", ws_under, 100, status="stopped"),
|
||||
_row("hyphen-agy", "agy", ws_hyphen, 100, status="stopped"),
|
||||
_row("nested-agy", "agy", ws_nested, 100, status="stopped"),
|
||||
_row("flat-agy", "agy", ws_flat, 100, status="stopped"),
|
||||
]
|
||||
}
|
||||
assert [r["name"] for r in same_type_rows(state_stopped, str(ws_hyphen), "agy")] == ["hyphen-agy"]
|
||||
assert [r["name"] for r in same_type_rows(state_stopped, str(ws_flat), "agy")] == ["flat-agy"]
|
||||
assert [r["name"] for r in same_type_rows(state_stopped, str(ws_alias), "agy")] == ["under-agy"]
|
||||
|
||||
# Single stopped row in ws_hyphen uses single-row workspace discovery (UB) instead of 2-row fresh spawn
|
||||
res_hyphen = decide_resume(state_stopped, "hyphen-agy", "agy", str(ws_hyphen), home=str(home))
|
||||
assert res_hyphen["uuid"] == UB
|
||||
assert res_hyphen["notes"] == []
|
||||
Reference in new issue
Block a user