feat(db,control): dormant headless-runner schema, stream parser, and fixtures (phase 1)

Groundwork for replacing tmux-TUI agent runs with worker-owned
'claude -p --output-format stream-json' subprocesses (Postgres as the
single source of truth; no shared files between workers):

- migration 0008: workers (heartbeat registry), agent_runs (one row per
  headless invocation), agent_events (persisted stream-json event log),
  session_archives (tar.gz'd claude session per agent for cross-worker
  --resume); agents gains session_id/worker_id, commands gains
  target_worker; 'crashed' joins the agent status vocabulary
- control/headless.py: munged-path helper, argv builders, tolerant
  stream parser, archive/materialize round-trip, RunSupervisor + launch
  (nothing calls it yet - runner config still defaults to tmux)
- repository: run/event/archive/worker accessors; claim_next_command
  learns target_worker pinning and type exclusion for full slots
- tests/fixtures/fake_claude.py: scripted stream-json stand-in binary
- scripts/validate_claude_headless.sh: manual real-binary validation
  checklist (resume-on-clean-HOME linchpin, hook behavior under -p)

No behavior change; suite 245 -> 270 green.
This commit is contained in:
2026-07-21 22:41:46 -04:00
parent 40390c0568
commit f3acc57015
9 changed files with 1496 additions and 4 deletions
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# Phase-1 validation of the real `claude` binary's headless behavior (plan §0).
# Run MANUALLY inside the control container (needs real claude + credentials); findings
# go in the phase-1 PR description. Never wired into CI — this probes the real binary.
#
# CLAUDE_BIN=claude ./scripts/validate_claude_headless.sh /tmp/headless-validation
#
# Each check prints PASS/FAIL/INFO; the script keeps going so one failure doesn't hide
# the rest. Items map to the plan's "must validate" list:
# 1. --verbose requirement with -p --output-format stream-json
# 2. exact stream-json event shapes for this CLI version
# 3. --resume from a transcript materialized onto a clean $HOME (THE LINCHPIN)
# 4. PreToolUse deny behavior under -p
# 5. Stop-hook decision:block behavior under -p
# 6. -p exit-code semantics + credential env var name
set -uo pipefail
CLAUDE_BIN="${CLAUDE_BIN:-claude}"
WORK="${1:-/tmp/headless-validation}"
SID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
mkdir -p "$WORK/repo" "$WORK/home-a" "$WORK/home-b"
cd "$WORK/repo"
note() { printf '\n=== %s ===\n' "$*"; }
result() { printf -- '--> %s\n' "$*"; }
note "0. version"
"$CLAUDE_BIN" --version
note "1. does -p --output-format stream-json work WITHOUT --verbose?"
if HOME="$WORK/home-a" "$CLAUDE_BIN" -p --output-format stream-json \
--session-id "$SID" -- 'Reply with the single word: ping' >"$WORK/no-verbose.out" 2>&1; then
result "PASS without --verbose (flag optional — keep passing it anyway)"
else
result "FAIL without --verbose (required, as the runner assumes): $(tail -1 "$WORK/no-verbose.out")"
fi
note "2. event shapes (fresh run, --verbose)"
SID2="$(python3 -c 'import uuid; print(uuid.uuid4())')"
HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
--session-id "$SID2" -- 'Reply with the single word: pong' \
| tee "$WORK/stream.jsonl" \
| python3 -c '
import json, sys
for line in sys.stdin:
line = line.strip()
if not line: continue
try:
e = json.loads(line)
keys = ",".join(sorted(e.keys()))
print(f" type={e.get(\"type\")}/{e.get(\"subtype\")} keys=[{keys}]")
except Exception:
print(f" UNPARSEABLE: {line[:100]}")
'
result "exit code: $? — full stream saved to $WORK/stream.jsonl"
note "3. cross-worker resume: materialize transcript onto a clean HOME (LINCHPIN)"
MUNGED="$(python3 -c "import sys; print(sys.argv[1].replace('/', '-').replace('.', '-'))" "$WORK/repo")"
SRC="$WORK/home-a/.claude/projects/$MUNGED"
DST="$WORK/home-b/.claude/projects/$MUNGED"
mkdir -p "$DST"
if cp -r "$SRC/$SID2.jsonl" "$DST/" 2>/dev/null; then
[ -d "$SRC/$SID2" ] && cp -r "$SRC/$SID2" "$DST/"
if HOME="$WORK/home-b" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
--resume "$SID2" -- 'What word did you reply with before? Answer with just that word.' \
>"$WORK/resume.jsonl" 2>"$WORK/resume.err"; then
if grep -q 'pong' "$WORK/resume.jsonl"; then
result "PASS: resume on clean HOME retained context (found 'pong')"
else
result "PARTIAL: resume ran but context unclear — inspect $WORK/resume.jsonl"
fi
else
result "FAIL: resume on clean HOME errored — $(tail -1 "$WORK/resume.err") (fallback re-injection will be load-bearing)"
fi
else
result "FAIL: no transcript found at $SRC — munge algorithm wrong for this version?"
fi
note "4. PreToolUse deny under -p"
mkdir -p .claude
cat > .claude/settings.json <<'EOF'
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"validation: always deny\"}}'"}
]
}
]
},
"permissions": {"defaultMode": "acceptEdits", "allow": ["Bash(echo *)"]}
}
EOF
HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
--settings .claude/settings.json \
-- 'Run the bash command `echo hello` and tell me its output.' \
>"$WORK/deny.jsonl" 2>&1
result "exit=$? — grep the stream for the deny reason:"
grep -o 'validation: always deny' "$WORK/deny.jsonl" | head -1 \
&& result "PASS: deny reason surfaced in stream" \
|| result "INSPECT $WORK/deny.jsonl: deny reason not found"
note "5. Stop hook decision:block under -p"
cat > .claude/settings.json <<'EOF'
{
"hooks": {
"Stop": [
{"hooks": [
{"type": "command",
"command": "if [ -f /tmp/.hv-stop-once ]; then echo '{}'; else touch /tmp/.hv-stop-once; echo '{\"decision\":\"block\",\"reason\":\"validation: say BLOCKED-ONCE then finish\"}'; fi"}
]}
]
}
}
EOF
rm -f /tmp/.hv-stop-once
HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
--settings .claude/settings.json -- 'Reply with the word: initial' \
>"$WORK/stopblock.jsonl" 2>&1
result "exit=$? — expect a second turn mentioning BLOCKED-ONCE:"
grep -o 'BLOCKED-ONCE' "$WORK/stopblock.jsonl" | head -1 \
&& result "PASS: Stop block re-prompted under -p" \
|| result "INSPECT $WORK/stopblock.jsonl: no evidence of re-prompt"
rm -f /tmp/.hv-stop-once
note "6. exit codes"
HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \
--resume "$(python3 -c 'import uuid; print(uuid.uuid4())')" -- 'x' \
>"$WORK/badresume.out" 2>&1
result "resume of nonexistent session exit=$? (runner treats nonzero-before-assistant as resume failure)"
result "INFO: check credential env name with: $CLAUDE_BIN setup-token --help (expect CLAUDE_CODE_OAUTH_TOKEN)"
note "done — artifacts in $WORK"