feat(lib): SQLite DB normalization (FW-L3) & stop semantics simplification (FW-L2)

This commit is contained in:
2026-06-21 09:05:15 +00:00
parent 478be56679
commit 8097df0cbe
11 changed files with 324 additions and 200 deletions
@@ -33,54 +33,41 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
usage() {
cat <<EOF
Usage: $0 --session <name> [--agent claude|agy] [--mode soft|hard] [--purge-conversation] [--yes]
[--capture-id] [--reason <reason>] [--graceful]
Usage: $0 --session <name> [--agent claude|agy] [--purge-conversation] [--yes] [--reason <reason>]
Modes:
soft — update YAML to status=archived, leave tmux running
hard (default) — tmux kill-session + update YAML to status=terminated
Stop extension (any of these → STOP mode, status=stopped instead of terminated):
--capture-id — record this workspace's conversation id to the row before kill
Stop arguments:
--reason <reason> — stop_reason field (default: manual_stop)
--graceful — send-keys exit → 3s → kill-session → 5s → SIGKILL fallback
(idempotent: stopping an already-stopped session is a no-op with exit 0)
EOF
}
SESSION_NAME=""
AGENT=""
MODE="hard" # "stop" 의 자연스러운 의미 = tmux 까지 종료
PURGE=0
YES=0
CAPTURE_ID=0
GRACEFUL=0
REASON=""
STOP_MODE=0
CAPTURE_ID=1
GRACEFUL=1
REASON="manual_stop"
STOP_MODE=1
while [ $# -gt 0 ]; do
case "$1" in
--session) SESSION_NAME="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;;
--mode) MODE="$2"; shift 2 ;;
--purge-conversation) PURGE=1; shift ;;
--yes) YES=1; shift ;;
--capture-id) CAPTURE_ID=1; STOP_MODE=1; shift ;;
--reason) REASON="$2"; STOP_MODE=1; shift 2 ;;
--graceful) GRACEFUL=1; STOP_MODE=1; shift ;;
--reason) REASON="$2"; shift 2 ;;
--mode|--capture-id|--graceful)
echo "ERROR: $1 option is deprecated. Stop now always stops gracefully and captures IDs." >&2
exit 2
;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; }
[ "$MODE" = "soft" ] || [ "$MODE" = "hard" ] || { echo "ERROR: --mode must be soft or hard" >&2; exit 2; }
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
# STOP 모드 기본 사유
if [ "$STOP_MODE" = "1" ] && [ -z "$REASON" ]; then
REASON="manual_stop"
fi
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
# --agent 미지정 시 이름 suffix 로 fallback (P1-F)
@@ -95,10 +82,34 @@ fi
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
# JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체).
MAPPED_DATA=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF'
import os, json, yaml
import os, sys, json, yaml, sqlite3
name = os.environ['SESSION_NAME']
with open(os.environ['YAML_PATH']) as f:
d = yaml.safe_load(f) or {}
yaml_path = os.environ['YAML_PATH']
db_path = os.path.splitext(yaml_path)[0] + '.db'
d = {}
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=10.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', []):
if s.get('name') == name:
cwd = (s.get('pane') or {}).get('cwd', '')
@@ -194,31 +205,27 @@ graceful_stop() {
# tmux 종료: graceful 이면 폴백 체인, 아니면 기존 hard kill.
if [ "$GRACEFUL" = "1" ] && [ "$TMUX_ALIVE" = "1" ]; then
graceful_stop
elif [ "$MODE" = "hard" ] && [ "$TMUX_ALIVE" = "1" ]; then
elif [ "$TMUX_ALIVE" = "1" ]; then
tmux kill-session -t "$SESSION_NAME"
echo "killed tmux: $SESSION_NAME"
elif [ "$MODE" = "hard" ]; then
else
echo "tmux already dead, just updating YAML"
fi
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" MODE="$MODE" PURGE="$PURGE" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" PURGE="$PURGE" \
NOW_ISO="$NOW_ISO" NOW_EPOCH="$NOW_EPOCH" LAST_STATUS="$LAST_STATUS" \
PURGE_UUID="$PURGE_UUID" TARGET_CWD="$TARGET_CWD" \
STOP_MODE="$STOP_MODE" REASON="$REASON" GRACEFUL="$GRACEFUL" \
CAPTURED_UUID="$CAPTURED_UUID" <<'PYEOF'
REASON="$REASON" CAPTURED_UUID="$CAPTURED_UUID" <<'PYEOF'
import shutil
name = os.environ['SESSION_NAME']
agent = os.environ['AGENT']
mode = os.environ['MODE']
purge = os.environ['PURGE'] == '1'
now = os.environ['NOW_ISO']
home = os.environ['HOME_DIR']
last_status = os.environ.get('LAST_STATUS', '')
purge_uuid = os.environ.get('PURGE_UUID', '').strip()
ws = os.environ.get('TARGET_CWD', '')
stop_mode = os.environ.get('STOP_MODE') == '1'
graceful = os.environ.get('GRACEFUL') == '1'
reason = os.environ.get('REASON', '') or 'manual_stop'
captured = os.environ.get('CAPTURED_UUID', '').strip()
@@ -231,29 +238,22 @@ if target is None:
print(f"ERROR: disappeared during script: {name}", flush=True)
raise SystemExit(1)
if mode == 'soft':
# P1-A: soft 는 tmux 가 살아있으니 archived. terminated 아님.
target['status'] = 'archived'
target['archived_at'] = now
target['termination_mode'] = 'soft'
elif stop_mode:
# STOP 모드: running -> stopped (terminated 와 의도 구분). conversation 보존.
if purge:
target['status'] = 'terminated'
target['terminated_at'] = now
target['terminated_at_epoch'] = int(os.environ['NOW_EPOCH'])
target['termination_mode'] = 'purge'
else:
target['status'] = 'stopped'
target['stopped_at'] = now
target['stopped_at_epoch'] = int(os.environ['NOW_EPOCH'])
target['stop_reason'] = reason
target['termination_mode'] = 'graceful' if graceful else 'stop'
else:
target['status'] = 'terminated'
target['terminated_at'] = now
target['terminated_at_epoch'] = int(os.environ['NOW_EPOCH'])
target['termination_mode'] = 'hard'
target['termination_mode'] = 'graceful'
if last_status:
target['last_visible_status_at_termination'] = last_status
# --capture-id: 해결된 conversation id 를 per-row own id 에 확정 기록 (tier-1 보장).
# purge 와 함께면 어차피 아래에서 지워지므로 기록하지 않는다.
# --capture-id: 항상 captured UUID 기록 (purge가 아닐 때만)
if captured and not purge:
if agent == 'claude':
target['claude_session_id_own'] = captured
@@ -305,16 +305,11 @@ PYEOF
delegate_publish_event "$DELEGATE_JOB_ID" completed "session terminated"
echo
if [ "$STOP_MODE" = "1" ]; then
echo "=== stop complete ==="
else
echo "=== stop complete ==="
fi
echo "=== stop complete ==="
echo " session: $SESSION_NAME"
echo " agent: $AGENT"
echo " mode: $MODE${STOP_MODE:+ (stop)}${GRACEFUL:+ +graceful}"
[ "$STOP_MODE" = "1" ] && echo " reason: $REASON"
[ "$CAPTURE_ID" = "1" ] && echo " captured: ${CAPTURED_UUID:-<none>}"
echo " reason: $REASON"
echo " captured: ${CAPTURED_UUID:-<none>}"
echo " purge: $PURGE${PURGE_UUID:+ (uuid $PURGE_UUID)}"
echo " time: $NOW_ISO"
echo