Compare commits
2
Commits
4a5a093986
...
4a3328d0b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a3328d0b7 | ||
|
|
17edf90676 |
No files matched your search
+162
-33
@@ -24,6 +24,13 @@ fi
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"
|
||||
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
|
||||
export WORKSPACE_ROOT
|
||||
|
||||
# Framework semantic version. Single runtime source of truth; kept in lockstep
|
||||
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
|
||||
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
|
||||
MAM_VERSION="3.1.0"
|
||||
export MAM_VERSION
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||
|
||||
_sanitize_herdr_agent_name() {
|
||||
@@ -43,12 +50,14 @@ _sanitize_herdr_agent_name() {
|
||||
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
||||
else
|
||||
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
|
||||
fi
|
||||
s="${s:0:23}-${h}"
|
||||
if [ -n "$h" ]; then
|
||||
s="${s:0:23}-$h"
|
||||
else
|
||||
s="${s:0:32}"
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "${s:0:32}"
|
||||
printf '%s\n' "$s"
|
||||
}
|
||||
|
||||
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
|
||||
@@ -59,7 +68,9 @@ for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME
|
||||
done
|
||||
|
||||
# Central TUI dialog and readiness validation tokens (OP-6)
|
||||
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
|
||||
_MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?'
|
||||
_MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue'
|
||||
_MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
|
||||
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
|
||||
|
||||
# Workspace-relative defaults with environment overrides (Phase Z)
|
||||
@@ -348,17 +359,18 @@ _resolve_herdr_pane_id() {
|
||||
local ws_flag=()
|
||||
[ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws")
|
||||
pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \
|
||||
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" python3 -c "
|
||||
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$PWD}" python3 -c "
|
||||
import sys, json, os
|
||||
tn = os.environ.get('TARGET_NAME', '')
|
||||
tsa = os.environ.get('TARGET_SAN', '')
|
||||
tws = os.environ.get('TARGET_WS', '')
|
||||
ws_root = os.environ.get('WORKSPACE_ROOT', os.getcwd())
|
||||
try:
|
||||
panes = json.load(sys.stdin).get('result', {}).get('panes', [])
|
||||
# Client-side filter in case an older herdr ignores --workspace.
|
||||
if tws:
|
||||
panes = [p for p in panes if p.get('workspace_id') == tws]
|
||||
# ISSUE-2: exact match only.
|
||||
# ISSUE-2: exact match first, then agent-sessions.yaml lookup fallback
|
||||
for key in ('label', 'name', 'agent'):
|
||||
for p in panes:
|
||||
v = p.get(key)
|
||||
@@ -367,6 +379,27 @@ try:
|
||||
if pid:
|
||||
print(pid)
|
||||
sys.exit(0)
|
||||
agent_kind = None
|
||||
yaml_path = os.path.join(ws_root, '.mam', 'agent-sessions.yaml')
|
||||
if os.path.isfile(yaml_path):
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_path) as f:
|
||||
ydata = yaml.safe_load(f) or {}
|
||||
for s in ydata.get('herdr_sessions', []):
|
||||
if s.get('name') == tn or s.get('name') == tsa:
|
||||
agent_kind = s.get('pane', {}).get('cmd') or ''
|
||||
break
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'warning: yaml load failed: {e}\n')
|
||||
if agent_kind:
|
||||
matching = [p.get('pane_id') for p in panes if p.get('agent') == agent_kind and p.get('pane_id')]
|
||||
if len(matching) == 1:
|
||||
print(matching[0])
|
||||
sys.exit(0)
|
||||
elif len(matching) > 1:
|
||||
# R-1: Ambiguous resolution with multiple same-kind panes — fail closed
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
@@ -731,13 +764,13 @@ except Exception:
|
||||
else
|
||||
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true)
|
||||
fi
|
||||
# Fatal CLI errors abort immediately. agent_not_ready is herdr's
|
||||
# documented "process is up, blocked on a dialog" status — continue
|
||||
# to wait_for_tui_ready. Do NOT treat "timed out waiting for agent
|
||||
# startup" as success: herdr returns that for a dead process too.
|
||||
if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"; then
|
||||
break
|
||||
fi
|
||||
# Fatal CLI errors abort immediately. agent_not_ready is the
|
||||
# documented "process is up, blocked on a dialog" status — continue
|
||||
# to wait_for_tui_ready. Do NOT treat "timed out waiting for agent
|
||||
# startup" as success: herdr returns that for a dead process too.
|
||||
if echo "$res" | grep -qE "agent_started|agent_not_ready"; then
|
||||
success=1
|
||||
break
|
||||
@@ -1793,41 +1826,117 @@ start_watchdog() {
|
||||
|
||||
# wait_for_tui_ready <session_name> <agent>
|
||||
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
|
||||
# Returns 0 on readiness, 1 on timeout, 2 on unobservable/headless.
|
||||
wait_for_tui_ready() {
|
||||
local sess="$1" agent="$2"
|
||||
# Self-contained resolution: works whether or not caller evaluated facts (C1-(3)).
|
||||
local tokens="${MAM_READY_TOKENS:-}"
|
||||
if [ -z "$tokens" ]; then
|
||||
local strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
|
||||
local weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
|
||||
local placeholder="${MAM_INPUT_PLACEHOLDER:-}"
|
||||
local prompt="${MAM_INPUT_PROMPT:-}"
|
||||
local rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
|
||||
local modal_tokens="${MAM_MODAL_TOKENS:-}"
|
||||
|
||||
# Self-contained resolution: fetch facts in 1 call if not already in environment
|
||||
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "${MAM_READY_TOKENS:-}" ]; then
|
||||
local _facts=""
|
||||
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
|
||||
tokens="$(printf '%s\n' "$_facts" | sed -n 's/^MAM_READY_TOKENS=//p')"
|
||||
[ -n "$tokens" ] && eval "tokens=$tokens"
|
||||
if [ -n "$_facts" ]; then
|
||||
eval "$_facts"
|
||||
strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
|
||||
weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
|
||||
placeholder="${MAM_INPUT_PLACEHOLDER:-}"
|
||||
prompt="${MAM_INPUT_PROMPT:-}"
|
||||
rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
|
||||
modal_tokens="${MAM_MODAL_TOKENS:-}"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$tokens" ]; then
|
||||
# Fallback if strong_tokens is empty but MAM_READY_TOKENS is set
|
||||
if [ -z "$strong_tokens" ] && [ -n "${MAM_READY_TOKENS:-}" ]; then
|
||||
strong_tokens="$MAM_READY_TOKENS"
|
||||
fi
|
||||
|
||||
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "$placeholder" ]; then
|
||||
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
|
||||
return 1
|
||||
fi
|
||||
local i
|
||||
|
||||
local empty_streak=0
|
||||
local empty_giveup="${MAM_READY_EMPTY_GIVEUP:-5}"
|
||||
local modal_pat="$_MAM_MODAL_TOKENS"
|
||||
[ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}"
|
||||
|
||||
local i content tail_lines
|
||||
for i in {1..30}; do
|
||||
if [ "$agent" = "claude" ]; then
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
fi
|
||||
if _pane_dialog_open "$sess"; then
|
||||
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter || true
|
||||
sleep 1
|
||||
# 1. Single capture per iteration (T-3)
|
||||
content=$(_pane_capture "$sess")
|
||||
|
||||
# Consecutive blank capture check (C-6)
|
||||
if [ -z "$content" ]; then
|
||||
empty_streak=$((empty_streak + 1))
|
||||
if [ "$empty_streak" -ge "$empty_giveup" ]; then
|
||||
echo "wait_for_tui_ready: unobservable/headless pane for '$sess' (consecutive empty captures: $empty_streak)" >&2
|
||||
return 2
|
||||
fi
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
local content
|
||||
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
|
||||
if [ -n "$content" ]; then
|
||||
if echo "$content" | grep -E -q "$tokens" 2>/dev/null; then
|
||||
echo "✅ $agent TUI detected ready."
|
||||
return 0
|
||||
empty_streak=0
|
||||
|
||||
# 2. Check modal dialogs FIRST (F-2): if a blocking modal signature is present in the tail lines,
|
||||
# handle it and do NOT declare ready on this iteration.
|
||||
tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20)
|
||||
if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
|
||||
# 3. Tier 2 Readiness Evaluation (T-2b, T-4: Ready check first)
|
||||
local s_matched=false
|
||||
local w_matched=false
|
||||
local c_matched=false
|
||||
|
||||
# S-layer: Strong ready token match
|
||||
if [ -n "$strong_tokens" ]; then
|
||||
if echo "$content" | grep -E -q "$strong_tokens" 2>/dev/null; then
|
||||
s_matched=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# S-layer: Placeholder match (must be >= 8 characters)
|
||||
if [ -n "$placeholder" ] && [ "${#placeholder}" -ge 8 ]; then
|
||||
if echo "$content" | grep -F -q "$placeholder" 2>/dev/null; then
|
||||
s_matched=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# W-layer: Weak ready token match
|
||||
if [ -n "$weak_tokens" ]; then
|
||||
if echo "$content" | grep -E -q "$weak_tokens" 2>/dev/null; then
|
||||
w_matched=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# C-layer: Corroboration (Prompt in last 5 non-blank lines OR rule pattern match)
|
||||
if [ -n "$prompt" ]; then
|
||||
if printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 5 | grep -F -q "$prompt" 2>/dev/null; then
|
||||
c_matched=true
|
||||
fi
|
||||
fi
|
||||
if [ -n "$rule_pattern" ]; then
|
||||
if echo "$content" | grep -E -q "$rule_pattern" 2>/dev/null; then
|
||||
c_matched=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Decision: S OR (W AND C)
|
||||
if [ "$s_matched" = true ] || { [ "$w_matched" = true ] && [ "$c_matched" = true ]; }; then
|
||||
echo "✅ $agent TUI detected ready."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 4. Startup dialog & hint resolution if not ready
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
sleep 1
|
||||
done
|
||||
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
|
||||
@@ -1920,11 +2029,14 @@ _pane_quiescent() {
|
||||
}
|
||||
|
||||
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust /
|
||||
# permission / OAuth / list-selection), checked in the bottom 20 pane lines
|
||||
# permission / OAuth / list-selection), checked in the bottom 8 pane lines
|
||||
# only (dialogs render near the input area; conversation text above must not
|
||||
# trigger this). Tokens must NOT appear on normal idle prompt screens.
|
||||
_pane_dialog_open() {
|
||||
_pane_tail "$1" 20 | grep -Eq "$_MAM_DIALOG_TOKENS"
|
||||
local extra="${MAM_MODAL_TOKENS:-}"
|
||||
local pat="$_MAM_MODAL_TOKENS"
|
||||
[ -n "$extra" ] && pat="${pat}|${extra}"
|
||||
_pane_tail "$1" "${MAM_DIALOG_TAIL_LINES:-8}" | grep -Eq "$pat"
|
||||
}
|
||||
|
||||
# send_keys_safe <sess> <text> [job_id]
|
||||
@@ -1939,6 +2051,23 @@ _pane_dialog_open() {
|
||||
send_keys_safe() {
|
||||
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
||||
local pre_submit deadline try
|
||||
local MAM_MODAL_TOKENS="${MAM_MODAL_TOKENS:-}"
|
||||
local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN
|
||||
local MAM_READY_TOKENS MAM_STRONG_READY_TOKENS MAM_WEAK_READY_TOKENS MAM_EXIT_KEY MAM_DELEGATE_AGENT_KEY
|
||||
|
||||
if [ -z "${MAM_MODAL_TOKENS:-}" ]; then
|
||||
local _sks_agent=""
|
||||
case "$sess" in
|
||||
*-claude|*-claude-[0-9]*|claude|claude-[0-9]*) _sks_agent="claude" ;;
|
||||
*-agy|*-agy-[0-9]*|agy|agy-[0-9]*) _sks_agent="agy" ;;
|
||||
*-hermes|*-hermes-[0-9]*|hermes|hermes-[0-9]*) _sks_agent="hermes" ;;
|
||||
*-cline|*-cline-[0-9]*|cline|cline-[0-9]*) _sks_agent="cline" ;;
|
||||
*-grok|*-grok-[0-9]*|grok|grok-[0-9]*) _sks_agent="grok" ;;
|
||||
esac
|
||||
if [ -n "$_sks_agent" ]; then
|
||||
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$_sks_agent" 2>/dev/null || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
local _q_rc=0
|
||||
_pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-20}" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
|
||||
|
||||
@@ -23,6 +23,9 @@ def main():
|
||||
print(f"MAM_INPUT_PLACEHOLDER={q(adapter.input_placeholder or '')}")
|
||||
print(f"MAM_INPUT_RULE_PATTERN={q(adapter.input_rule_pattern or '')}")
|
||||
print(f"MAM_READY_TOKENS={q(adapter.ready_tokens)}")
|
||||
print(f"MAM_STRONG_READY_TOKENS={q(adapter.strong_ready_tokens)}")
|
||||
print(f"MAM_WEAK_READY_TOKENS={q(adapter.weak_ready_tokens)}")
|
||||
print(f"MAM_MODAL_TOKENS={q(adapter.modal_tokens or '')}")
|
||||
print(f"MAM_EXIT_KEY={q(adapter.exit_key)}")
|
||||
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
def input_rule_pattern(self) -> str:
|
||||
return '─{10,}'
|
||||
|
||||
@property
|
||||
def modal_tokens(self) -> Optional[str]:
|
||||
return 'Try the new fullscreen renderer\\?'
|
||||
|
||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||
return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl"
|
||||
|
||||
|
||||
@@ -16,6 +16,18 @@ class ClineAgentAdapter(BaseAgentAdapter):
|
||||
def ready_tokens(self) -> str:
|
||||
return 'Cline|history|Chat|What can I do|slash commands'
|
||||
|
||||
@property
|
||||
def strong_ready_tokens(self) -> str:
|
||||
return 'Cline|What can I do|slash commands'
|
||||
|
||||
@property
|
||||
def weak_ready_tokens(self) -> str:
|
||||
return 'history|Chat'
|
||||
|
||||
@property
|
||||
def modal_tokens(self) -> Optional[str]:
|
||||
return 'Select API Provider|Enter API Key|Select an API provider|API Provider|Cline API key'
|
||||
|
||||
@property
|
||||
def exit_key(self) -> str:
|
||||
return '/exit'
|
||||
|
||||
@@ -73,6 +73,18 @@ class BaseAgentAdapter:
|
||||
def input_rule_pattern(self) -> Optional[str]:
|
||||
return None
|
||||
|
||||
@property
|
||||
def modal_tokens(self) -> Optional[str]:
|
||||
return None
|
||||
|
||||
@property
|
||||
def strong_ready_tokens(self) -> str:
|
||||
return self.ready_tokens
|
||||
|
||||
@property
|
||||
def weak_ready_tokens(self) -> str:
|
||||
return ''
|
||||
|
||||
def derive_session_name(self, slug: str, role: str = "creator") -> str:
|
||||
r = role.lower()
|
||||
return f"{slug}-{r}-{self.name}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-create
|
||||
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
|
||||
version: 3.0.1
|
||||
version: 3.1.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -225,7 +225,7 @@ spawn
|
||||
cleanup_herdr_on_error() {
|
||||
local exit_code=$?
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo "⚠️ Error occurred during initialization. Rolling back and killing herdr session '$SESSION_NAME'..." >&2
|
||||
echo "⚠️ Error occurred during initialization (exit code $exit_code). Rolling back and killing herdr session '$SESSION_NAME'..." >&2
|
||||
_herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
@@ -237,11 +237,49 @@ if [ -z "$HERDR_SERVER_OPT" ]; then
|
||||
fi
|
||||
|
||||
# TUI 준비 대기
|
||||
if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
|
||||
echo "ERROR: agent TUI never became ready — aborting (rollback via trap)" >&2
|
||||
exit 1
|
||||
TUI_READY_RC=0
|
||||
wait_for_tui_ready "$SESSION_NAME" "$AGENT" || TUI_READY_RC=$?
|
||||
LAST_VISIBLE_STATUS_OVERRIDE=""
|
||||
|
||||
if [ "$TUI_READY_RC" -ne 0 ]; then
|
||||
# 1. Diagnostic dump
|
||||
DIAG_DIR="${WORKSPACE:-$PWD}/.mam/diagnostics"
|
||||
mkdir -p "$DIAG_DIR" 2>/dev/null || true
|
||||
DIAG_FILE="$DIAG_DIR/${SESSION_NAME}-$(date +%s).txt"
|
||||
{
|
||||
echo "=== MAM Diagnostic Dump ==="
|
||||
echo "Date: $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
echo "MAM Version: ${MAM_VERSION:-unknown}"
|
||||
echo "Session: $SESSION_NAME"
|
||||
echo "Agent: $AGENT"
|
||||
echo "ReturnCode: $TUI_READY_RC"
|
||||
echo "--- Pane Raw Capture ---"
|
||||
_pane_capture "$SESSION_NAME" 2>/dev/null || true
|
||||
} > "$DIAG_FILE" 2>/dev/null || true
|
||||
if [ ! -f "$DIAG_FILE" ]; then
|
||||
echo "WARNING: Failed to write diagnostic dump to $DIAG_FILE" >&2
|
||||
fi
|
||||
|
||||
# 2. Check pane PID liveness
|
||||
PANE_PID_CHECK=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
|
||||
ALIVE=false
|
||||
if [ -n "$PANE_PID_CHECK" ] && [[ "$PANE_PID_CHECK" =~ ^[0-9]+$ ]]; then
|
||||
if kill -0 "$PANE_PID_CHECK" 2>/dev/null; then
|
||||
ALIVE=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ALIVE" = true ] && [ "${MAM_KILL_ON_READY_TIMEOUT:-0}" != "1" ]; then
|
||||
echo "WARNING: agent TUI never became ready within timeout, but process (PID $PANE_PID_CHECK) is alive. Preserving session for diagnosis." >&2
|
||||
LAST_VISIBLE_STATUS_OVERRIDE="tui-ready-timeout"
|
||||
SUBMIT_JOB_PROMPT="" # disable instruction injection on unready session
|
||||
else
|
||||
echo "ERROR: agent TUI never became ready — aborting (rollback via trap)" >&2
|
||||
exit 42
|
||||
fi
|
||||
fi
|
||||
if [ "$AGENT" = "claude" ]; then
|
||||
|
||||
if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
||||
handle_startup_dialogs "$SESSION_NAME" 15
|
||||
fi
|
||||
|
||||
@@ -257,8 +295,8 @@ NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
# herdr server shim (HERDR_SESSION_NAME picked up by the lib.sh shim).
|
||||
START_CMD="HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
|
||||
# If --onboard is specified, automatically build the onboarding prompt
|
||||
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then
|
||||
# If --onboard is specified, automatically build the onboarding prompt (if session is ready)
|
||||
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
||||
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks:
|
||||
1. Read the project documentation at README.md and the multi-agent protocol guidelines at .agents/MULTI_AGENT_RULES.md to understand the design rules.
|
||||
2. Run 'git status' and 'git diff' to analyze the current modifications and active work in the repository.
|
||||
@@ -268,6 +306,9 @@ fi
|
||||
|
||||
# agent-sessions.yaml 에 append
|
||||
DELEGATE_JOB_ID=""
|
||||
if [ -n "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
||||
SUBMIT_JOB_PROMPT=""
|
||||
fi
|
||||
if [ -n "$SUBMIT_JOB_PROMPT" ]; then
|
||||
delegate_agent="${MAM_DELEGATE_AGENT_KEY:-}"
|
||||
if [ -z "$delegate_agent" ]; then
|
||||
@@ -306,6 +347,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
|
||||
MAM_WS_LABEL="$MAM_WS_LABEL" \
|
||||
SESSION_UUID="$SESSION_UUID" \
|
||||
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
|
||||
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
|
||||
name = os.environ['SESSION_NAME']
|
||||
agent = os.environ['AGENT']
|
||||
@@ -389,6 +431,10 @@ elif agent == 'grok':
|
||||
entry['session_id_verified'] = False
|
||||
entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified"
|
||||
|
||||
last_vis_override = os.environ.get('LAST_VISIBLE_STATUS_OVERRIDE', '')
|
||||
if last_vis_override:
|
||||
entry['last_visible_status'] = last_vis_override
|
||||
|
||||
sessions.append(entry)
|
||||
|
||||
snap = d.setdefault('snapshot', {})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-delegate-job
|
||||
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, cline, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
|
||||
version: 3.0.1
|
||||
version: 3.1.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-loop
|
||||
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
|
||||
version: 3.0.1
|
||||
version: 3.1.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-monitor
|
||||
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
|
||||
version: 3.0.1
|
||||
version: 3.1.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-orc-onboard
|
||||
description: Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture.
|
||||
version: 3.0.1
|
||||
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
|
||||
version: 3.1.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-resume
|
||||
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
|
||||
version: 3.0.1
|
||||
version: 3.1.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-status
|
||||
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
|
||||
version: 3.0.1
|
||||
version: 3.1.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-stop
|
||||
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
|
||||
version: 3.0.1
|
||||
version: 3.1.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -158,6 +158,19 @@
|
||||
#default: 2
|
||||
# MAM_MAX_PANE_ROWS=2
|
||||
|
||||
# Kill the session instead of preserving it when TUI readiness times out.
|
||||
#default: 0
|
||||
# MAM_KILL_ON_READY_TIMEOUT=0
|
||||
|
||||
# Number of trailing non-blank pane lines scanned for blocking modal signatures.
|
||||
#default: 8
|
||||
# MAM_DIALOG_TAIL_LINES=8
|
||||
|
||||
# Consecutive empty captures before wait_for_tui_ready declares the pane unobservable.
|
||||
# NOTE: distinct from SKS_EMPTY_GIVEUP, which governs send_keys_safe quiescence (default 3).
|
||||
#default: 5
|
||||
# MAM_READY_EMPTY_GIVEUP=5
|
||||
|
||||
# ==============================================================================
|
||||
# deploy / distribution source (for forks/mirrors)
|
||||
# ==============================================================================
|
||||
|
||||
+70
-16
@@ -6,37 +6,91 @@
|
||||
|
||||
## 📌 현재 버전 개요 (Current Release)
|
||||
|
||||
- **프레임워크 버전**: `v3.0.1`
|
||||
- **최신 릴리스 일시**: 2026-08-27 (KST)
|
||||
- **프레임워크 버전**: `v3.1.0`
|
||||
- **최신 릴리스 일시**: 2026-08-28 (KST)
|
||||
- **기준 브랜치**: `main`
|
||||
- **핵심 아키텍처**:
|
||||
- **Herdr Shim Routing & Multi-Workspace Isolation Engine**: `HERDR_WORKSPACE_ID` 환경 변수 스코핑 및 `$WORKSPACE_ROOT/.mam/herdr_workspace_id` 영속화 메커니즘을 통해 다중 워크스페이스 동시 실행 시 페인 교차 오염 원천 차단
|
||||
- **Atomic Safe Paste Insertion (`pane send-text`)**: 미지원 서브커맨드(`agent send`) 제거 및 `pane send-text` 단일 삽입 계약 도입, `send_keys_safe` 실패 전파(`rc 3`) 및 중복 제출(Double Submit) 방지
|
||||
- **Strict Exact-Match Single Pane Resolver**: 부분 문자열 매칭(`in tn`) 완전 제거, 단일 중앙 `_resolve_herdr_pane_id` 헬퍼 기반 5대 서브커맨드(`has-session`, `kill-session`, `capture-pane`, `send-keys`, `paste-buffer`) 무결성 정립
|
||||
- **TUI Readiness & Dialog Token Precision**: `_MAM_DIALOG_TOKENS` 일반 팁(`Yes, try it`) 오탐 제거 및 풀스크린 모달 Escape 거절 브랜치 분리
|
||||
- **Comprehensive Test Suite Milestone**: 412개 전체 테스트 100% PASS (412 passed / 0 failed).
|
||||
- **2-Tier TUI Readiness Model (`S ∨ (W ∧ C)`) & Modal Priority**: 강한 토큰(`S`) 단독 충족 및 약한 토큰(`W`) + 보강 패턴(`C`) 결합 판정식 도입, 모달 선행 검사 및 힌트 분리를 통한 다이얼로그 기아(starvation) 원천 해소
|
||||
- **Adapter Modal Contract (`T-2d`)**: `BaseAgentAdapter` 내 `modal_tokens` 프로퍼티 신설 및 Cline/Claude 맞춤형 모달 시그니처 팩트 브리지 통합
|
||||
- **Fail-Closed Exact Pane Resolver & Multi-Workspace Isolation Engine**: `_resolve_herdr_pane_id` 동종 페인 다중 매치 시 fail-closed(`exit 1`) 차단, `HERDR_WORKSPACE_ID` 스코핑 및 `$WORKSPACE_ROOT/.mam/herdr_workspace_id` 영속화
|
||||
- **Atomic Safe Paste Insertion & Preserved Diagnostic Dumps**: `pane send-text` 단일 안전 삽입 계약, TUI 미준비 시 세션 보존(`exit 0`) 및 `.mam/diagnostics/` 진단 덤프 생성
|
||||
- **Runtime Framework Version Constant (`MAM_VERSION`) & 3-Way Lockstep**: `lib.sh` 내 `MAM_VERSION="3.1.0"` 런타임 진실 공급원 정의 및 3자 동기화 가드 체계 구축
|
||||
- **Comprehensive Test Suite Milestone**: 436개 전체 테스트 100% PASS (436 passed / 0 failed).
|
||||
|
||||
---
|
||||
|
||||
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
||||
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v3.0.1`으로 동기화되어 배포됩니다.
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v3.1.0`으로 동기화되어 배포됩니다.
|
||||
|
||||
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
||||
| :--- | :---: | :--- | :---: |
|
||||
| **`multi-agent-mux-create`** | `3.0.1` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `3.0.1` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `3.0.1` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `3.0.1` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `3.0.1` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `3.0.1` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `3.0.1` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `3.0.1` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
| **`multi-agent-mux-create`** | `3.1.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `3.1.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `3.1.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `3.1.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `3.1.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `3.1.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `3.1.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `3.1.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 버전별 상세 변경 내역 (Changelog)
|
||||
|
||||
### 🚀 `v3.1.0` — 2-Tier TUI Readiness Model, Adapter Modal Contract & Fail-Closed Pane Resolution (2026-08-28)
|
||||
|
||||
> **주요 마일스톤**: 다이얼로그 기아(starvation) 구조적 해소를 위한 2계층 TUI 준비 판정 모델(`S ∨ (W ∧ C)`), 모달/힌트 2계층 토큰 분리, 어댑터별 `modal_tokens` 계약(`T-2d`) 정립, 동종 페인 다중 매치 fail-closed 방어, 준비 실패 세션 진단 보존(`create_session.sh`), `lib.sh` 런타임 버전 상수(`MAM_VERSION="3.1.0"`) 신설 및 436개 전체 테스트 100% PASS 달성.
|
||||
|
||||
#### ⚠️ 동작 변경 및 마이그레이션 안내
|
||||
* **B-1: TUI 미준비 세션 보존 (`create_session.sh`)**:
|
||||
- TUI 준비 감지 타임아웃 발생 시, PID가 생존해 있다면 세션을 강제 종료(`kill-session`)하지 않고 `status: running`, `last_visible_status: tui-ready-timeout`으로 등록한 뒤 `exit 0`(성공)으로 반환합니다.
|
||||
- 구 `v3.0.1`의 무조건 롤백 및 종료 동작을 원할 경우 `MAM_KILL_ON_READY_TIMEOUT=1`을 설정하십시오.
|
||||
* **B-2: 프로세스 사망 시 종료 코드 변경**:
|
||||
- `create_session.sh` 실행 중 PID가 사망한 경우 명시적 구분 코드 `42`(`MAM_EXIT_READY_TIMEOUT`)로 종료합니다.
|
||||
* **B-3: 동종 페인 다중 매치 시 Fail-Closed 거부 (`_resolve_herdr_pane_id`)**:
|
||||
- 동일한 에이전트 종류의 페인이 여러 개 발견되고 정확한 세션명 일치가 실패할 경우, 임의의 첫 번째 페인을 선택하지 않고 즉시 `exit 1`로 실패하여 교차 세션 오염을 원천 차단합니다.
|
||||
* **B-4 & B-5: 환경변수 전파 (`WORKSPACE_ROOT`, `MAM_VERSION`)**:
|
||||
- `WORKSPACE_ROOT` 및 `MAM_VERSION`이 자식 프로세스로 자동 `export`됩니다. `MAM_VERSION`은 런타임 위조 방지를 위해 환경변수 오버라이드가 차단됩니다.
|
||||
|
||||
#### 🔧 기존 워크스페이스 사용자 안내
|
||||
기존 배포된 워크스페이스 사용자는 `.mam.env`에 아래 3종의 신규 환경변수를 추가하여 동작을 튜닝할 수 있습니다:
|
||||
|
||||
| 변수명 | 기본값 | 설명 |
|
||||
| :--- | :---: | :--- |
|
||||
| `MAM_KILL_ON_READY_TIMEOUT` | `0` | `1`로 설정 시 TUI 준비 타임아웃 발생 시 세션을 보존하지 않고 강제 종료(v3.0.1 동작) |
|
||||
| `MAM_DIALOG_TAIL_LINES` | `8` | 블로킹 모달 시그니처를 검사할 페인 하단 비공백 라인 수 |
|
||||
| `MAM_READY_EMPTY_GIVEUP` | `5` | `wait_for_tui_ready`가 페인을 관측 불가(Headless)로 판정하기까지의 연속 빈 캡처 횟수 |
|
||||
|
||||
#### 1. 2계층 TUI 준비 판정 모델 (`S ∨ (W ∧ C)`)
|
||||
* **단독 충분 강한 신호 (`S`)**: `strong_ready_tokens` 또는 8자 이상의 유의미한 `input_placeholder`(`Ask anything...`) 일치 시 준비 완료 선언.
|
||||
* **보강 결합 약한 신호 (`W ∧ C`)**: `weak_ready_tokens`(`history`, `Chat` 등)는 `input_prompt`(`❯`, `>`) 또는 `input_rule_pattern`(`─{10,}`)과 동시 일치할 때만 준비 완료로 판정하여 오탐 차단.
|
||||
* **하강 호환성 (Fallback)**: `input_*` 속성이 미정의된 에이전트(Hermes 등)는 강한 토큰 단독 검사로 안전하게 하강.
|
||||
|
||||
#### 2. 모달/힌트 토큰 분리 및 어댑터 `modal_tokens` 계약 (`T-2a` / `T-2d`)
|
||||
* **토큰 집합 분리**: `_MAM_MODAL_TOKENS`(고신뢰 블로킹 모달)와 `_MAM_HINT_TOKENS`(자연어 TUI 안내 팁)를 분리. `_pane_dialog_open`이 힌트 텍스트로 인해 영구 차단(Starvation)되는 결함 해소. `_MAM_DIALOG_TOKENS`는 하위호환용 합집합으로 존치.
|
||||
* **`BaseAgentAdapter.modal_tokens`**: 에이전트별 특화 모달(Claude의 `Try the new fullscreen renderer?`, Cline의 `Select API Provider` 등)을 팩트 브리지(`MAM_MODAL_TOKENS`)로 통합.
|
||||
|
||||
#### 3. Fail-Closed 페인 해석 및 워크스페이스 스코프 강화 (`R-1` / `R-2`)
|
||||
* `_resolve_herdr_pane_id`에서 다중 매치 시 `sys.exit(1)` 처리 및 `export WORKSPACE_ROOT` 보강으로 격리 안정성 확보.
|
||||
|
||||
#### 4. 준비 실패 세션 진단 보존 및 `MAM_VERSION` 스탬프
|
||||
* `create_session.sh` 실패/타임아웃 시 `.mam/diagnostics/<session>-<timestamp>.txt`로 페인 덤프 및 `MAM Version` 스탬프 자동 기록.
|
||||
|
||||
#### 5. 런타임 버전 상수 `MAM_VERSION` 신설 및 3자 락스텝 검증
|
||||
* `lib.sh` 내 `MAM_VERSION="3.1.0"` 선언 및 `test_version_consistency.py`를 통한 `lib.sh` ↔ `VERSIONS.md` ↔ 8개 `SKILL.md` 3자 일치 자동 검증.
|
||||
|
||||
#### 6. 신규 환경변수 3종 문서화
|
||||
* `.mam.env.example`에 `MAM_KILL_ON_READY_TIMEOUT`, `MAM_DIALOG_TAIL_LINES`, `MAM_READY_EMPTY_GIVEUP` 3종 등록.
|
||||
|
||||
#### 7. 테스트 스위트 확장: 412 → 436 (+24, 100% PASS)
|
||||
* `test_c1_tui_readiness.py` (21 tests), `test_adapter_modal_tokens_contract`, `test_version_consistency.py` 신설 등 총 436개 테스트 무결점 통과.
|
||||
|
||||
#### 8. 교차 피어 리뷰 이력
|
||||
* F-1~F-8 및 R-1, R-2 총 10건의 피어 리뷰 이슈 도출 및 해소:
|
||||
- F-1 (`Yes, try it` 팁 오탐 제거), F-2 (업셀 모달 선행 검사), F-3 (변수 스코프 격리), F-4 (리뷰어 실측 검증 후 자진 철회), F-5/F-6/F-7 (어댑터 팩트 연동 및 세션 패턴 보강), F-8 (비-클로드 어댑터 modal_tokens 계약 정립), R-1 (Fail-closed 페인 리졸버), R-2 (`WORKSPACE_ROOT` export 전파).
|
||||
- 계획 단계 챌린지(`b9c29c3b`)를 통한 C-1 (`MAM_VERSION` 3자 락스텝) 및 C-2 (마이그레이션 안내) 수용.
|
||||
|
||||
### 🚀 `v3.0.1` — Herdr Shim Routing Contract Refactor, Multi-Workspace Isolation & 412-Test Milestone (2026-08-27)
|
||||
|
||||
> **주요 마일스톤**: Herdr 심 라우팅 5대 결함(ISSUE-1~5) 및 F-1~F-4 보완 완결, `HERDR_WORKSPACE_ID` 환경 변수 스코핑 및 파일 기반 격리 영속화 엔진 탑재, `pane send-text` 기반 단일 안전 삽입 계약 정립, 부분 문자열 매칭(`in tn`) 완전 제거, 412개 전체 테스트 100% PASS 달성.
|
||||
|
||||
@@ -84,6 +84,22 @@ def test_adapter_required_properties():
|
||||
assert adapter.delegate_agent_key == delk
|
||||
assert adapter.identity_cache_fields == cache_f
|
||||
|
||||
|
||||
def test_adapter_modal_tokens_contract():
|
||||
"""F-8: Verify adapter-level modal_tokens contract (T-2d)."""
|
||||
expected_modal = {
|
||||
'claude': 'Try the new fullscreen renderer\\?',
|
||||
'cline': 'Select API Provider|Enter API Key|Select an API provider|API Provider|Cline API key',
|
||||
'agy': None,
|
||||
'hermes': None,
|
||||
'grok': None,
|
||||
}
|
||||
for agent, expected in expected_modal.items():
|
||||
adapter = get_adapter(agent)
|
||||
assert adapter is not None
|
||||
assert adapter.modal_tokens == expected, f"modal_tokens mismatch for {agent}"
|
||||
|
||||
|
||||
def test_facts_bridge_eval_contract():
|
||||
import subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -284,8 +284,10 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED";
|
||||
def test_mam_dialog_tokens_exclude_yes_try_it():
|
||||
"""N-1 / F-1: token list must not contain Yes, try it; Escape branch stays."""
|
||||
content = open(_LIB_SH, encoding="utf-8").read()
|
||||
token_line = next(l for l in content.splitlines() if l.startswith("_MAM_DIALOG_TOKENS="))
|
||||
assert "Yes, try it" not in token_line
|
||||
modal_line = next(l for l in content.splitlines() if l.startswith("_MAM_MODAL_TOKENS="))
|
||||
hint_line = next(l for l in content.splitlines() if l.startswith("_MAM_HINT_TOKENS="))
|
||||
assert "Yes, try it" not in modal_line
|
||||
assert "Yes, try it" not in hint_line
|
||||
assert "grep -q 'Yes, try it'" in content
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
# test_c1_tui_readiness.py — Unit and integration tests for TUI readiness,
|
||||
# 2-tier readiness model, modal/hint token separation, and session diagnostics/preservation.
|
||||
|
||||
import os, subprocess, time, json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
_LIB_SH = str(Path(__file__).resolve().parent.parent / ".agents" / "skills" / "lib.sh")
|
||||
_CREATE_SH = str(Path(__file__).resolve().parent.parent / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh")
|
||||
|
||||
|
||||
def _run_lib_helpers(script_body, env_extra=None):
|
||||
import tempfile, shutil
|
||||
tmp_dir = tempfile.mkdtemp(prefix="test_tui_ready_")
|
||||
try:
|
||||
env = dict(os.environ)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
env["WORKSPACE_ROOT"] = tmp_dir
|
||||
wrapper = f"""
|
||||
set -euo pipefail
|
||||
cd "{tmp_dir}"
|
||||
source "{_LIB_SH}"
|
||||
_init_herdr_isolation() {{ :; }}
|
||||
{script_body}
|
||||
"""
|
||||
return subprocess.run(["bash", "-c", wrapper], capture_output=True, text=True, env=env, cwd=tmp_dir)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _lib_helper_src():
|
||||
content = open(_LIB_SH, encoding="utf-8").read()
|
||||
start = content.find("_herdr_ws_id_file() {")
|
||||
end = content.find('cmd="${1:-}"', start)
|
||||
assert start != -1 and end != -1
|
||||
return content[start:end]
|
||||
|
||||
|
||||
|
||||
def test_c1_hint_tokens_only_dialog_closed():
|
||||
"""C-1: Hint tokens (Use arrow keys / Esc to cancel / Press Enter) alone must return dialog CLOSED."""
|
||||
screen = """Select an option:
|
||||
1. Option A
|
||||
2. Option B
|
||||
Use arrow keys to navigate · Esc to cancel
|
||||
"""
|
||||
script = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "DIALOG_CLOSED" in res.stdout
|
||||
|
||||
|
||||
def test_c1b_hint_press_enter_sends_enter():
|
||||
"""C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED, and wait_for_tui_ready must send Enter exactly once per iteration (30 total across 30 iterations, not 60)."""
|
||||
screen = """Some startup banner
|
||||
Press Enter to continue
|
||||
"""
|
||||
script = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
KEYS=/tmp/mam-press-enter-keys.$$
|
||||
: > "$KEYS"
|
||||
_sks_herdr() {{
|
||||
if [ "${{1:-}}" = "send-keys" ]; then echo "$*" >> "$KEYS"; fi
|
||||
return 0
|
||||
}}
|
||||
sleep() {{ :; }}
|
||||
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
|
||||
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
|
||||
export MAM_WEAK_READY_TOKENS=''
|
||||
export MAM_INPUT_PLACEHOLDER=''
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess custom || rc=$?
|
||||
echo "RC=$rc"
|
||||
echo "KEYS_COUNT=$(grep -c "send-keys -t dummy-sess Enter" "$KEYS" || echo 0)"
|
||||
rm -f "$KEYS"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "DIALOG_CLOSED" in res.stdout
|
||||
assert "KEYS_COUNT=30" in res.stdout
|
||||
|
||||
|
||||
def test_c1c_transition_to_upsell_modal_sends_escape_not_enter():
|
||||
"""C-1c: Screen transition from Press Enter to Upsell Modal (Yes, try it) sends Escape, not duplicate Enter."""
|
||||
state_file = f"/tmp/mam-screen-state.{os.getpid()}"
|
||||
script = f"""
|
||||
echo "1" > "{state_file}"
|
||||
_pane_capture() {{
|
||||
local s
|
||||
s=$(cat "{state_file}" 2>/dev/null || echo 1)
|
||||
if [ "$s" = "1" ]; then
|
||||
printf 'Some banner\\nPress Enter to continue\\n'
|
||||
else
|
||||
printf 'Claude Code\\nYes, try it\\n'
|
||||
fi
|
||||
}}
|
||||
KEYS=/tmp/mam-transition-keys.{os.getpid()}
|
||||
: > "$KEYS"
|
||||
_sks_herdr() {{
|
||||
if [ "${{1:-}}" = "send-keys" ]; then
|
||||
echo "$*" >> "$KEYS"
|
||||
# Transition to screen 2 on first Enter
|
||||
if [ "${{4:-}}" = "Enter" ]; then
|
||||
echo "2" > "{state_file}"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}}
|
||||
sleep() {{ :; }}
|
||||
export MAM_STRONG_READY_TOKENS='AnthropicReady'
|
||||
export MAM_WEAK_READY_TOKENS=''
|
||||
export MAM_INPUT_PLACEHOLDER=''
|
||||
export MAM_TUI_TIMEOUT=2
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess claude 2 || rc=$?
|
||||
echo "RC=$rc"
|
||||
echo "KEYS_CONTENT=$(tr '\\n' '|' < "$KEYS")"
|
||||
rm -f "$KEYS" "{state_file}"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "send-keys -t dummy-sess Enter" in res.stdout
|
||||
assert "send-keys -t dummy-sess Escape" in res.stdout
|
||||
assert "send-keys -t dummy-sess Enter|send-keys -t dummy-sess Escape|" in res.stdout
|
||||
|
||||
|
||||
def test_c2_modal_tokens_dialog_open():
|
||||
"""C-2: High-confidence modal tokens (e.g. Do you trust the files) must return dialog OPEN."""
|
||||
screen = """Security Alert
|
||||
Do you trust the files in this repository?
|
||||
"""
|
||||
script = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "DIALOG_OPEN" in res.stdout
|
||||
|
||||
|
||||
def test_c3_strong_token_and_hint_token_readiness_succeeds():
|
||||
"""C-3: Strong ready token + hint token together must detect readiness immediately (rc 0)."""
|
||||
screen = """Cline v3.0.0
|
||||
Use arrow keys to select a tool
|
||||
❯
|
||||
"""
|
||||
script = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
sleep() {{ :; }}
|
||||
wait_for_tui_ready dummy-sess cline
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "TUI detected ready" in res.stdout
|
||||
|
||||
|
||||
def test_c4_placeholder_only_readiness_succeeds():
|
||||
"""C-4: Long placeholder (Ask anything..., len>=8) alone satisfies S-tier readiness."""
|
||||
screen = """Welcome to the AI Assistant
|
||||
Ask anything...
|
||||
"""
|
||||
script = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
sleep() {{ :; }}
|
||||
# Override ready tokens so strong tokens do not match
|
||||
export MAM_STRONG_READY_TOKENS='NonExistentStrongToken'
|
||||
export MAM_WEAK_READY_TOKENS=''
|
||||
export MAM_INPUT_PLACEHOLDER='Ask anything...'
|
||||
wait_for_tui_ready dummy-sess cline
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "TUI detected ready" in res.stdout
|
||||
|
||||
|
||||
def test_c4b_box_modal_does_not_succeed():
|
||||
"""C-4b: Box modal frame (────── + solo ❯) with no strong token or placeholder must NOT satisfy readiness."""
|
||||
screen = """┌───────────────────────────────────┐
|
||||
│ Please select authentication mode │
|
||||
│ ───────────────────────────────── │
|
||||
│ 1. API Key │
|
||||
│ 2. OAuth │
|
||||
└───────────────────────────────────┘
|
||||
❯
|
||||
"""
|
||||
script = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
sleep() {{ :; }}
|
||||
export MAM_STRONG_READY_TOKENS='NonExistentStrongToken'
|
||||
export MAM_WEAK_READY_TOKENS='Chat|history'
|
||||
export MAM_INPUT_PLACEHOLDER='Ask anything...'
|
||||
export MAM_INPUT_PROMPT='❯'
|
||||
export MAM_INPUT_RULE_PATTERN='─{{10,}}'
|
||||
_MAM_MODAL_TOKENS='NON_MATCHING_MODAL'
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess cline || rc=$?
|
||||
echo "RC=$rc"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "RC=1" in res.stdout
|
||||
|
||||
|
||||
def test_c4c_prompt_and_rule_corroboration_alone_cannot_satisfy_readiness():
|
||||
"""C-4c: Neither prompt alone, rule alone, nor prompt+rule alone can satisfy readiness (C-tier cannot promote alone)."""
|
||||
screen = """────────────────────────────
|
||||
❯
|
||||
"""
|
||||
script = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
sleep() {{ :; }}
|
||||
export MAM_STRONG_READY_TOKENS='NonExistentToken'
|
||||
export MAM_WEAK_READY_TOKENS='NonExistentWeak'
|
||||
export MAM_INPUT_PLACEHOLDER='NonExistentPlaceholder'
|
||||
export MAM_INPUT_PROMPT='❯'
|
||||
export MAM_INPUT_RULE_PATTERN='─{{10,}}'
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess cline || rc=$?
|
||||
echo "RC=$rc"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "RC=1" in res.stdout
|
||||
|
||||
|
||||
def test_c4d_weak_token_requires_corroboration():
|
||||
"""C-4d: Weak token (Chat) alone fails; Weak token + prompt in last 5 lines succeeds."""
|
||||
# 1. Weak token alone without prompt in last 5 lines
|
||||
screen_weak_alone = """Chat history from earlier session:
|
||||
line 1
|
||||
line 2
|
||||
line 3
|
||||
line 4
|
||||
line 5
|
||||
line 6
|
||||
"""
|
||||
script1 = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen_weak_alone}'; }}
|
||||
sleep() {{ :; }}
|
||||
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
|
||||
export MAM_WEAK_READY_TOKENS='Chat|history'
|
||||
export MAM_INPUT_PLACEHOLDER=''
|
||||
export MAM_INPUT_PROMPT='❯'
|
||||
export MAM_INPUT_RULE_PATTERN=''
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess cline || rc=$?
|
||||
echo "RC=$rc"
|
||||
"""
|
||||
res1 = _run_lib_helpers(script1)
|
||||
assert res1.returncode == 0, res1.stderr + res1.stdout
|
||||
assert "RC=1" in res1.stdout
|
||||
|
||||
# 2. Weak token + prompt in last 5 lines
|
||||
screen_weak_corroborated = """Chat Session Active
|
||||
Ready for input
|
||||
❯
|
||||
"""
|
||||
script2 = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen_weak_corroborated}'; }}
|
||||
sleep() {{ :; }}
|
||||
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
|
||||
export MAM_WEAK_READY_TOKENS='Chat|history'
|
||||
export MAM_INPUT_PLACEHOLDER=''
|
||||
export MAM_INPUT_PROMPT='❯'
|
||||
export MAM_INPUT_RULE_PATTERN=''
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess cline || rc=$?
|
||||
echo "RC=$rc"
|
||||
"""
|
||||
res2 = _run_lib_helpers(script2)
|
||||
assert res2.returncode == 0, res2.stderr + res2.stdout
|
||||
assert "RC=0" in res2.stdout
|
||||
|
||||
|
||||
def test_c4e_short_placeholder_not_promoted_to_strong():
|
||||
"""C-4e: Placeholder with length < 8 chars must NOT be promoted to strong S-tier."""
|
||||
screen = """Ask
|
||||
"""
|
||||
script = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
sleep() {{ :; }}
|
||||
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
|
||||
export MAM_WEAK_READY_TOKENS=''
|
||||
export MAM_INPUT_PLACEHOLDER='Ask'
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess custom || rc=$?
|
||||
echo "RC=$rc"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "RC=1" in res.stdout
|
||||
|
||||
|
||||
def test_c5_hermes_fallback_to_strong_only():
|
||||
"""C-5: When input_* are all empty (Hermes), strong_ready_tokens alone satisfies readiness."""
|
||||
screen = """Hermes Agent v1.0
|
||||
System initialized.
|
||||
"""
|
||||
script = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
sleep() {{ :; }}
|
||||
wait_for_tui_ready dummy-sess hermes
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "TUI detected ready" in res.stdout
|
||||
|
||||
|
||||
def test_c6_consecutive_empty_captures_unobservable_rc2():
|
||||
"""C-6: Consecutive empty captures give up as unobservable/headless (rc 2) without burning 30s."""
|
||||
script = """
|
||||
_pane_capture() { echo ""; }
|
||||
sleep() { :; }
|
||||
export MAM_READY_EMPTY_GIVEUP=3
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess claude || rc=$?
|
||||
echo "RC=$rc"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "RC=2" in res.stdout
|
||||
assert "unobservable/headless pane" in res.stderr
|
||||
|
||||
|
||||
def test_c7_readiness_checks_decoded_text_not_raw_json():
|
||||
"""C-7: Raw JSON field names (e.g. Chat in JSON envelope) do not cause false positives."""
|
||||
raw_json = json.dumps({"result": {"read": {"text": "Just raw text with no ready tokens", "Chat": 1}}})
|
||||
script = f"""
|
||||
_sks_herdr() {{
|
||||
if [ "${{1:-}}" = "capture-pane" ]; then
|
||||
printf '%s' '{raw_json}'
|
||||
fi
|
||||
return 0
|
||||
}}
|
||||
sleep() {{ :; }}
|
||||
export MAM_STRONG_READY_TOKENS='NonExistentStrong'
|
||||
export MAM_WEAK_READY_TOKENS='Chat'
|
||||
export MAM_INPUT_PLACEHOLDER=''
|
||||
export MAM_INPUT_PROMPT='❯'
|
||||
export MAM_INPUT_RULE_PATTERN=''
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess cline || rc=$?
|
||||
echo "RC=$rc"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "RC=1" in res.stdout
|
||||
|
||||
|
||||
def test_c8_mam_dialog_tokens_is_union_of_modal_and_hint():
|
||||
"""C-8: _MAM_DIALOG_TOKENS must remain the union of _MAM_MODAL_TOKENS and _MAM_HINT_TOKENS."""
|
||||
script = """
|
||||
echo "MODAL=$_MAM_MODAL_TOKENS"
|
||||
echo "HINT=$_MAM_HINT_TOKENS"
|
||||
echo "DIALOG=$_MAM_DIALOG_TOKENS"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
lines = dict(line.split("=", 1) for line in res.stdout.strip().splitlines())
|
||||
expected_dialog = f"{lines['MODAL']}|{lines['HINT']}"
|
||||
assert lines["DIALOG"] == expected_dialog
|
||||
|
||||
|
||||
def test_c8b_agent_modal_tokens_merged_into_dialog_open():
|
||||
"""C-8b (T-2d / F-8): Per-agent modal_tokens from adapter are merged into _pane_dialog_open and block send_keys_safe."""
|
||||
# 1. Direct _pane_dialog_open test
|
||||
screen = """Please enter your Cline API key to continue:
|
||||
"""
|
||||
script1 = f"""
|
||||
_pane_capture() {{ printf '%s' '{screen}'; }}
|
||||
export MAM_MODAL_TOKENS='Cline API key'
|
||||
if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi
|
||||
"""
|
||||
res1 = _run_lib_helpers(script1)
|
||||
assert res1.returncode == 0, res1.stderr + res1.stdout
|
||||
assert "DIALOG_OPEN" in res1.stdout
|
||||
|
||||
# 2. Dynamic facts resolution via send_keys_safe on cline session
|
||||
script2 = f"""
|
||||
_pane_quiescent() {{ return 0; }}
|
||||
_pane_capture() {{ printf '%s' 'Select an API provider to get started:'; }}
|
||||
_sks_herdr() {{ return 0; }}
|
||||
rc=0
|
||||
SKS_DIALOG_TIMEOUT=1 send_keys_safe "my-project-worker-cline-01" "some text" || rc=$?
|
||||
echo "RC=$rc"
|
||||
"""
|
||||
res2 = _run_lib_helpers(script2)
|
||||
assert res2.returncode == 0, res2.stderr + res2.stdout
|
||||
assert "RC=2" in res2.stdout, f"Expected dialog blocking (RC=2) for cline modal: {res2.stdout}"
|
||||
|
||||
|
||||
|
||||
def test_c9_create_session_timeout_alive_pid_preserves_session(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""C-9 & C-11: create_session with readiness timeout + alive PID preserves session with timeout status (exit 0) and dumps diagnostic."""
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
diag_dir = mam_sandbox / ".mam" / "diagnostics"
|
||||
yaml_file = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||
|
||||
# Make wait_for_tui_ready immediately return 1 in sandboxed lib.sh
|
||||
lib_content = lib_path.read_text()
|
||||
lib_path.write_text(lib_content.replace("wait_for_tui_ready() {", "wait_for_tui_ready() { return 1; #"))
|
||||
# In sandbox create_session.sh, simulate alive process using $$
|
||||
create_content = script_path.read_text()
|
||||
script_path.write_text(create_content.replace(
|
||||
'PANE_PID_CHECK=$(_herdr list-panes',
|
||||
'PANE_PID_CHECK=$$ # $(_herdr list-panes'
|
||||
))
|
||||
|
||||
cmd = [
|
||||
"bash", str(script_path),
|
||||
"--workspace", str(mam_sandbox),
|
||||
"--agent", "cline",
|
||||
"--role", "creator",
|
||||
"--session", "test-timeout-sess",
|
||||
"--onboard"
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "WARNING: agent TUI never became ready within timeout, but process" in res.stderr
|
||||
|
||||
# Check diagnostic dump file created (C-11)
|
||||
dump_files = list(diag_dir.glob("test-timeout-sess-*.txt"))
|
||||
assert len(dump_files) == 1
|
||||
dump_content = dump_files[0].read_text()
|
||||
assert "ReturnCode: 1" in dump_content
|
||||
|
||||
# Check YAML registration with tui-ready-timeout and NO delegate job (N-C)
|
||||
assert yaml_file.exists()
|
||||
yaml_content = yaml_file.read_text()
|
||||
assert "tui-ready-timeout" in yaml_content
|
||||
assert "delegate_job_id: null" in yaml_content or "delegate_job_id: None" in yaml_content or "delegate_job_id:" not in yaml_content or "delegate_job_id: ''" in yaml_content
|
||||
|
||||
|
||||
def test_c10_create_session_timeout_dead_pid_kills_session(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""C-10: create_session with readiness timeout + dead PID triggers rollback kill and exits with code 42."""
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
diag_dir = mam_sandbox / ".mam" / "diagnostics"
|
||||
|
||||
# Make wait_for_tui_ready return 1 and mock list-panes to return dead PID
|
||||
lib_content = lib_path.read_text()
|
||||
create_content = script_path.read_text()
|
||||
script_path.write_text(create_content.replace(
|
||||
'PANE_PID_CHECK=$(_herdr list-panes',
|
||||
'PANE_PID_CHECK=99999999 # $(_herdr list-panes'
|
||||
))
|
||||
lib_path.write_text(lib_content.replace("wait_for_tui_ready() {", "wait_for_tui_ready() { return 1; #"))
|
||||
|
||||
cmd = [
|
||||
"bash", str(script_path),
|
||||
"--workspace", str(mam_sandbox),
|
||||
"--agent", "cline",
|
||||
"--role", "creator",
|
||||
"--session", "test-dead-sess"
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res.returncode == 42, res.stderr + res.stdout
|
||||
assert "ERROR: agent TUI never became ready" in res.stderr
|
||||
|
||||
# Check diagnostic dump file created
|
||||
dump_files = list(diag_dir.glob("test-dead-sess-*.txt"))
|
||||
assert len(dump_files) == 1
|
||||
assert "ReturnCode: 1" in dump_files[0].read_text()
|
||||
|
||||
|
||||
def test_adapter_strong_weak_partition():
|
||||
"""N-D: Verify strong_ready_tokens and weak_ready_tokens partition ready_tokens for all adapters."""
|
||||
from lib_py.agents.registry import get_adapter
|
||||
for agent in ('claude', 'agy', 'hermes', 'cline', 'grok'):
|
||||
adapter = get_adapter(agent)
|
||||
assert adapter is not None
|
||||
ready_set = set(t for t in adapter.ready_tokens.split('|') if t)
|
||||
strong_set = set(t for t in adapter.strong_ready_tokens.split('|') if t)
|
||||
weak_set = set(t for t in adapter.weak_ready_tokens.split('|') if t)
|
||||
assert strong_set | weak_set == ready_set, f"Mismatch in {agent}: {strong_set} | {weak_set} != {ready_set}"
|
||||
assert strong_set.isdisjoint(weak_set), f"Overlap in {agent}: {strong_set & weak_set}"
|
||||
|
||||
|
||||
def test_sks_agent_suffix_resolution_not_substring():
|
||||
"""Verify send_keys_safe resolves agent by strict suffix without substring false positives."""
|
||||
script = """
|
||||
_pane_quiescent() { return 0; }
|
||||
_pane_capture() { echo "prompt > "; }
|
||||
_sks_herdr() { return 0; }
|
||||
RESOLVED=""
|
||||
mock_py() {
|
||||
if [ "$1" = "-m" ] && [ "$2" = "lib_py.agents" ] && [ "$3" = "facts" ]; then
|
||||
echo "echo RESOLVED_AGENT=$4"
|
||||
fi
|
||||
}
|
||||
_delegate_py_bin() { echo "mock_py"; }
|
||||
|
||||
check_sks() {
|
||||
local sess="$1"
|
||||
unset MAM_MODAL_TOKENS
|
||||
# Call send_keys_safe and capture output
|
||||
send_keys_safe "$sess" "hello" 2>&1 || true
|
||||
}
|
||||
|
||||
echo "1: $(check_sks 'claude-reviewer-cline-01')"
|
||||
echo "2: $(check_sks 'my-claude-creator-cline')"
|
||||
echo "3: $(check_sks 'reviewer-creator-grok-01')"
|
||||
echo "4: $(check_sks 'planner-reviewer-claude-01')"
|
||||
echo "5: $(check_sks 'test-worker-hermes-02')"
|
||||
echo "6: $(check_sks 'workspace-creator-agy-05')"
|
||||
echo "7: $(check_sks 'claude')"
|
||||
echo "8: $(check_sks 'cline-01')"
|
||||
echo "9: $(check_sks 'grok-02')"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "1: RESOLVED_AGENT=cline" in res.stdout
|
||||
assert "2: RESOLVED_AGENT=cline" in res.stdout
|
||||
assert "3: RESOLVED_AGENT=grok" in res.stdout
|
||||
assert "4: RESOLVED_AGENT=claude" in res.stdout
|
||||
assert "5: RESOLVED_AGENT=hermes" in res.stdout
|
||||
assert "6: RESOLVED_AGENT=agy" in res.stdout
|
||||
assert "7: RESOLVED_AGENT=claude" in res.stdout
|
||||
assert "8: RESOLVED_AGENT=cline" in res.stdout
|
||||
assert "9: RESOLVED_AGENT=grok" in res.stdout
|
||||
|
||||
|
||||
def test_f2_upsell_modal_defense_with_real_facts_and_banner():
|
||||
"""F-2/F-6: Real Claude Code banner with upsell modal must trigger modal defense, not return ready."""
|
||||
script = """
|
||||
SENT_KEYS=""
|
||||
_sks_herdr() {
|
||||
if [ "$1" = "send-keys" ]; then
|
||||
SENT_KEYS="$SENT_KEYS $*"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
COUNT_FILE=$(mktemp)
|
||||
echo "0" > "$COUNT_FILE"
|
||||
|
||||
_pane_capture() {
|
||||
local c
|
||||
c=$(cat "$COUNT_FILE")
|
||||
c=$((c + 1))
|
||||
echo "$c" > "$COUNT_FILE"
|
||||
if [ "$c" -le 2 ]; then
|
||||
# First 2 captures (loop iter 1 capture + tail check): Claude Code banner with Fullscreen Modal
|
||||
cat << 'EOF'
|
||||
Claude Code (Sonnet 3.5)
|
||||
Try the new fullscreen renderer?
|
||||
Flicker-free output
|
||||
Selected text auto-copies to your clipboard
|
||||
Yes, try it
|
||||
EOF
|
||||
else
|
||||
# Subsequent captures (after Escape sent): modal dismissed, normal prompt
|
||||
cat << 'EOF'
|
||||
Claude Code (Sonnet 3.5)
|
||||
❯
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
rc=0
|
||||
wait_for_tui_ready "my-claude-sess" "claude" || rc=$?
|
||||
rm -f "$COUNT_FILE"
|
||||
echo "RC=$rc"
|
||||
echo "KEYS=$SENT_KEYS"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "Escape" in res.stdout, f"Expected Escape to be sent for upsell modal: {res.stdout}"
|
||||
assert "RC=0" in res.stdout
|
||||
|
||||
|
||||
def test_r1_resolve_pane_id_fail_closed_on_multiple_same_kind_panes():
|
||||
"""R-1: If fallback finds multiple panes of same agent kind, fail closed (exit 1)."""
|
||||
helper = _lib_helper_src()
|
||||
script = helper + """
|
||||
|
||||
# Mock real herdr pane list returning 2 claude panes
|
||||
_real_herdr() {
|
||||
cat << 'EOF'
|
||||
{"result": {"panes": [
|
||||
{"pane_id": "w1:p1", "label": "unrelated-1", "agent": "claude"},
|
||||
{"pane_id": "w1:p2", "label": "unrelated-2", "agent": "claude"}
|
||||
]}}
|
||||
EOF
|
||||
}
|
||||
|
||||
# Create a dummy yaml with unknown session mapped to claude cmd
|
||||
mkdir -p .mam
|
||||
cat << 'EOF' > .mam/agent-sessions.yaml
|
||||
herdr_sessions:
|
||||
- name: my-unmatched-session
|
||||
pane:
|
||||
cmd: claude
|
||||
EOF
|
||||
|
||||
rc=0
|
||||
pid=$(_resolve_herdr_pane_id "my-unmatched-session") || rc=$?
|
||||
echo "RC=$rc|PID=$pid"
|
||||
"""
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "RC=1|PID=" in res.stdout, f"Expected fail-closed RC=1 and empty PID: {res.stdout}"
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# ==============================================================================
|
||||
# test_version_consistency.py — 3-Way Version Lockstep & Immutability Test Suite
|
||||
# ==============================================================================
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LIB_SH = REPO_ROOT / ".agents" / "skills" / "lib.sh"
|
||||
VERSIONS_MD = REPO_ROOT / "VERSIONS.md"
|
||||
SKILLS_DIR = REPO_ROOT / ".agents" / "skills"
|
||||
|
||||
SKILL_NAMES = [
|
||||
"multi-agent-mux-create",
|
||||
"multi-agent-mux-stop",
|
||||
"multi-agent-mux-resume",
|
||||
"multi-agent-mux-status",
|
||||
"multi-agent-mux-monitor",
|
||||
"multi-agent-mux-delegate-job",
|
||||
"multi-agent-mux-loop",
|
||||
"multi-agent-mux-orc-onboard",
|
||||
]
|
||||
|
||||
|
||||
def _get_lib_sh_mam_version():
|
||||
cmd = f'source "{LIB_SH}" && echo "$MAM_VERSION"'
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(REPO_ROOT))
|
||||
assert res.returncode == 0, f"Failed to source lib.sh: {res.stderr}"
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def _get_versions_md_versions():
|
||||
text = VERSIONS_MD.read_text(encoding="utf-8")
|
||||
|
||||
# 1. Current release framework version
|
||||
m_curr = re.search(r"-\s*\*\*프레임워크 버전\*\*:\s*`v(\d+\.\d+\.\d+)`", text)
|
||||
assert m_curr, "Failed to parse Current Release framework version in VERSIONS.md"
|
||||
current_framework_ver = m_curr.group(1)
|
||||
|
||||
# 2. Skill matrix table versions
|
||||
table_matches = re.findall(r"\|\s*\*\*`([^`]+)`\*\*\s*\|\s*`(\d+\.\d+\.\d+)`\s*\|", text)
|
||||
matrix_dict = dict(table_matches)
|
||||
|
||||
return current_framework_ver, matrix_dict
|
||||
|
||||
|
||||
def _get_skill_md_frontmatter_version(skill_name: str):
|
||||
skill_file = SKILLS_DIR / skill_name / "SKILL.md"
|
||||
assert skill_file.is_file(), f"Skill file not found: {skill_file}"
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
|
||||
# Strict frontmatter parsing
|
||||
fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||
assert fm_match, f"No YAML frontmatter found in {skill_file}"
|
||||
fm_data = yaml.safe_load(fm_match.group(1))
|
||||
assert isinstance(fm_data, dict) and "version" in fm_data, f"No version in frontmatter of {skill_file}"
|
||||
return str(fm_data["version"])
|
||||
|
||||
|
||||
def test_three_way_version_lockstep():
|
||||
"""T-4: lib.sh(MAM_VERSION) == VERSIONS.md(Current + Matrix 8 items) == 8x SKILL.md frontmatters."""
|
||||
# 1. lib.sh runtime constant
|
||||
lib_ver = _get_lib_sh_mam_version()
|
||||
assert lib_ver != "", "MAM_VERSION in lib.sh is empty"
|
||||
|
||||
# 2. VERSIONS.md
|
||||
vmd_curr, vmd_matrix = _get_versions_md_versions()
|
||||
assert lib_ver == vmd_curr, f"lib.sh MAM_VERSION ({lib_ver}) != VERSIONS.md current ({vmd_curr})"
|
||||
|
||||
for skill in SKILL_NAMES:
|
||||
assert skill in vmd_matrix, f"Skill {skill} missing from VERSIONS.md matrix"
|
||||
assert vmd_matrix[skill] == lib_ver, f"VERSIONS.md matrix for {skill} ({vmd_matrix[skill]}) != lib.sh ({lib_ver})"
|
||||
|
||||
# 3. 8x SKILL.md files
|
||||
for skill in SKILL_NAMES:
|
||||
fm_ver = _get_skill_md_frontmatter_version(skill)
|
||||
assert fm_ver == lib_ver, f"{skill}/SKILL.md version ({fm_ver}) != lib.sh ({lib_ver})"
|
||||
|
||||
|
||||
def test_mam_version_is_not_env_overridable():
|
||||
"""T-0 (Constraint 1): MAM_VERSION cannot be spoofed by caller environment."""
|
||||
cmd = f'source "{LIB_SH}" && echo "$MAM_VERSION"'
|
||||
env = os.environ.copy()
|
||||
env["MAM_VERSION"] = "9.9.9"
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, env=env, cwd=str(REPO_ROOT))
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() != "9.9.9", "MAM_VERSION was improperly overwritten by caller environment"
|
||||
assert res.stdout.strip() == _get_lib_sh_mam_version()
|
||||
Reference in new issue
Block a user