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:
1 parent
7b761be7b8
commit
ec61511699
51 files changed
+3477
-293
No files matched your search
+154
-11
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
Reference in new issue
Block a user