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
+187
-12
@@ -29,7 +29,7 @@ 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="4.4.0"
|
||||
MAM_VERSION="4.4.1"
|
||||
export MAM_VERSION
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||
|
||||
@@ -237,7 +237,11 @@ _real_herdr() {
|
||||
session=""
|
||||
fi
|
||||
if [ -n "$session" ]; then
|
||||
"$REAL_HERDR" --session "$session" "$@"
|
||||
if [ "${1:-}" = "--session" ] || [ "${1:-}" = "-s" ]; then
|
||||
"$REAL_HERDR" "$@"
|
||||
else
|
||||
"$REAL_HERDR" --session "$session" "$@"
|
||||
fi
|
||||
else
|
||||
"$REAL_HERDR" "$@"
|
||||
fi
|
||||
@@ -1896,12 +1900,17 @@ _delegate_submit_job_legacy() {
|
||||
# a missing script, or a broker failure never aborts the caller.
|
||||
_delegate_publish_event_legacy() {
|
||||
local job_id="$1" event="$2" detail="${3:-}"
|
||||
local tok="${4:-}" att="${5:-}"
|
||||
[ -n "$job_id" ] || return 0
|
||||
local py_bin pub
|
||||
py_bin="$(_delegate_py_bin)"
|
||||
pub="$(_delegate_script publish_event.py)"
|
||||
[ -n "$pub" ] && [ -f "$pub" ] || return 0
|
||||
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" || true
|
||||
local -a extra=()
|
||||
[ -n "${MAM_JOBS_DIR:-}" ] && extra+=(--registry-dir "$MAM_JOBS_DIR")
|
||||
[ -n "$tok" ] && extra+=(--expect-dispatch-token "$tok")
|
||||
[ -n "$att" ] && extra+=(--attempt-id "$att")
|
||||
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
|
||||
}
|
||||
|
||||
# start_watchdog <job_id> [workdir]
|
||||
@@ -1985,12 +1994,17 @@ delegate_submit_job() {
|
||||
# failure must never abort the caller.
|
||||
delegate_publish_event() {
|
||||
local job_id="$1" event="$2" detail="${3:-}"
|
||||
local tok="${4:-}" att="${5:-}"
|
||||
[ -n "$job_id" ] || return 0
|
||||
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_publish_event_legacy "$@"; return $?; fi
|
||||
local pub
|
||||
pub="$(_delegate_script publish_event.py)"
|
||||
[ -n "$pub" ] && [ -f "$pub" ] || return 0
|
||||
_delegation_py publish --job "$job_id" --event "$event" --detail "$detail" || true
|
||||
local -a extra=()
|
||||
[ -n "${MAM_JOBS_DIR:-}" ] && extra+=(--registry-dir "$MAM_JOBS_DIR")
|
||||
[ -n "$tok" ] && extra+=(--expect-dispatch-token "$tok")
|
||||
[ -n "$att" ] && extra+=(--attempt-id "$att")
|
||||
_delegation_py publish --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
|
||||
}
|
||||
|
||||
# ensure_subscriber_daemon <job_id> [workdir]
|
||||
@@ -2219,6 +2233,91 @@ _pane_dialog_open() {
|
||||
_pane_tail "$1" "${MAM_DIALOG_TAIL_LINES:-8}" | grep -Eq "$pat"
|
||||
}
|
||||
|
||||
# wait_for_agent_idle <sess> [timeout_ms]
|
||||
# Phase 2 사전 TUI 유휴 핸드셰이크:
|
||||
# 0: idle / done (성공)
|
||||
# 1: agent still working (최종 타임아웃)
|
||||
# 2: blocked (대화창/모달 차단)
|
||||
# 3: unknown (휴리스틱 폴백 신호)
|
||||
wait_for_agent_idle() {
|
||||
local sess="${1#herdr:}"
|
||||
local timeout_ms="${2:-${SKS_IDLE_TIMEOUT_MS:-120000}}"
|
||||
|
||||
if [ "${SKS_IDLE_HANDSHAKE:-1}" = "0" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local target
|
||||
target=$(_sanitize_herdr_agent_name "$sess")
|
||||
|
||||
local raw_status="" agent_st=""
|
||||
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
||||
if [ -n "$raw_status" ]; then
|
||||
agent_st="$("$(_delegate_py_bin)" -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' <<< "$raw_status" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$agent_st" ] || [ "$agent_st" = "unknown" ]; then
|
||||
return 3
|
||||
fi
|
||||
if [ "$agent_st" = "blocked" ]; then
|
||||
return 2
|
||||
fi
|
||||
if [ "$agent_st" = "idle" ] || [ "$agent_st" = "done" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local max_retries="${SKS_IDLE_RETRIES:-2}"
|
||||
local backoff_s="${SKS_IDLE_RETRY_BACKOFF_S:-10}"
|
||||
local try=0
|
||||
while [ "$try" -le "$max_retries" ]; do
|
||||
local watcher_out="" watcher_rc=0
|
||||
watcher_out="$("$(_delegate_py_bin)" -m lib_py.loop.watcher wait "$target" \
|
||||
--session "${HERDR_SESSION_NAME:-default}" \
|
||||
--until idle --until done --until blocked \
|
||||
--timeout-ms "$timeout_ms" 2>/dev/null)" || watcher_rc=$?
|
||||
|
||||
local parsed_st=""
|
||||
if [ -n "$watcher_out" ]; then
|
||||
parsed_st="$("$(_delegate_py_bin)" -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
print(d.get("agent_status", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' <<< "$watcher_out" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ $watcher_rc -eq 0 ]; then
|
||||
if [ "$parsed_st" = "blocked" ]; then
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
elif [ $watcher_rc -eq 2 ]; then
|
||||
if [ "$parsed_st" = "blocked" ]; then
|
||||
return 2
|
||||
fi
|
||||
if [ "$try" -lt "$max_retries" ]; then
|
||||
sleep "$backoff_s"
|
||||
try=$((try + 1))
|
||||
continue
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
return 3
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# send_keys_safe <sess> <text> [job_id]
|
||||
# 1. Wait for renderer quiescence (RC-A). If unobservable (headless), bypass visual checks.
|
||||
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
|
||||
@@ -2232,8 +2331,7 @@ 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
|
||||
local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN 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=""
|
||||
@@ -2274,10 +2372,83 @@ send_keys_safe() {
|
||||
|
||||
local agent_target
|
||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||
# Native herdr 0.8+ fast path: agent prompt handles atomic text + enter submission
|
||||
# Gated behind quiescence and dialog checks; returns 0 on RPC success to prevent duplicate input
|
||||
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
|
||||
return 0
|
||||
|
||||
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
|
||||
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
|
||||
local b_deadline=$(( $(date +%s) + ${SKS_BUSY_TIMEOUT:-120} ))
|
||||
local still_busy=1
|
||||
while [ "$(date +%s)" -lt "$b_deadline" ]; do
|
||||
local tail_pane
|
||||
tail_pane=$(_pane_tail "$sess" 8)
|
||||
if ! printf '%s\n' "$tail_pane" | grep -Fq "esc to interrupt" && ! printf '%s\n' "$tail_pane" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
|
||||
still_busy=0
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$still_busy" -eq 1 ]; then
|
||||
echo "send_keys_safe: pane still busy after SKS_BUSY_TIMEOUT ($sess)" >&2
|
||||
return 4
|
||||
fi
|
||||
fi
|
||||
|
||||
# Native herdr 0.8+ fast path: agent prompt handles atomic submission
|
||||
if [ "${SKS_PROMPT_WAIT:-1}" = "1" ]; then
|
||||
local p_out="" p_rc=0
|
||||
local _p_tmp
|
||||
_p_tmp=$(mktemp 2>/dev/null || echo "/tmp/sks_p_${sess}_$$")
|
||||
if _sks_herdr agent prompt "$agent_target" "$text" --wait --until working --timeout 5000 >"$_p_tmp" 2>&1; then
|
||||
rm -f "$_p_tmp" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
p_rc=$?
|
||||
p_out=$(cat "$_p_tmp" 2>/dev/null || true)
|
||||
rm -f "$_p_tmp" 2>/dev/null || true
|
||||
|
||||
if printf '%s\n' "$p_out" | grep -Fq "agent_not_found"; then
|
||||
local _p_tmp2
|
||||
_p_tmp2=$(mktemp 2>/dev/null || echo "/tmp/sks_p2_${sess}_$$")
|
||||
if _sks_herdr agent prompt "$sess" "$text" --wait --until working --timeout 5000 >"$_p_tmp2" 2>&1; then
|
||||
rm -f "$_p_tmp2" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
p_rc=$?
|
||||
p_out=$(cat "$_p_tmp2" 2>/dev/null || true)
|
||||
rm -f "$_p_tmp2" 2>/dev/null || true
|
||||
fi
|
||||
if printf '%s\n' "$p_out" | grep -Fq "agent_blocked"; then
|
||||
echo "send_keys_safe: agent blocked ($sess)" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Only fall through to legacy paste-buffer if agent prompt command itself is not supported
|
||||
# or if target agent is not found (generic pane / terminal)
|
||||
if printf '%s\n' "$p_out" | grep -Eq "unknown command|unrecognized command|no such command|unexpected argument|unrecognized option|agent_not_found"; then
|
||||
: # Fall through to legacy paste buffer
|
||||
else
|
||||
# Prompt was dispatched via RPC; on any error (empty output, timeout, stalled, transport error, etc.),
|
||||
# do NOT resubmit via paste buffer (single injection contract). Check agent get after 2s.
|
||||
sleep 2
|
||||
local check_raw check_st
|
||||
check_raw="$(_sks_herdr agent get "$agent_target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
||||
check_st="$("$(_delegate_py_bin)" -c '
|
||||
import sys, json
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' <<< "$check_raw" 2>/dev/null || true)"
|
||||
if [ "$check_st" = "working" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo "send_keys_safe: delivery unknown/failed ($sess)" >&2
|
||||
return 4
|
||||
fi
|
||||
else
|
||||
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: paste buffer submission. Compute verification markers on demand.
|
||||
@@ -2328,8 +2499,12 @@ send_keys_safe() {
|
||||
sleep "$try"
|
||||
local cur_content
|
||||
cur_content=$(_pane_capture "$sess")
|
||||
# Hardened Submission Checks
|
||||
if printf '%s\n' "$cur_content" | grep -Fq "esc to interrupt" || printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
|
||||
# Hardened Submission Checks: exclude busy tokens if already present in pre_submit
|
||||
local pre_busy=0
|
||||
if printf '%s\n' "$pre_submit" | grep -Fq "esc to interrupt" || printf '%s\n' "$pre_submit" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
|
||||
pre_busy=1
|
||||
fi
|
||||
if [ "$pre_busy" -eq 0 ] && ( printf '%s\n' "$cur_content" | grep -Fq "esc to interrupt" || printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing" ); then
|
||||
return 0
|
||||
fi
|
||||
if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | tr -d '[:space:]' | grep -Fq "$marker_norm" && [ "$cur_content" != "$pre_submit" ]; then
|
||||
|
||||
Reference in new issue
Block a user