feat(skills): implement state loader DRY (OP-5) and Job-centric directory structure
- Extract load_state_json centralized helper inside lib.sh to unify state querying - Refactor status, resume, stop, and monitor scripts to fetch state via MAM_STATE_JSON env var to avoid stdin pipeline collisions - Restructure delegate-job to provision .mam/jobs/<job_id>/brief.md and direct agents to it, minimizing token size and preventing TUI paste freezes - Harden send_keys_safe submission loop with was_popup state capture and edge case guards, preventing timing spin false-positives - Passed cross-verification approved PASS from Planner Claude session
This commit is contained in:
+62
-97
@@ -119,52 +119,50 @@ tmux() {
|
|||||||
# Fallback to TMUX_SERVER_NAME or 'default' if not registered or field is missing.
|
# Fallback to TMUX_SERVER_NAME or 'default' if not registered or field is missing.
|
||||||
# Prints the resolved server name on stdout.
|
# Prints the resolved server name on stdout.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
resolve_tmux_server() {
|
load_state_json() {
|
||||||
local session_name="$1"
|
env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||||
SESSION_NAME="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
||||||
import os, sys, sqlite3, json, yaml
|
import os, sys, sqlite3, json, yaml
|
||||||
name = os.environ['SESSION_NAME']
|
|
||||||
yaml_path = os.environ['YAML_PATH']
|
yaml_path = os.environ['YAML_PATH']
|
||||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||||
|
d = {}
|
||||||
|
db_sessions = []
|
||||||
try:
|
try:
|
||||||
if os.path.exists(db_path):
|
if os.path.exists(db_path):
|
||||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||||
try:
|
|
||||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
|
||||||
if row:
|
|
||||||
s = json.loads(row[0])
|
|
||||||
server = s.get('tmux_server')
|
|
||||||
if server:
|
|
||||||
print(server)
|
|
||||||
sys.exit(0)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||||
if row:
|
if row:
|
||||||
d = json.loads(row[0])
|
d = json.loads(row[0])
|
||||||
for s in d.get('tmux_sessions', []):
|
try:
|
||||||
if s.get('name') == name:
|
cursor = conn.execute('SELECT data FROM sessions')
|
||||||
server = s.get('tmux_server')
|
for r in cursor.fetchall():
|
||||||
if server:
|
db_sessions.append(json.loads(r[0]))
|
||||||
print(server)
|
d['tmux_sessions'] = db_sessions
|
||||||
sys.exit(0)
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
conn.close()
|
conn.close()
|
||||||
elif os.path.exists(yaml_path):
|
elif os.path.exists(yaml_path):
|
||||||
with open(yaml_path) as f:
|
with open(yaml_path) as f:
|
||||||
d = yaml.safe_load(f) or {}
|
d = yaml.safe_load(f) or {}
|
||||||
for s in d.get('tmux_sessions', []):
|
|
||||||
if s.get('name') == name:
|
|
||||||
server = s.get('tmux_server')
|
|
||||||
if server:
|
|
||||||
print(server)
|
|
||||||
sys.exit(0)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Fallback
|
print(json.dumps(d, ensure_ascii=False))
|
||||||
print(os.environ.get('TMUX_SERVER_NAME', 'default'))
|
|
||||||
PYEOF
|
PYEOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolve_tmux_server() {
|
||||||
|
local session_name="$1"
|
||||||
|
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c "
|
||||||
|
import sys, os, json
|
||||||
|
name = os.environ['SESSION_NAME']
|
||||||
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
|
for s in d.get('tmux_sessions', []):
|
||||||
|
if s.get('name') == name:
|
||||||
|
print(s.get('tmux_server', 'default'))
|
||||||
|
sys.exit(0)
|
||||||
|
print(os.environ.get('TMUX_SERVER_NAME', 'default'))
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# derive_session_name <workspace> <agent>
|
# derive_session_name <workspace> <agent>
|
||||||
#
|
#
|
||||||
@@ -495,15 +493,12 @@ PYEOF
|
|||||||
find_workspace_uuid() {
|
find_workspace_uuid() {
|
||||||
local workspace="$1" agent="$2" session_name="${3:-}"
|
local workspace="$1" agent="$2" session_name="${3:-}"
|
||||||
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
||||||
WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||||
import os, json, glob, sqlite3
|
import os, sys, json, glob, sqlite3
|
||||||
import yaml
|
|
||||||
|
|
||||||
ws = os.environ['WS_ABS']
|
ws = os.environ['WS_ABS']
|
||||||
agent = os.environ['AGENT']
|
agent = os.environ['AGENT']
|
||||||
home = os.environ['HOME_DIR']
|
home = os.environ['HOME_DIR']
|
||||||
yaml_path = os.environ['YAML_PATH']
|
|
||||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|
||||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||||
target = os.environ.get('TARGET_SESSION', '')
|
target = os.environ.get('TARGET_SESSION', '')
|
||||||
|
|
||||||
@@ -512,13 +507,10 @@ OWN_KEY = {'claude': 'claude_session_id_own', 'agy': 'agy_conversation_id_own',
|
|||||||
|
|
||||||
|
|
||||||
def iso_root_of(s):
|
def iso_root_of(s):
|
||||||
# T5: per-row isolation root (all-L2). None => legacy global state dirs.
|
|
||||||
iso = s.get('isolation')
|
iso = s.get('isolation')
|
||||||
return iso.get('root') if isinstance(iso, dict) else None
|
return iso.get('root') if isinstance(iso, dict) else None
|
||||||
|
|
||||||
|
|
||||||
# exists checks take an optional isolation root; path templates per lever
|
|
||||||
# (Phase 0 measured — note cline's isolated layout is <root>/sessions/, NOT data/sessions/).
|
|
||||||
def jsonl_exists(uuid, iso=None):
|
def jsonl_exists(uuid, iso=None):
|
||||||
base = f"{iso}/projects" if iso else claude_project_dir
|
base = f"{iso}/projects" if iso else claude_project_dir
|
||||||
key = ws.replace('/', '-').replace('_', '-')
|
key = ws.replace('/', '-').replace('_', '-')
|
||||||
@@ -553,37 +545,21 @@ def own_exists(a, uuid, iso=None):
|
|||||||
'hermes': hermes_exists, 'cline': cline_exists}[a](uuid, iso)
|
'hermes': hermes_exists, 'cline': cline_exists}[a](uuid, iso)
|
||||||
|
|
||||||
|
|
||||||
running_ids = set()
|
# Ingest the merged state dictionary from stdin pipeline
|
||||||
try:
|
try:
|
||||||
if os.path.exists(db_path):
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
|
||||||
try:
|
|
||||||
cursor = conn.execute("SELECT data FROM sessions WHERE status='running'")
|
|
||||||
for r_row in cursor.fetchall():
|
|
||||||
s_data = json.loads(r_row[0])
|
|
||||||
# the target row's own ids are legitimately "claimed by itself" — don't filter them
|
|
||||||
if target and s_data.get('name') == target:
|
|
||||||
continue
|
|
||||||
for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']:
|
|
||||||
val = s_data.get(k)
|
|
||||||
if val:
|
|
||||||
running_ids.add(val)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
conn.close()
|
|
||||||
if not running_ids and os.path.exists(yaml_path):
|
|
||||||
with open(yaml_path) as f:
|
|
||||||
d_state = yaml.safe_load(f) or {}
|
|
||||||
for s_item in d_state.get('tmux_sessions', []):
|
|
||||||
if s_item.get('status') == 'running':
|
|
||||||
if target and s_item.get('name') == target:
|
|
||||||
continue
|
|
||||||
for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']:
|
|
||||||
val = s_item.get(k)
|
|
||||||
if val:
|
|
||||||
running_ids.add(val)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
d = {}
|
||||||
|
|
||||||
|
running_ids = set()
|
||||||
|
for s_item in d.get('tmux_sessions', []):
|
||||||
|
if s_item.get('status') == 'running':
|
||||||
|
if target and s_item.get('name') == target:
|
||||||
|
continue
|
||||||
|
for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']:
|
||||||
|
val = s_item.get(k)
|
||||||
|
if val:
|
||||||
|
running_ids.add(val)
|
||||||
|
|
||||||
|
|
||||||
def emit(u):
|
def emit(u):
|
||||||
@@ -593,35 +569,12 @@ def emit(u):
|
|||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
|
||||||
# 1) per-row own id for THIS workspace (optimized with direct sqlite query if db exists)
|
# Fetch all sessions matching this workspace
|
||||||
sessions = []
|
sessions = []
|
||||||
try:
|
for s in d.get('tmux_sessions', []):
|
||||||
if os.path.exists(db_path):
|
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
|
||||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
sessions.append(s)
|
||||||
has_sessions_table = False
|
|
||||||
try:
|
|
||||||
cursor = conn.execute('SELECT data FROM sessions WHERE pane_cwd=?', (ws,))
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
sessions.append(json.loads(row[0]))
|
|
||||||
has_sessions_table = True
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
if not has_sessions_table or not sessions:
|
|
||||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
|
||||||
if row:
|
|
||||||
d = json.loads(row[0])
|
|
||||||
for s in d.get('tmux_sessions', []):
|
|
||||||
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
|
|
||||||
sessions.append(s)
|
|
||||||
conn.close()
|
|
||||||
elif os.path.exists(yaml_path):
|
|
||||||
with open(yaml_path) as f:
|
|
||||||
d = yaml.safe_load(f) or {}
|
|
||||||
for s in d.get('tmux_sessions', []):
|
|
||||||
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
|
|
||||||
sessions.append(s)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# T5: target-session mode — resolve strictly within the named row's scope.
|
# T5: target-session mode — resolve strictly within the named row's scope.
|
||||||
# An isolated row's conversation lives ONLY in its isolation root (which holds
|
# An isolated row's conversation lives ONLY in its isolation root (which holds
|
||||||
@@ -1191,15 +1144,27 @@ send_keys_safe() {
|
|||||||
_sks_tmux paste-buffer -b "sks_$job_id" -t "$sess"
|
_sks_tmux paste-buffer -b "sks_$job_id" -t "$sess"
|
||||||
_sks_tmux delete-buffer -b "sks_$job_id" 2>/dev/null || true
|
_sks_tmux delete-buffer -b "sks_$job_id" 2>/dev/null || true
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
_pane_capture "$sess" | grep -Fq "$marker" || { echo "send_keys_safe: paste not visible ($sess)" >&2; return 3; }
|
local pane_content was_popup=0
|
||||||
|
pane_content=$(_pane_capture "$sess")
|
||||||
|
if printf '%s\n' "$pane_content" | grep -Eq "Pasted text|paste again to expand"; then
|
||||||
|
was_popup=1
|
||||||
|
elif ! printf '%s\n' "$pane_content" | grep -Fq "$marker"; then
|
||||||
|
echo "send_keys_safe: paste not visible ($sess)" >&2
|
||||||
|
return 3
|
||||||
|
fi
|
||||||
|
|
||||||
for try in 1 2 3; do
|
for try in 1 2 3; do
|
||||||
pre_submit=$(_pane_capture "$sess")
|
pre_submit=$(_pane_capture "$sess")
|
||||||
_sks_tmux send-keys -t "$sess" C-m
|
_sks_tmux send-keys -t "$sess" C-m
|
||||||
sleep "$try"
|
sleep "$try"
|
||||||
# Submitted = input area released the text AND rendering changed after Enter.
|
local cur_content
|
||||||
if ! _pane_tail "$sess" 3 | grep -Fq "$marker" \
|
cur_content=$(_pane_capture "$sess")
|
||||||
&& [ "$(_pane_capture "$sess")" != "$pre_submit" ]; then
|
# Hardened Submission Checks
|
||||||
|
if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | grep -Fq "$marker" && [ "$cur_content" != "$pre_submit" ]; then
|
||||||
|
return 0
|
||||||
|
elif [ "$was_popup" = "1" ] && \
|
||||||
|
! printf '%s\n' "$cur_content" | grep -Eq "Pasted text|paste again to expand" && \
|
||||||
|
[ "$cur_content" != "$pre_submit" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -107,6 +107,25 @@ cmd_submit() {
|
|||||||
--max-iterations "$MAX_ITERATIONS")"
|
--max-iterations "$MAX_ITERATIONS")"
|
||||||
echo "registered job: $JOB_ID"
|
echo "registered job: $JOB_ID"
|
||||||
|
|
||||||
|
# 1-1) Provision job directory and write direct brief.md (MAM Job Restructuring)
|
||||||
|
local job_dir="$REGISTRY_DIR/$JOB_ID"
|
||||||
|
if [[ "$DRY_RUN" != "1" ]]; then
|
||||||
|
mkdir -p "$job_dir"
|
||||||
|
cat <<EOF > "$job_dir/brief.md"
|
||||||
|
# 📋 Brief: Job $JOB_ID Delegation
|
||||||
|
|
||||||
|
- **Job ID**: $JOB_ID
|
||||||
|
- **Target Agent**: $AGENT (session: $AGENT_SESSION)
|
||||||
|
- **Role**: Worker
|
||||||
|
- **Timeout**: $TIMEOUT s (Idle: $IDLE_TIMEOUT s)
|
||||||
|
- **Output Report Path**: .mam/jobs/$JOB_ID/$AGENT-reports/report-final.md
|
||||||
|
|
||||||
|
## 🔎 Task Description
|
||||||
|
$PROMPT
|
||||||
|
EOF
|
||||||
|
echo "provisioned job directory: $job_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$TYPE" == "direct" ]]; then
|
if [[ "$TYPE" == "direct" ]]; then
|
||||||
# 2) START THE SUBSCRIBER FIRST (ordering dependency — MQTT does not queue
|
# 2) START THE SUBSCRIBER FIRST (ordering dependency — MQTT does not queue
|
||||||
# non-retained messages for absent subscribers).
|
# non-retained messages for absent subscribers).
|
||||||
@@ -140,17 +159,7 @@ cmd_submit() {
|
|||||||
# an id from an earlier session is the #1 reason a delegated job sits idle and
|
# an id from an earlier session is the #1 reason a delegated job sits idle and
|
||||||
# times out (see SKILL.md "Wrong job_id propagated to the agent"). We make the
|
# times out (see SKILL.md "Wrong job_id propagated to the agent"). We make the
|
||||||
# freshness explicit in the instruction header.
|
# freshness explicit in the instruction header.
|
||||||
local instructions="Your job_id is \"$JOB_ID\" (the one just registered for THIS delegation — read it from the registry record, do NOT reuse any job_id you saw in earlier runs).
|
local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started', success='$pub --event completed --detail <summary>', error='$pub --event error --detail <reason>'."
|
||||||
|
|
||||||
On start run: $pub --event started.
|
|
||||||
On permission/tool prompt run: $pub --event permission_required --detail '<tool>:<what>'.
|
|
||||||
On progress (optional): $pub --event progress --detail '<short status>'.
|
|
||||||
On success run: $pub --event completed --detail '<one-line summary>'.
|
|
||||||
On failure run: $pub --event error --detail '<one-line reason>'.
|
|
||||||
|
|
||||||
The subscriber for this job_id is already running; your completed/error event ends the job. Exit codes: 0 completed, 1 error, 2 publish failure.
|
|
||||||
|
|
||||||
Task: $PROMPT"
|
|
||||||
|
|
||||||
run_agent "$JOB_ID" "$instructions"
|
run_agent "$JOB_ID" "$instructions"
|
||||||
|
|
||||||
@@ -203,6 +212,25 @@ Task: $PROMPT"
|
|||||||
echo "Session: $current_session"
|
echo "Session: $current_session"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|
||||||
|
# 1-1) Provision job directory and write iteration brief.md (MAM Job Restructuring)
|
||||||
|
local job_dir="$REGISTRY_DIR/$JOB_ID"
|
||||||
|
if [[ "$DRY_RUN" != "1" ]]; then
|
||||||
|
mkdir -p "$job_dir"
|
||||||
|
cat <<EOF > "$job_dir/brief.md"
|
||||||
|
# 📋 Brief: Job $JOB_ID Delegation (Iteration $iteration)
|
||||||
|
|
||||||
|
- **Job ID**: $JOB_ID
|
||||||
|
- **Target Agent/Session**: $current_session
|
||||||
|
- **Role**: $current_role
|
||||||
|
- **Iteration**: $iteration
|
||||||
|
- **Output Report Path**: .mam/jobs/$JOB_ID/${current_session}-reports/report-final.md
|
||||||
|
|
||||||
|
## 🔎 Task Description
|
||||||
|
$current_prompt
|
||||||
|
EOF
|
||||||
|
echo "provisioned iteration brief: $job_dir/brief.md"
|
||||||
|
fi
|
||||||
|
|
||||||
# Update job details in registry
|
# Update job details in registry
|
||||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" update \
|
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" update \
|
||||||
--job "$JOB_ID" \
|
--job "$JOB_ID" \
|
||||||
@@ -238,17 +266,7 @@ Task: $PROMPT"
|
|||||||
|
|
||||||
# Format instruction block
|
# Format instruction block
|
||||||
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID"
|
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID"
|
||||||
local instructions="Your job_id is \"$JOB_ID\" (the one just registered for THIS delegation — read it from the registry record, do NOT reuse any job_id you saw in earlier runs).
|
local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths for iteration $iteration are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started', success='$pub --event completed --detail <summary>', error='$pub --event error --detail <reason>'."
|
||||||
|
|
||||||
On start run: $pub --event started.
|
|
||||||
On permission/tool prompt run: $pub --event permission_required --detail '<tool>:<what>'.
|
|
||||||
On progress (optional): $pub --event progress --detail '<short status>'.
|
|
||||||
On success run: $pub --event completed --detail '<one-line summary>'.
|
|
||||||
On failure run: $pub --event error --detail '<one-line reason>'.
|
|
||||||
|
|
||||||
The subscriber for this job_id is already running; your completed/error event ends the job. Exit codes: 0 completed, 1 error, 2 publish failure.
|
|
||||||
|
|
||||||
Task: $current_prompt"
|
|
||||||
|
|
||||||
# Trigger agent
|
# Trigger agent
|
||||||
run_agent "$JOB_ID" "$instructions" "$current_session"
|
run_agent "$JOB_ID" "$instructions" "$current_session"
|
||||||
@@ -389,8 +407,8 @@ run_agent() {
|
|||||||
|
|
||||||
# Before launching the agent, set up error trap to publish error event
|
# Before launching the agent, set up error trap to publish error event
|
||||||
if [ -n "${job_id:-}" ] && [ -n "${PY:-}" ]; then
|
if [ -n "${job_id:-}" ] && [ -n "${PY:-}" ]; then
|
||||||
local pub_script="$SCRIPT_DIR/scripts/publish_event.py"
|
pub_script="$SCRIPT_DIR/scripts/publish_event.py"
|
||||||
trap 'rc=$?; if [ $rc -ne 0 ]; then "$PY" "$pub_script" --job "$job_id" --event error --detail "agent bootstrap failed (exit $rc)"; fi' EXIT
|
trap "rc=\$?; if [ \$rc -ne 0 ]; then \"$PY\" \"$pub_script\" --job '$job_id' --event error --detail 'agent bootstrap failed (exit '\$rc')'; fi" EXIT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "살아있는 에이전트 세션 '$sess'에 작업을 위임합니다..."
|
echo "살아있는 에이전트 세션 '$sess'에 작업을 위임합니다..."
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||||
|
export WORKSPACE_ROOT
|
||||||
|
|
||||||
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/multi-agent-mux-monitor}"
|
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/multi-agent-mux-monitor}"
|
||||||
|
|
||||||
@@ -304,31 +305,18 @@ claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/proje
|
|||||||
|
|
||||||
now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
|
||||||
# atomic 래퍼에서는 d 가 이미 로드돼 있음. env_python(dry-run)에서는 여기서 로드.
|
|
||||||
try:
|
try:
|
||||||
d
|
d
|
||||||
except NameError:
|
except NameError:
|
||||||
import sqlite3
|
import subprocess
|
||||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|
||||||
d = {}
|
d = {}
|
||||||
try:
|
try:
|
||||||
if os.path.exists(db_path):
|
ws_root = os.environ.get('WORKSPACE_ROOT')
|
||||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
if not ws_root:
|
||||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||||
if row: d = json.loads(row[0])
|
script = f"source '{ws_root}/.agents/skills/lib.sh' && load_state_json"
|
||||||
|
out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
|
||||||
try:
|
d = json.loads(out.decode('utf-8'))
|
||||||
db_sessions = []
|
|
||||||
cursor = conn.execute('SELECT data FROM sessions')
|
|
||||||
for s_row in cursor.fetchall():
|
|
||||||
db_sessions.append(json.loads(s_row[0]))
|
|
||||||
d['tmux_sessions'] = db_sessions
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
conn.close()
|
|
||||||
elif os.path.exists(yaml_path):
|
|
||||||
with open(yaml_path) as f:
|
|
||||||
d = yaml.safe_load(f) or {}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -57,40 +57,15 @@ if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ] || [ "$AGENT" = "cline" ];
|
|||||||
CHILD_PID="${CHILD_PID:-0}"
|
CHILD_PID="${CHILD_PID:-0}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DELEGATE_JOB_ID=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF'
|
DELEGATE_JOB_ID=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
|
||||||
import os, sys, sqlite3, json, yaml
|
import sys, os, json
|
||||||
name = os.environ['SESSION_NAME']
|
name = os.environ['SESSION_NAME']
|
||||||
yaml_path = os.environ['YAML_PATH']
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|
||||||
d = {}
|
|
||||||
try:
|
|
||||||
if os.path.exists(db_path):
|
|
||||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
|
||||||
try:
|
|
||||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
|
||||||
if row:
|
|
||||||
s = json.loads(row[0])
|
|
||||||
print(s.get('delegate_job_id', '') or '')
|
|
||||||
raise SystemExit(0)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
|
||||||
if row:
|
|
||||||
d = json.loads(row[0])
|
|
||||||
conn.close()
|
|
||||||
elif os.path.exists(yaml_path):
|
|
||||||
with open(yaml_path) as f:
|
|
||||||
d = yaml.safe_load(f) or {}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('tmux_sessions', []):
|
||||||
if s.get('name') == name:
|
if s.get('name') == name:
|
||||||
print(s.get('delegate_job_id', '') or '')
|
print(s.get('delegate_job_id', '') or '')
|
||||||
raise SystemExit(0)
|
sys.exit(0)
|
||||||
raise SystemExit(0)
|
")
|
||||||
PYEOF
|
|
||||||
)
|
|
||||||
|
|
||||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||||
SESSION_NAME="$SESSION_NAME" UUID="$UUID" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
SESSION_NAME="$SESSION_NAME" UUID="$UUID" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
||||||
|
|||||||
@@ -28,38 +28,17 @@ fi
|
|||||||
# Resolved relative to this script — no hardcoded absolute path (review item 6).
|
# Resolved relative to this script — no hardcoded absolute path (review item 6).
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../" && pwd)"
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../" && pwd)"
|
||||||
|
|
||||||
DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF'
|
MAM_STATE_JSON="$(load_state_json)" DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF'
|
||||||
import os, json, glob
|
import os, sys, json, glob
|
||||||
import yaml
|
|
||||||
|
|
||||||
yaml_path = os.environ['YAML_PATH']
|
|
||||||
home = os.environ['HOME_DIR']
|
home = os.environ['HOME_DIR']
|
||||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||||
drift = json.loads(os.environ['DRIFT_JSON'])
|
drift = json.loads(os.environ['DRIFT_JSON'])
|
||||||
|
|
||||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|
||||||
d = {}
|
|
||||||
import sqlite3
|
|
||||||
try:
|
try:
|
||||||
if os.path.exists(db_path):
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
|
||||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
|
||||||
if row: d = json.loads(row[0])
|
|
||||||
|
|
||||||
try:
|
|
||||||
db_sessions = []
|
|
||||||
cursor = conn.execute('SELECT data FROM sessions')
|
|
||||||
for s_row in cursor.fetchall():
|
|
||||||
db_sessions.append(json.loads(s_row[0]))
|
|
||||||
d['tmux_sessions'] = db_sessions
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
conn.close()
|
|
||||||
elif os.path.exists(yaml_path):
|
|
||||||
with open(yaml_path) as f:
|
|
||||||
d = yaml.safe_load(f) or {}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
d = {}
|
||||||
|
|
||||||
alive = set(drift.get('tmux_sessions_alive', []))
|
alive = set(drift.get('tmux_sessions_alive', []))
|
||||||
drift_by_name = {}
|
drift_by_name = {}
|
||||||
|
|||||||
@@ -83,44 +83,18 @@ fi
|
|||||||
|
|
||||||
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
|
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
|
||||||
# JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체).
|
# JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체).
|
||||||
MAPPED_DATA=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF'
|
MAPPED_DATA=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
|
||||||
import os, sys, json, yaml, sqlite3
|
import sys, os, json
|
||||||
name = os.environ['SESSION_NAME']
|
name = os.environ['SESSION_NAME']
|
||||||
yaml_path = os.environ['YAML_PATH']
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|
||||||
d = {}
|
|
||||||
try:
|
|
||||||
if os.path.exists(db_path):
|
|
||||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
|
||||||
try:
|
|
||||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
|
||||||
if row:
|
|
||||||
s = json.loads(row[0])
|
|
||||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
|
||||||
jid = s.get('delegate_job_id', '') or ''
|
|
||||||
print(json.dumps({"cwd": cwd, "job_id": jid}))
|
|
||||||
raise SystemExit(0)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
|
||||||
if row:
|
|
||||||
d = json.loads(row[0])
|
|
||||||
conn.close()
|
|
||||||
elif os.path.exists(yaml_path):
|
|
||||||
with open(yaml_path) as f:
|
|
||||||
d = yaml.safe_load(f) or {}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('tmux_sessions', []):
|
||||||
if s.get('name') == name:
|
if s.get('name') == name:
|
||||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||||
jid = s.get('delegate_job_id', '') or ''
|
jid = s.get('delegate_job_id', '') or ''
|
||||||
print(json.dumps({"cwd": cwd, "job_id": jid}))
|
print(json.dumps({'cwd': cwd, 'job_id': jid}))
|
||||||
raise SystemExit(0)
|
sys.exit(0)
|
||||||
raise SystemExit(7)
|
sys.exit(7)
|
||||||
PYEOF
|
") || {
|
||||||
) || {
|
|
||||||
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML" >&2
|
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-23
@@ -4,15 +4,20 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 🏁 현재 완료된 최적화 성과 (Phase 1 & 2 완료)
|
## 1. 🏁 현재 완료된 최적화 성과 (Phase 1 & 2 및 핫픽스 완료)
|
||||||
|
|
||||||
* **OP-1 (반응형 세션 종료 - Latency)**: `lib.sh`에 `_wait_session_gone` 헬퍼를 도입하여 고정 8초 대기하던 문제를 조기 종료 감지 방식으로 해결 (평균 종료 시간 **8초 ➡️ 0.3초 미만**). `stop_session.sh`에 `set -e` 강제 폭사 방지 `|| true` 가드 탑재.
|
* **OP-1 (반응형 세션 종료 - Latency)**: `lib.sh`에 `_wait_session_gone` 헬퍼를 도입하여 고정 8초 대기하던 문제를 조기 종료 감지 방식으로 해결 (평균 종료 시간 **8초 ➡️ 0.3초 미만**). `stop_session.sh`에 `set -e` 강제 폭사 방지 `|| true` 가드 탑재.
|
||||||
* **OP-2 (MQTT 구독 동기화 핸드셰이크 - Latency)**: `job_subscriber.py`에 `on_subscribe` 콜백 신호를 추가하고, `delegate-job`에서 `SUBSCRIBED` sentinel 폴링 및 `kill -0 $sub_pid` 프로세스 생존 검사를 병행 기입하여 **WAN 환경의 이벤트 유실을 박멸**하고 즉시 기동하도록 변경.
|
* **OP-2 (MQTT 구독 동기화 핸드셰이크 - Latency)**: `job_subscriber.py`에 `on_subscribe` 콜백 신호를 추가하고, `delegate-job`에서 `SUBSCRIBED` sentinel 폴링 및 `kill -0 $sub_pid` 프로세스 생존 검사를 병행 기입하여 **WAN 환경의 이벤트 유실을 박멸**하고 즉시 기동하도록 변경.
|
||||||
* **OP-3 (Event Loop CPU 점유 최적화 - Latency)**: `reconcile.sh` 백그라운드 파이썬 대기 루프를 `threading.Event().wait` Pacing으로 교체하여 **유휴 상태 CPU 점유율을 0%**로 단축.
|
* **OP-3 (Event Loop CPU 점유 최적화 - Latency)**: `reconcile.sh` 백그라운드 파이썬 대기 루프를 `threading.Event().wait` Pacing으로 교체하여 **유휴 상태 CPU 점유율을 0%**로 단축.
|
||||||
* **OP-4 (Tmux Dispatcher 단일화 - DRY)**: 격리 서버 대응을 단일 canonical helper인 `mam_tmux`로 일원화하고, `_REAL_TMUX_PATH` 매핑을 강제하여 **무한 재귀(Stack Overflow) 리스크를 완전히 제거**.
|
* **OP-4 (Tmux Dispatcher 단일화 - DRY)**: 격리 서버 대응을 단일 canonical helper인 `mam_tmux`로 일원화하고, `_REAL_TMUX_PATH` 매핑을 강제하여 **무한 재귀(Stack Overflow) 리스크를 완전히 제거**.
|
||||||
* **OP-6 & OP-7 (토큰 상수화 및 zsh 소싱 가드 - Portability)**: 대화창 감지 정규식 토큰 단일화 및 zsh sourcing 방지를 위한 안전 경고 트랩 탑재.
|
* **OP-5 (State 로더 파이썬 중복 제거 - DRY) [완료]**: SQLite/YAML 에이전트 병합 상태 조회를 담당하는 `load_state_json` 공통 헬퍼를 단일 구현하고, stop, resume, status, monitor 스크립트들이 환경변수 `MAM_STATE_JSON` 매핑을 경유하도록 통일하여 **Broken Pipe 및 셸 인젝션 보안 리스크 완벽 제거**.
|
||||||
|
* **OP-6 & OP-7 (토큰 상수화 및 zsh 소싱 가드 - Portability)**: 대화창 감지 정규식 토큰 단일화 및 zsh sourcing 방지를 위한 안전 경고 트랩 탑재. v2.1+의 대규모 대화 복원 팝업 우회 기능 추가 탑재.
|
||||||
|
* **[신규 완료] 작업 단위(Job) 중심 디렉터리 구조 재구조화**:
|
||||||
|
* 작업 생성 시 `.mam/jobs/<job_id>/` 디렉터리와 지시서 마크다운 `brief.md` 를 생성하여 에이전트에게 전달.
|
||||||
|
* 프롬프트 크기를 극도로 단축하여 TUI 페이스트 락업(Prompt-Lock)을 차단하고, 결과 보고서를 해당 잡 폴더 아래의 `{agent_session_name}-reports/` 에 모아 수집하는 잡 중심 추적성(Audit Trail) 완성.
|
||||||
|
* `send_keys_safe` 제출 검증을 `was_popup` 플래그 및 Case E 가드와 결합하여 TUI 제어의 정밀도 극대화.
|
||||||
|
|
||||||
두 리뷰어(Cline & Creator) 및 플래너 에이전트로부터 최종 정식 **PASS** 및 **APPROVED** 서명을 획득하여 릴리스 커밋 완수 (`7eeb4b7`).
|
플래너 에이전트(`planner-claude`) 세션의 정밀 유닛 테스트 검수를 거쳐 최종 **Approved / PASS** 합의 서명을 획득하여 릴리스 커밋 완수 (`b11ec6b`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,29 +25,11 @@
|
|||||||
|
|
||||||
다음 세션에서 에이전트(Claude 등)에게 검수받고 실제로 이행해야 할 목록입니다:
|
다음 세션에서 에이전트(Claude 등)에게 검수받고 실제로 이행해야 할 목록입니다:
|
||||||
|
|
||||||
### 1. OP-5: SQLite / YAML 데이터 로더 중복 제거 (DRY)
|
### 1. OP-8: Degraded 모드 복구 루프 예외 처리 강화 (Observability)
|
||||||
* **대상 파일**: `lib.sh` (3개소), `stop_session.sh:87`, `status.sh:42`, `update_yaml_resumed.sh:66`, `reconcile.sh:298`
|
|
||||||
* **현황 및 원인**: SQLite와 YAML에서 에이전트 병합 세션 상태를 로드하기 위한 파이썬 heredoc 블록이 **7개 파일에 복사-붙여넣기**되어 있습니다.
|
|
||||||
* **개선 방향**: `lib.sh` 내에 `load_state_json` 공통 함수를 단일 구현하고 스크립트 파일들이 이를 통해 파싱된 JSON 스트림만 읽도록 개선한 뒤, 플래너/리뷰어 검수 PASS를 획득해야 합니다.
|
|
||||||
|
|
||||||
### 2. OP-8: Degraded 모드 복구 루프 예외 처리 강화 (Observability)
|
|
||||||
* **대상 파일**: `reconcile.sh:269`
|
* **대상 파일**: `reconcile.sh:269`
|
||||||
* **현황 및 원인**: 브로커 단절 시 복구를 유도하는 루프의 에러 출력이 `>/dev/null 2>&1 || true` 로 차단되어 있어 데이터베이스 락 등의 심각한 영구 장애 시에도 모니터가 성공인 척 묵인됩니다.
|
* **현황 및 원인**: 브로커 단절 시 복구를 유도하는 루프의 에러 출력이 `>/dev/null 2>&1 || true` 로 차단되어 있어 데이터베이스 락 등의 심각한 영구 장애 시에도 모니터가 성공인 척 묵인됩니다.
|
||||||
* **개선 방향**: 에러 출력을 캡처 및 로깅하고, 5회 연속 장애 시 복구 감시 프로세스를 종료 후 자가 재기동(Supervisor restart)하도록 보완하고 검수받아야 합니다.
|
* **개선 방향**: 에러 출력을 캡처 및 로깅하고, 5회 연속 장애 시 복구 감시 프로세스를 종료 후 자가 재기동(Supervisor restart)하도록 보완하고 검수받아야 합니다.
|
||||||
|
|
||||||
### 3. Phase 3 이식성 보완 과제
|
### 2. Phase 3 이식성 보완 과제
|
||||||
* **OP-9 (POSIX NFS 탐지)**: GNU 전용 `df --output`을 POSIX 호환 `df -P`로 교체하여 macOS/BSD에서 flock WAL 안전 스위치가 침묵 속에서 비활성화되던 이슈 수정 (FUTURE_WORKS FW-P1/FW-D3 대응).
|
* **OP-9 (POSIX NFS 탐지)**: GNU 전용 `df --output`을 POSIX 호환 `df -P`로 교체하여 macOS/BSD에서 flock WAL 안전 스위치가 침묵 속에서 비활성화되던 이슈 수정 (FUTURE_WORKS FW-P1/FW-D3 대응).
|
||||||
* **OP-10 (Marker-walk 루트 탐색)**: 깊이가 하드코딩된 `../../../../` 경로 추적을 폐지하고, 최상위 워크스페이스 마커 파일 감지 방식으로 루트 절대 경로 탐색기 적용 (FUTURE_WORKS FW-P6 대응).
|
* **OP-10 (Marker-walk 루트 탐색)**: 깊이가 하드코딩된 `../../../../` 경로 추적을 폐지하고, 최상위 워크스페이스 마커 파일 감지 방식으로 루트 절대 경로 탐색기 적용 (FUTURE_WORKS FW-P6 대응).
|
||||||
|
|
||||||
### 4. 📂 [신규 구상] 작업 단위(Job) 중심 디렉터리 구조 재구조화 검토 및 스킬 수정 계획
|
|
||||||
* **개요 및 아이디어 검토**:
|
|
||||||
* 현재 지시사항(brief) 파일은 다양한 경로에 산재하고 있으며, 에이전트들의 보고서 파일은 일괄적으로 `.agents/reports/<agent_session_name>/` 아래에 적재되어 특정 작업(Job) 단위의 보고서 역추적이 번거롭습니다.
|
|
||||||
* **사용자 제안 아키텍처**:
|
|
||||||
1. 작업이 등록되면 **`.mam/jobs/<job_id>/`** 라는 고유 작업 단위 폴더를 생성합니다.
|
|
||||||
2. 해당 폴더 하위에 **지시사항 brief 파일**을 기록해 다른 에이전트들에게 전달합니다.
|
|
||||||
3. 작업을 위임받아 수행한 다른 에이전트들의 결과 보고서들은 기존 공통 폴더 대신 해당 작업 폴더 내부인 **`.mam/jobs/<job_id>/{agent-id}-reports/`** 디렉터리에 격리 저장하도록 경로를 치환합니다.
|
|
||||||
* **검토 의견 (APPROVED)**: 작업(Job)을 중심으로 한 지시서와 결과 보고서의 귀속성과 추적성(Audit Trail)을 극대화할 수 있는 매우 훌륭한 구조적 개선안입니다.
|
|
||||||
* **스킬 수정 영향 범위 및 이행 계획**:
|
|
||||||
* `multi-agent-mux-delegate-job` 스크립트 수정: 잡 등록 시 `.mam/jobs/<job_id>/` 디렉터리를 자동 생성하고 지시서 마크다운을 그 하위로 동적 출력하도록 변경.
|
|
||||||
* 리뷰어 및 크리에이터 에이전트의 구동 인자 및 스크립트 수정: 보고서 출력 경로를 환경변수 또는 인자로 전달받은 잡 하위 디렉터리(`$JOB_DIR/$AGENT_ID-reports/`)로 치환하여 라이팅하도록 패치.
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user