Files
0xWheatyz f3acc57015 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.
2026-07-21 22:41:46 -04:00

164 lines
5.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""A stand-in ``claude`` binary for headless-runner tests.
Wired in via the existing ``claude_bin`` setting (the same seam the tmux fakes used).
Parses the real headless argv, emits a scripted ``--output-format stream-json`` stream on
stdout, and writes a genuine session transcript + sidecar under
``$HOME/.claude/projects/<munged-cwd>/`` so archive/materialize/resume paths exercise the
real filesystem layout. Behavior is selected with ``FAKE_CLAUDE_MODE``:
- ``success`` (default): init + assistant + result, exit 0.
- ``error``: init + assistant + one garbage line, then exit 2 with no result event.
- ``hang``: init, then sleep forever (the kill/cancel/reaper tests SIGTERM it).
- ``slow``: like success with a pause between events (concurrency tests).
- ``resume-fail``: a ``--resume`` invocation exits 1 before any assistant event
(exercises the context re-injection fallback).
On ``--resume`` (outside resume-fail) the transcript materialized by the worker MUST
already exist at the expected path — missing means cross-worker materialization broke,
so the fake fails loudly, exit 3.
"""
from __future__ import annotations
import json
import os
import signal
import sys
import time
from pathlib import Path
def _parse_argv(argv: list[str]) -> dict:
opts = {
"print": False,
"verbose": False,
"output_format": None,
"session_id": None,
"resume": None,
"settings": None,
"budget": None,
"prompt": None,
}
i = 0
while i < len(argv):
arg = argv[i]
if arg == "-p":
opts["print"] = True
elif arg == "--verbose":
opts["verbose"] = True
elif arg == "--output-format":
i += 1
opts["output_format"] = argv[i]
elif arg == "--session-id":
i += 1
opts["session_id"] = argv[i]
elif arg in ("--resume", "-r"):
i += 1
opts["resume"] = argv[i]
elif arg == "--settings":
i += 1
opts["settings"] = argv[i]
elif arg == "--max-budget-usd":
i += 1
opts["budget"] = argv[i]
elif arg == "--":
opts["prompt"] = " ".join(argv[i + 1 :])
break
else:
opts["prompt"] = arg
i += 1
return opts
def _munged(cwd: str) -> str:
return cwd.replace("/", "-").replace(".", "-")
def _emit(event: dict) -> None:
sys.stdout.write(json.dumps(event) + "\n")
sys.stdout.flush()
def _write_transcript(session_id: str, prompt: str) -> None:
base = Path(os.path.expanduser("~")) / ".claude" / "projects" / _munged(os.getcwd())
base.mkdir(parents=True, exist_ok=True)
jsonl = base / f"{session_id}.jsonl"
with jsonl.open("a") as fh:
fh.write(json.dumps({"type": "user", "prompt": prompt}) + "\n")
fh.write(json.dumps({"type": "assistant", "text": f"handled: {prompt}"}) + "\n")
sidecar = base / session_id / "tool-results"
sidecar.mkdir(parents=True, exist_ok=True)
(sidecar / "result-1.txt").write_text("fake tool output\n")
def main() -> int:
signal.signal(signal.SIGTERM, signal.SIG_DFL)
mode = os.environ.get("FAKE_CLAUDE_MODE", "success")
opts = _parse_argv(sys.argv[1:])
if not opts["print"] or opts["output_format"] != "stream-json":
sys.stderr.write("fake_claude: expected -p --output-format stream-json\n")
return 64
session_id = opts["session_id"] or opts["resume"] or "fake-session"
prompt = opts["prompt"] or ""
if mode == "resume-fail" and opts["resume"]:
sys.stderr.write("fake_claude: no conversation found to resume\n")
return 1
if opts["resume"]:
base = Path(os.path.expanduser("~")) / ".claude" / "projects" / _munged(os.getcwd())
if not (base / f"{session_id}.jsonl").exists():
sys.stderr.write(f"fake_claude: transcript missing at {base}\n")
return 3
_emit(
{
"type": "system",
"subtype": "init",
"session_id": session_id,
"cwd": os.getcwd(),
"tools": ["Bash", "Read", "Edit"],
}
)
if mode == "hang":
time.sleep(3600)
return 0
if mode == "slow":
time.sleep(float(os.environ.get("FAKE_CLAUDE_SLOW_SECONDS", "1.0")))
_emit(
{
"type": "assistant",
"session_id": session_id,
"message": {
"role": "assistant",
"content": [{"type": "text", "text": f"working on: {prompt}"}],
},
}
)
_write_transcript(session_id, prompt)
if mode == "error":
sys.stdout.write("this is not json\n")
sys.stdout.flush()
return 2
_emit(
{
"type": "result",
"subtype": "success",
"session_id": session_id,
"is_error": False,
"num_turns": 1,
"total_cost_usd": 0.01,
"result": f"done: {prompt}",
}
)
return 0
if __name__ == "__main__":
sys.exit(main())