feat(control): flag-gated headless runner with cross-worker resume (phase 2)

Wires the phase-1 headless machinery behind runner=headless (default
stays tmux; legacy agents, session_id null, keep the tmux paths):

- spawn: branches tmux vs headless.launch; extracts _agent_env (shared
  with resume - a headless resume is a new process needing identity/
  credential env); headless spawns require a task (no idle-REPL mode),
  enforced at spawn and as a 400 in the API
- resume: headless path materializes the session archive from the DB
  onto whichever worker claimed the command, then claude -p --resume;
  falls back to a fresh session with DB-re-injected context (visible
  worker event) when no transcript survives anywhere; refuses while a
  run is live. Undeliverable resumes now raise -> command FAILED,
  fixing silent input loss on both runners
- kill: headless path flags cancel_requested; the owning supervisor
  SIGTERMs its own child (cross-worker safe)
- worker: stable per-container ids, DB-driven run slots (full workers
  skip claiming spawn/resume/mise_init, leaving them for less-loaded
  workers), credsync refresh in the main loop
- settings_gen: permissions block (defaultMode + allowlist) for
  headless runs - -p auto-denies anything that would prompt; hooks
  remain the hard gate
- credsync + migration 0009 (runtime_secrets): login publishes the
  Fernet-encrypted claude credential bundle; every worker materializes
  it (merge-safe for local trust state); login_submit pinned to the
  login_start worker via commands.target_worker

Suite 270 -> 290 green, including the cross-worker resume linchpin
(clean-HOME materialize + --resume against the fake binary).
This commit is contained in:
2026-07-21 22:55:28 -04:00
parent f3acc57015
commit 650f376934
15 changed files with 1016 additions and 43 deletions
+21 -3
View File
@@ -65,8 +65,8 @@ def test_resume_command_feeds_answer_and_sets_working(env, monkeypatch):
_seed_project("api")
seen = {}
def fake_resume(agent, ans):
seen.update(name=agent["name"], ans=ans)
def fake_resume(agent, ans, worker_id=None):
seen.update(name=agent["name"], ans=ans, worker_id=worker_id)
return True, "ok"
monkeypatch.setattr(spawn, "resume", fake_resume)
@@ -74,11 +74,29 @@ def test_resume_command_feeds_answer_and_sets_working(env, monkeypatch):
worker.drain("w")
assert _get(cmd["id"])["status"] == "done"
assert seen == {"name": "api", "ans": "Postgres"}
assert seen == {"name": "api", "ans": "Postgres", "worker_id": "w"}
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "p", "api")["status"] == "working"
def test_resume_command_fails_loudly_when_undeliverable(env, monkeypatch):
"""An undeliverable answer must surface as a FAILED command — never a silent 'done'
(the original bug: send-keys into a dead tmux pane reported success)."""
_seed_project("api")
monkeypatch.setattr(
spawn, "resume", lambda agent, ans, worker_id=None: (False, "no live session")
)
cmd = _enqueue(type="resume", project_id="p", agent_name="api", payload={"answer": "x"})
worker.drain("w")
failed = _get(cmd["id"])
assert failed["status"] == "failed"
assert "no live session" in failed["error"]
with get_engine().begin() as conn:
# The agent must NOT be flipped to working when nothing was delivered.
assert repo.get_agent_by_name(conn, "p", "api")["status"] != "working"
def test_approve_command_records_operator_verdict_with_head_sha(env, fake_gitops):
_seed_project("senior")
cmd = _enqueue(