mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 19:06: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.
164 lines
5.0 KiB
Python
Executable File
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())
|