fix(issue-6): fail-closed registry timeout and TUI turn handshake

- Introduce finalize_unfinished in multi-agent-mux-delegate-job for fail-closed atomic transitions
- Enforce CAS dispatch_token and attempt_id pinning across engine, CLI, and lifecycle scripts
- Integrate Herdr wait_for_agent_idle turn handshake to prevent prompt loss and race conditions
- Resolve bash 3.2 set -u empty array expansion compatibility in lib.sh
- Preserve Issue #5 R2 contract in CAS transition engine (engine.py, publish_event.py, service.py)
- Add comprehensive regression suites (test_issue6_fail_closed.py, test_issue6_turn_handshake.py)
- Bump lockstep framework and skills to v4.4.1 across lib.sh, eager.py, VERSIONS.md, 8 SKILL.md
- Synchronize documentation (IMPROVEMENTS.md, MESSAGING.md, state-transitions.md, task.md, phase5-e2e.md)
- Unanimous PASS achieved across 4 reviewer team leaders (Claude, Codex, OpenCode, Grok)

Closes #6
This commit is contained in:
Godopu committed 2026-09-09 14:02:47 +09:00
1 parent 7b761be7b8
commit ec61511699
51 files changed
+3477 -293

No files matched your search

+154 -11
View File
@@ -469,7 +469,8 @@ elif cmd1 == "agent":
agents_list.append({
"name": name,
"agent": data.get("agent", "claude"),
"agent_status": data.get("status", "running"),
"agent_status": data.get("agent_status", "idle"),
"status": data.get("status", "running"),
"cwd": data.get("cwd", ""),
"pane_id": data.get("pane_id", "w1:p1"),
"workspace_id": data.get("workspace_id", "w1")
@@ -697,12 +698,23 @@ elif cmd1 == "agent":
"command": agent_data.get("command", ""),
"argv": agent_data.get("command", "")
}
agent_st = agent_data.get("agent_status")
if agent_st is None:
seq = agent_data.get("status_sequence")
if seq is None:
seq = state.get("status_sequence")
if seq and isinstance(seq, list) and len(seq) > 0:
agent_st = seq[0]
else:
agent_st = "idle"
print(json.dumps({
"pane": pane_info,
"result": {
"agent": {
"agent": agent_data.get("agent", "claude"),
"status": agent_data.get("status", "running"),
"agent_status": agent_st,
"interactive_ready": agent_data.get("interactive_ready", True),
"cwd": agent_data.get("cwd", ""),
"pane_id": agent_data.get("pane_id", "w1:p1"),
"workspace_id": agent_data.get("workspace_id", "w1")
@@ -713,6 +725,76 @@ elif cmd1 == "agent":
else:
sys.stderr.write("Agent " + name + " not found\\n")
sys.exit(1)
elif cmd2 == "wait":
if len(args) < 3:
sys.exit(1)
name = args[2]
until_list = []
timeout_ms = None
i = 3
while i < len(args):
if args[i] == "--until" and i + 1 < len(args):
until_list.append(args[i+1])
i += 2
elif args[i] == "--timeout" and i + 1 < len(args):
try:
timeout_ms = int(args[i+1])
except Exception:
pass
i += 2
else:
i += 1
agents = state.get("agents", {})
matched_k = None
for k in agents:
if _match_agent(k, name):
matched_k = k
break
if not matched_k:
sys.stderr.write(json.dumps({
"error": {
"code": "agent_not_found",
"message": "agent " + name + " not found"
}
}) + "\\n")
sys.exit(1)
agent_data = agents[matched_k]
seq = agent_data.get("status_sequence")
if seq is None:
seq = state.get("status_sequence")
if seq is not None and isinstance(seq, list) and len(seq) > 0:
current_status = seq.pop(0)
agent_data["agent_status"] = current_status
state["agents"] = agents
save_state()
else:
current_status = agent_data.get("agent_status", "idle")
targets = until_list if until_list else ["idle", "done", "blocked"]
if current_status in targets:
print(json.dumps({
"result": {
"agent": {
"agent": agent_data.get("agent", "claude"),
"agent_status": current_status,
"interactive_ready": agent_data.get("interactive_ready", True),
"cwd": agent_data.get("cwd", ""),
"pane_id": agent_data.get("pane_id", "w1:p1"),
"workspace_id": agent_data.get("workspace_id", "w1")
}
}
}))
sys.exit(0)
else:
sys.stderr.write(json.dumps({
"error": {
"code": "timeout",
"message": "timed out waiting for agent " + name
}
}) + "\\n")
sys.exit(1)
elif cmd2 == "read":
if len(args) < 3:
sys.exit(1)
@@ -731,25 +813,86 @@ elif cmd1 == "agent":
sys.stderr.write("Agent " + name + " not found\\n")
sys.exit(1)
elif cmd2 == "prompt":
if len(args) < 4:
wait_flag = False
until_val = None
timeout_val = None
pos_args = []
i = 2
while i < len(args):
if args[i] == "--wait":
wait_flag = True
i += 1
elif args[i] == "--until" and i + 1 < len(args):
until_val = args[i+1]
i += 2
elif args[i] == "--timeout" and i + 1 < len(args):
timeout_val = args[i+1]
i += 2
else:
pos_args.append(args[i])
i += 1
if len(pos_args) < 2:
sys.exit(1)
name = args[2]
text = args[3]
name, text = pos_args[0], pos_args[1]
agents = state.get("agents", {})
matched_k = None
for k in agents:
if _match_agent(k, name):
matched_k = k
break
if matched_k:
agents[matched_k]["sent_text"] = agents[matched_k].get("sent_text", "") + text
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\n" + text + "\\n\\nesc to interrupt"
state["agents"] = agents
save_state()
if not matched_k:
sys.stderr.write(json.dumps({
"error": {
"code": "agent_not_found",
"message": "Agent " + name + " not found"
}
}) + "\\n")
sys.exit(1)
target_agent = agents[matched_k]
target_agent["sent_text"] = target_agent.get("sent_text", "") + text
target_agent["buffer"] = target_agent.get("buffer", "") + "\\n" + text + "\\n\\nesc to interrupt"
if wait_flag:
p_res = state.get("prompt_wait_result")
if p_res is None:
p_res = target_agent.get("prompt_wait_result", "ok")
if p_res == "ok":
if until_val == "working":
target_agent["agent_status"] = "working"
state["agents"] = agents
save_state()
print(json.dumps({"id": "cli:agent:prompt", "result": {"type": "ok"}}))
sys.exit(0)
elif p_res == "empty_output":
state["agents"] = agents
save_state()
sys.exit(1)
else:
state["agents"] = agents
save_state()
sys.stderr.write(json.dumps({
"error": {
"code": p_res,
"message": "agent prompt result: " + str(p_res)
}
}) + "\\n")
sys.exit(1)
else:
p_res = state.get("prompt_wait_result")
if p_res is None:
p_res = target_agent.get("prompt_wait_result", "ok")
if p_res == "empty_output":
sys.exit(1)
if p_res != "ok":
sys.stderr.write(json.dumps({
"error": {
"code": p_res,
"message": "agent prompt result: " + str(p_res)
}
}) + "\\n")
sys.exit(1)
print(json.dumps({"id": "cli:agent:prompt", "result": {"type": "ok"}}))
sys.exit(0)
sys.stderr.write("Agent " + name + " not found\\n")
sys.exit(1)
else:
# Real herdr has no `agent send`. Unknown subcommands must fail
# (exit 1) rather than silently succeeding.
+6
View File
@@ -1,5 +1,7 @@
usage: job_subscriber.py [-h] (--job JOB | --wait-any) [--timeout TIMEOUT]
[--idle-timeout IDLE_TIMEOUT] [--expect-retention]
[--expect-dispatch-token EXPECT_DISPATCH_TOKEN]
[--attempt-id ATTEMPT_ID]
[--registry-dir REGISTRY_DIR] [-v]
Subscribe to Job events on MQTT
@@ -14,5 +16,9 @@ optional arguments:
max seconds with no new event (default:
job.idle_timeout_sec or 120)
--expect-retention warn if no retained terminal event arrives promptly
--expect-dispatch-token EXPECT_DISPATCH_TOKEN
expected dispatch token for CAS verification
--attempt-id ATTEMPT_ID
minimum attempt id to accept events for
--registry-dir REGISTRY_DIR
-v, --verbose
+4 -1
View File
@@ -5,7 +5,8 @@ usage: publish_event.py [-h] (--job JOB | --pick-pending)
[--registry-dir REGISTRY_DIR] [--attempts ATTEMPTS]
[--finalize-report PATH]
[--side-effect KIND=REFERENCE]
[--attempt-id ATTEMPT_ID] [-v]
[--attempt-id ATTEMPT_ID]
[--expect-dispatch-token EXPECT_DISPATCH_TOKEN] [-v]
Publish a Job event to MQTT
@@ -30,4 +31,6 @@ optional arguments:
--attempt-id ATTEMPT_ID
attempt this finalization belongs to (default: the
job's current iteration)
--expect-dispatch-token EXPECT_DISPATCH_TOKEN
expected dispatch token for CAS verification
-v, --verbose
+3 -2
View File
@@ -1,16 +1,17 @@
usage: registry.py [-h] [--registry-dir REGISTRY_DIR]
{register,list,get,status,update,get-feedback,pick,logs}
{register,list,get,status,update,close,get-feedback,pick,logs}
...
multi-agent-mux-delegate-job registry CLI
positional arguments:
{register,list,get,status,update,get-feedback,pick,logs}
{register,list,get,status,update,close,get-feedback,pick,logs}
register create a pending job; prints the job id
list list jobs (optionally by status)
get print one job record as JSON
status set a job status
update update a job record
close close a job with deadline_timeout or cancelled
get-feedback get the last feedback detail (completed/error) for a
job
pick claim a pending job for a session; prints id
+1 -1
View File
@@ -207,7 +207,7 @@ LOG=/tmp/mam-d5-sks.$$
: > "$LOG"
_sks_herdr() {
echo "$*" >> "$LOG"
if [ "${1:-}" = "agent" ] && [ "${2:-}" = "prompt" ]; then return 1; fi
if [ "${1:-}" = "agent" ] && [ "${2:-}" = "prompt" ]; then echo "unknown command: agent prompt" >&2; return 1; fi
if [ "${1:-}" = "paste-buffer" ]; then return 1; fi
return 0
}
+1
View File
@@ -17,6 +17,7 @@ _pane_dialog_open() {{ return 1; }}
# Mock _sks_herdr commands
_sks_herdr() {{
if [ "${{1:-}}" = "agent" ] && [ "${{2:-}}" = "prompt" ]; then
echo "unknown command: agent prompt" >&2
return 1
fi
return 0
+1 -1
View File
@@ -327,7 +327,7 @@ def test_registry_cli_contract_snapshot(mam_sandbox):
assert subparsers, "registry must expose subcommands"
commands = set(subparsers[0].choices)
assert commands == {
"register", "list", "get", "status", "update", "get-feedback", "pick", "logs",
"register", "list", "get", "status", "update", "close", "get-feedback", "pick", "logs",
}, commands
reg = _parser_snapshot(subparsers[0].choices["register"])
assert reg["--prompt"][2] is None
+5 -6
View File
@@ -101,17 +101,16 @@ def test_x03_the_record_carries_iteration_not_an_attempt_id(mam_sandbox, tmp_pat
"""Why auto-completion does not use the engine's expected_attempt.
The engine compares expected_attempt against `attempt_id` falling back to
`iteration`. A record has only the latter, and the two mean different things,
so the attempt guard is applied where attempt identity actually lives: the
dispatch record in the shadow store, compared against observed evidence."""
`iteration`. A record now carries attempt_id (initialized to 1 in Issue 6)
and iteration, and the attempt guard is applied where attempt identity
actually lives: the dispatch record in the shadow store, compared against
observed evidence."""
monkeypatch.chdir(mam_sandbox)
reg = _reg(tmp_path)
job_id = _make_job(reg)
record = _load(reg, job_id)
assert "iteration" in record
assert "attempt_id" not in record, (
"if this ever changes, revisit the layering decision in the phase 2b report"
)
assert record.get("attempt_id") == 1
def test_x04_policy_rejects_evidence_from_a_superseded_attempt():
File diff suppressed because it is too large. Load diff
+436
View File
@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""Issue #6 Phase 2 — TUI Idle Handshake & Submission Hardening regression test suite.
Tests T2-1 through T2-11 according to docs/contracts/issue-6/implementation_plan.md (Rev.2).
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
LIB_SH = SKILLS / "lib.sh"
WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
def _run_lib_sh(snippet, env=None, cwd=None, timeout=60):
run_env = dict(os.environ)
run_env["DELEGATE_JOB_PYTHON"] = sys.executable
run_env["AGENT_PYTHON_BIN"] = sys.executable
if cwd:
run_env["WORKSPACE_ROOT"] = str(cwd)
run_env["HOME"] = str(cwd)
if env:
run_env.update({k: str(v) for k, v in env.items()})
return subprocess.run(
["bash", "-c", f'source "{LIB_SH}" >/dev/null 2>&1; {snippet}'],
capture_output=True,
text=True,
cwd=str(cwd or REPO_ROOT),
env=run_env,
timeout=timeout,
)
def _run_wrapper(args, env=None, cwd=None, timeout=60):
run_env = dict(os.environ)
run_env["DELEGATE_JOB_PYTHON"] = sys.executable
run_env["AGENT_PYTHON_BIN"] = sys.executable
if cwd:
run_env["WORKSPACE_ROOT"] = str(cwd)
run_env["HOME"] = str(cwd)
if env:
run_env.update({k: str(v) for k, v in env.items()})
return subprocess.run(
[str(WRAPPER)] + list(args),
capture_output=True,
text=True,
cwd=str(cwd or REPO_ROOT),
env=run_env,
timeout=timeout,
)
# ---------------------------------------------------------------------------
# T2-1: status_sequence=["working", "done"] -> agent wait 호출 후 agent prompt 호출, Enter/C-m 0회
# ---------------------------------------------------------------------------
def test_t2_1_status_sequence_wait_then_prompt(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt1"] = {
"agent": "claude",
"agent_status": "working",
"status_sequence": ["working", "done"],
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh(
'wait_for_agent_idle "agt1" 5000 && send_keys_safe "agt1" "test prompt" "job1"',
env={"SKS_IDLE_RETRY_BACKOFF_S": "0"},
cwd=tmp_path,
)
assert res.returncode == 0, f"Expected rc=0, got {res.returncode}. Stderr: {res.stderr}"
final_state = json.loads(state_file.read_text())
calls = final_state.get("calls", [])
wait_calls = [c for c in calls if len(c) >= 2 and c[0] == "agent" and c[1] == "wait"]
prompt_calls = [c for c in calls if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"]
assert len(wait_calls) >= 1, "Expected at least 1 agent wait call"
assert len(prompt_calls) == 1, "Expected exactly 1 agent prompt call"
wait_idx = calls.index(wait_calls[0])
prompt_idx = calls.index(prompt_calls[0])
assert wait_idx < prompt_idx, "agent wait must be called before agent prompt"
send_key_calls = [c for c in calls if (c and c[0] in ("send-keys", "send-text")) or (len(c) >= 2 and c[0] == "pane" and c[1] in ("send-keys", "send-text"))]
assert len(send_key_calls) == 0, f"No legacy send-keys expected, got: {send_key_calls}"
# ---------------------------------------------------------------------------
# T2-2: working 고정, SKS_IDLE_TIMEOUT_MS=1000 -> rc=1, agent prompt 미호출
# ---------------------------------------------------------------------------
def test_t2_2_working_fixed_timeout(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_busy"] = {
"agent": "claude",
"agent_status": "working",
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh(
'wait_for_agent_idle "agt_busy" 500',
env={"SKS_IDLE_RETRIES": "1", "SKS_IDLE_RETRY_BACKOFF_S": "0"},
cwd=tmp_path,
)
assert res.returncode == 1, f"Expected rc=1, got {res.returncode}"
final_state = json.loads(state_file.read_text())
prompt_calls = [c for c in final_state.get("calls", []) if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"]
assert len(prompt_calls) == 0, "agent prompt must NOT be called when idle wait fails"
# ---------------------------------------------------------------------------
# T2-3: unknown -> agent wait 미호출, _pane_quiescent 폴백 경로 진입
# ---------------------------------------------------------------------------
def test_t2_3_unknown_status_fallback(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_unk"] = {
"agent": "claude",
"agent_status": "unknown",
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh('wait_for_agent_idle "agt_unk"', cwd=tmp_path)
assert res.returncode == 3, f"Expected rc=3 (unknown), got {res.returncode}"
final_state = json.loads(state_file.read_text())
wait_calls = [c for c in final_state.get("calls", []) if len(c) >= 2 and c[0] == "agent" and c[1] == "wait"]
assert len(wait_calls) == 0, "agent wait must NOT be called when status is unknown"
# ---------------------------------------------------------------------------
# T2-4: blocked -> rc=2, 주입 없음
# ---------------------------------------------------------------------------
def test_t2_4_blocked_status(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_block"] = {
"agent": "claude",
"agent_status": "blocked",
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh('wait_for_agent_idle "agt_block"', cwd=tmp_path)
assert res.returncode == 2, f"Expected rc=2 (blocked), got {res.returncode}"
# ---------------------------------------------------------------------------
# T2-5: done 즉시 -> 대기 0회, 즉시 주입
# ---------------------------------------------------------------------------
def test_t2_5_done_immediate(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_done"] = {
"agent": "claude",
"agent_status": "done",
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh('wait_for_agent_idle "agt_done"', cwd=tmp_path)
assert res.returncode == 0, f"Expected rc=0, got {res.returncode}"
final_state = json.loads(state_file.read_text())
wait_calls = [c for c in final_state.get("calls", []) if len(c) >= 2 and c[0] == "agent" and c[1] == "wait"]
assert len(wait_calls) == 0, "agent wait must NOT be called when already done"
# ---------------------------------------------------------------------------
# T2-6: 폴백 경로, pre_submit에 esc to interrupt 존재 + 페이스트 드롭 -> rc=4
# ---------------------------------------------------------------------------
def test_t2_6_fallback_pre_submit_busy_token_drop(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_drop"] = {
"agent": "claude",
"agent_status": "unknown",
"pane_id": "w1:p1",
"prompt_wait_result": "timeout",
"buffer": "esc to interrupt\ntest drop",
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh(
'send_keys_safe "agt_drop" "test drop" "job_drop"',
env={"SKS_PROMPT_WAIT": "0", "SKS_IDLE_HANDSHAKE": "0"},
cwd=tmp_path,
)
assert res.returncode == 4, f"Expected rc=4, got {res.returncode}"
# ---------------------------------------------------------------------------
# T2-7: prompt_wait_result=agent_prompt_stalled + 이후 working -> rc=0, prompt 호출 1회
# ---------------------------------------------------------------------------
def test_t2_7_prompt_wait_stalled_recovery(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_stall"] = {
"agent": "claude",
"agent_status": "working",
"prompt_wait_result": "agent_prompt_stalled",
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh(
'send_keys_safe "agt_stall" "test stalled" "job_stall"',
env={"SKS_IDLE_HANDSHAKE": "0"},
cwd=tmp_path,
)
assert res.returncode == 0, f"Expected rc=0, got {res.returncode}. Stderr: {res.stderr}"
final_state = json.loads(state_file.read_text())
prompt_calls = [c for c in final_state.get("calls", []) if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"]
assert len(prompt_calls) == 1, f"Expected prompt called exactly once without re-submission, got {len(prompt_calls)}"
# ---------------------------------------------------------------------------
# T2-8: 래퍼 순서: idle 대기 로그가 subscriber pid: 로그보다 앞
# ---------------------------------------------------------------------------
def test_t2_8_wrapper_idle_before_subscriber_log(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_order"] = {
"agent": "claude",
"agent_status": "idle",
}
state_file.write_text(json.dumps(state, indent=2))
reg_dir = tmp_path / "jobs"
reg_dir.mkdir(parents=True, exist_ok=True)
res = _run_wrapper(
[
"submit",
"--agent", "claude",
"--agent-session", "herdr:agt_order",
"--prompt", "test T2-8 order",
"--registry-dir", str(reg_dir),
"--workdir", str(tmp_path),
"--timeout", "4",
"--idle-timeout", "2",
],
cwd=tmp_path,
timeout=60,
)
stdout = res.stdout
assert "subscriber pid:" in stdout
final_state = json.loads(state_file.read_text())
calls = final_state.get("calls", [])
get_calls = [c for c in calls if "agent" in c and "get" in c]
assert len(get_calls) >= 1, f"agent get should be called during idle handshake, got: {calls}"
# ---------------------------------------------------------------------------
# T2-9: working 고정 + SKS_IDLE_RETRIES=1, SKS_IDLE_TIMEOUT_MS=500 -> agent wait 2회, 래퍼 rc=5, cancelled
# ---------------------------------------------------------------------------
def test_t2_9_wrapper_idle_timeout_session_busy(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_busy2"] = {
"agent": "claude",
"agent_status": "working",
}
state_file.write_text(json.dumps(state, indent=2))
reg_dir = tmp_path / "jobs"
reg_dir.mkdir(parents=True, exist_ok=True)
res = _run_wrapper(
[
"submit",
"--agent", "claude",
"--agent-session", "herdr:agt_busy2",
"--prompt", "test T2-9 busy",
"--registry-dir", str(reg_dir),
"--workdir", str(tmp_path),
"--timeout", "6",
"--idle-timeout", "2",
],
env={"SKS_IDLE_RETRIES": "1", "SKS_IDLE_RETRY_BACKOFF_S": "0", "SKS_IDLE_TIMEOUT_MS": "200"},
cwd=tmp_path,
timeout=60,
)
assert res.returncode == 5, f"Expected rc=5, got {res.returncode}. Stderr: {res.stderr}"
final_state = json.loads(state_file.read_text())
calls = final_state.get("calls", [])
wait_calls = [c for c in calls if "agent" in c and "wait" in c]
assert len(wait_calls) == 2, f"Expected 2 agent wait calls, got {len(wait_calls)}"
job_files = list(reg_dir.glob("*.json"))
assert len(job_files) == 1
job_data = json.loads(job_files[0].read_text())
assert job_data.get("status") == "cancelled"
assert job_data.get("detail") == "session_busy"
sub_logs = list(reg_dir.glob("*.subscriber.out"))
assert len(sub_logs) == 0
# ---------------------------------------------------------------------------
# T2-10: HERDR_SESSION_NAME=iso-x -> mock 호출 기록에 herdr --session iso-x agent wait
# ---------------------------------------------------------------------------
def test_t2_10_watcher_session_argv(tmp_path, mock_herdr):
from lib_py.loop.watcher import build_argv
argv_default = build_argv("agt", session="default")
assert "--session" not in argv_default
assert argv_default[:3] == ["herdr", "agent", "wait"]
argv_iso = build_argv("agt", session="iso-x")
assert argv_iso[:5] == ["herdr", "--session", "iso-x", "agent", "wait"]
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_iso"] = {
"agent": "claude",
"agent_status": "working",
"status_sequence": ["done"],
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh(
'wait_for_agent_idle "agt_iso"',
env={"HERDR_SESSION_NAME": "iso-x", "SKS_IDLE_RETRY_BACKOFF_S": "0"},
cwd=tmp_path,
)
assert res.returncode == 0
final_state = json.loads(state_file.read_text())
calls = final_state.get("calls", [])
iso_calls = [c for c in calls if len(c) >= 4 and "iso-x" in c and "agent" in c and "wait" in c]
assert len(iso_calls) >= 1, f"Expected call with --session iso-x agent wait, got calls: {calls}"
# ---------------------------------------------------------------------------
# T2-11: 정적 린트: bare python -m 또는 python3 -m lib_py.loop 패턴 0건
# ---------------------------------------------------------------------------
def test_t2_11_static_lint_no_bare_python_in_lib_sh_and_wrapper():
pattern = re.compile(r'^\s*python3?\s+-m\s+lib_py\.loop', re.MULTILINE)
lib_sh_text = LIB_SH.read_text()
matches_lib = pattern.findall(lib_sh_text)
assert len(matches_lib) == 0, f"Found bare python -m in lib.sh: {matches_lib}"
wrapper_text = WRAPPER.read_text()
matches_wrap = pattern.findall(wrapper_text)
assert len(matches_wrap) == 0, f"Found bare python -m in wrapper: {matches_wrap}"
# ---------------------------------------------------------------------------
# T2-12: _SKS_IDLE_RC=3 + busy 화면 + SKS_BUSY_TIMEOUT=0 -> native prompt 전 rc=4 반환
# ---------------------------------------------------------------------------
def test_t2_12_busy_timeout_expiration_returns_rc4(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_busy"] = {
"agent": "claude",
"agent_status": "working",
}
state_file.write_text(json.dumps(state, indent=2))
# Mock pane tail returning "Working... esc to interrupt"
res = _run_lib_sh(
'_SKS_IDLE_RC=3 SKS_BUSY_TIMEOUT=0 send_keys_safe "agt_busy" "test prompt" "job_b1"',
env={"SKS_BUSY_TIMEOUT": "0"},
cwd=tmp_path,
)
assert res.returncode == 4, f"Expected rc=4 on busy expiration, got {res.returncode}"
final_state = json.loads(state_file.read_text())
prompt_calls = [c for c in final_state.get("calls", []) if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"]
assert len(prompt_calls) == 0, "agent prompt must NOT be called when busy gate expires"
# ---------------------------------------------------------------------------
# T2-13: agent prompt 모호한 RPC 실패 -> paste-buffer 폴백 금지, rc=4 반환 (단일 주입 계약)
# ---------------------------------------------------------------------------
def test_t2_13_ambiguous_rpc_failure_no_paste_duplicate(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_rpc"] = {
"agent": "claude",
"agent_status": "done",
"prompt_wait_result": "transport_error",
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh(
'send_keys_safe "agt_rpc" "test prompt" "job_rpc1"',
cwd=tmp_path,
)
assert res.returncode == 4, f"Expected rc=4 on ambiguous RPC failure, got {res.returncode}"
final_state = json.loads(state_file.read_text())
calls = final_state.get("calls", [])
paste_calls = [c for c in calls if len(c) >= 2 and (c[0] in ("paste-buffer", "set-buffer") or c[1] in ("paste-buffer", "set-buffer"))]
assert len(paste_calls) == 0, f"Must not fall back to paste buffer on ambiguous RPC error, got: {paste_calls}"
# ---------------------------------------------------------------------------
# T2-14: agent prompt 빈 응답(empty output) 실패 -> paste-buffer 폴백 금지, rc=4 반환 (단일 주입 계약)
# ---------------------------------------------------------------------------
def test_t2_14_empty_rpc_failure_no_paste_fallback(tmp_path, mock_herdr):
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["agt_empty"] = {
"agent": "claude",
"agent_status": "done",
"prompt_wait_result": "empty_output",
}
state_file.write_text(json.dumps(state, indent=2))
res = _run_lib_sh(
'send_keys_safe "agt_empty" "test prompt" "job_empty1"',
cwd=tmp_path,
)
assert res.returncode == 4, f"Expected rc=4 on empty RPC failure, got {res.returncode}"
final_state = json.loads(state_file.read_text())
calls = final_state.get("calls", [])
prompt_calls = [c for c in calls if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"]
paste_calls = [c for c in calls if len(c) >= 2 and (c[0] in ("paste-buffer", "set-buffer") or c[1] in ("paste-buffer", "set-buffer"))]
assert len(prompt_calls) == 1, f"Expected prompt called exactly once, got {len(prompt_calls)}"
assert len(paste_calls) == 0, f"Must not fall back to paste buffer on empty RPC failure, got: {paste_calls}"