mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 17:56:25 +00:00
f3acc57015
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.
139 lines
5.5 KiB
Bash
Executable File
139 lines
5.5 KiB
Bash
Executable File
#!/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"
|