Adds app/afk/ — the "away-from-keyboard" control plane that watches the
issue tracker for ready-for-agent issues, dispatches each to a fresh
full-access T3 thread (with the issue-implementer preamble prepended,
because T3 does not honour ~/.claude/CLAUDE.md), and drives the resulting
run through its lifecycle: tests-red -> green -> pushed -> CI -> deployed,
escalating or fix-forwarding via a small pure state machine.
The loop is split into pure cores (no I/O, exhaustively unit-tested) and
thin injected adapters (the only edges that ever touch T3, the tracker,
CI, or Slack — faked in every test, so nothing here talks to a real
server, GitHub/Forgejo, or the cluster):
pure: types, dispatch_policy, run_state_machine, phase_checklist,
config, issue_implementer_prompt
adapters: t3_client (two-POST dispatch + snapshot), tracker, ci_watcher,
notifier
loops: poller — CronJob tick #1: list_ready -> select_dispatchable
-> dispatch + stamp the in-progress lock (label only
AFTER a successful dispatch, so a failed dispatch
never leaves a phantom lock). Per-repo lock derived
from the ready set, since the CronJob is stateless
between ticks.
watcher — CronJob tick #2: assemble RunState from snapshot +
CI -> next_action -> act (close on success; relabel
ready-for-human + ring the doorbell on the two
escalations; dispatch a corrective turn on
fix-forward; refresh the progress checklist).
SHIPS DISABLED, on purpose: Config defaults to kill_switch=True AND an
empty allowlist, so a freshly-loaded config dispatches nothing and does
zero I/O. The package is not imported by the running service and has no
auto-enable path. Arming it is a deliberate, later, manual step requiring
BOTH gates (clear the kill switch AND enrol the exact repos) so one
fat-fingered env var can't arm every repo.
Test-first throughout: 412 tests pass (poller + watcher add integration
tests wiring the real pure cores to in-memory fakes). mypy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""Tests for ``app.afk.t3_client`` — the in-cluster T3 dispatch/snapshot adapter.
|
|
|
|
Everything here runs against an in-memory FAKE HTTP transport (``FakeHttp``);
|
|
no test touches a real T3 server, GitHub/Forgejo, or the cluster. The fake
|
|
records every request and replays staged responses, so the assertions pin the
|
|
wire contract the control plane depends on:
|
|
|
|
* ``dispatch`` issues exactly TWO POSTs to ``/api/orchestration/dispatch`` —
|
|
``thread.create`` then ``thread.turn.start`` — carrying
|
|
``modelSelection.instanceId == "claudeAgent"`` and ``runtimeMode ==
|
|
"full-access"``, with ``ISSUE_IMPLEMENTER_PREAMBLE`` PREPENDED to
|
|
``message.text`` and the thread id from the first response threaded into the
|
|
second.
|
|
* each request carries the ``Authorization: Bearer <token>`` header from the
|
|
injected bearer provider (re-read per call, so token refresh is honoured).
|
|
* ``snapshot`` GETs ``/api/orchestration/snapshot`` and returns the parsed body.
|
|
"""
|
|
import pytest
|
|
|
|
from app.afk import t3_client
|
|
from app.afk.issue_implementer_prompt import ISSUE_IMPLEMENTER_PREAMBLE
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fake HTTP transport — httpx-shaped (``post``/``get`` → response with
|
|
# ``.json()`` + ``.raise_for_status()``), so the real client can hand the
|
|
# adapter a plain ``httpx.Client`` while tests hand it this recorder.
|
|
# --------------------------------------------------------------------------- #
|
|
class FakeResponse:
|
|
def __init__(self, payload: dict, status_code: int = 200) -> None:
|
|
self._payload = payload
|
|
self.status_code = status_code
|
|
|
|
def json(self) -> dict:
|
|
return self._payload
|
|
|
|
def raise_for_status(self) -> None:
|
|
if self.status_code >= 400:
|
|
raise RuntimeError(f"HTTP {self.status_code}")
|
|
|
|
|
|
class FakeHttp:
|
|
"""Records each POST/GET and replays queued responses in order.
|
|
|
|
``post`` pops from ``post_responses`` (FIFO); ``get`` pops from
|
|
``get_responses``. Each recorded call captures the url, json body, and
|
|
headers so tests can assert the two-command dispatch shape and the bearer.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
post_responses: list[dict] | None = None,
|
|
get_responses: list[dict] | None = None,
|
|
) -> None:
|
|
self.post_responses = list(post_responses or [])
|
|
self.get_responses = list(get_responses or [])
|
|
self.posts: list[dict] = []
|
|
self.gets: list[dict] = []
|
|
|
|
def post(self, url: str, json: dict, headers: dict) -> FakeResponse:
|
|
self.posts.append({"url": url, "json": json, "headers": headers})
|
|
if not self.post_responses:
|
|
raise AssertionError("unexpected POST — no response staged")
|
|
return FakeResponse(self.post_responses.pop(0))
|
|
|
|
def get(self, url: str, headers: dict) -> FakeResponse:
|
|
self.gets.append({"url": url, "headers": headers})
|
|
if not self.get_responses:
|
|
raise AssertionError("unexpected GET — no response staged")
|
|
return FakeResponse(self.get_responses.pop(0))
|
|
|
|
|
|
# Two thread.create / thread.turn.start replies the happy-path dispatch needs.
|
|
_CREATE_REPLY = {"threadId": "thread-abc"}
|
|
_TURN_REPLY = {"ok": True}
|
|
|
|
|
|
def _client(http: FakeHttp, *, base_url: str = "http://t3-afk:8080", token: str = "tok-1"):
|
|
return t3_client.T3Client(
|
|
base_url=base_url,
|
|
http=http,
|
|
bearer_provider=lambda: token,
|
|
)
|
|
|
|
|
|
def _dispatch(http: FakeHttp, **kw) -> str:
|
|
repo = kw.pop("repo", "infra")
|
|
issue = kw.pop("issue", 42)
|
|
prompt = kw.pop("prompt", "Do the thing.")
|
|
return _client(http, **kw).dispatch(repo=repo, issue=issue, prompt=prompt)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# dispatch — the two-POST shape.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_dispatch_issues_exactly_two_posts_to_dispatch_endpoint():
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
_dispatch(http)
|
|
assert len(http.posts) == 2
|
|
assert http.gets == []
|
|
for call in http.posts:
|
|
assert call["url"] == "http://t3-afk:8080/api/orchestration/dispatch"
|
|
|
|
|
|
def test_dispatch_first_command_is_thread_create():
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
_dispatch(http)
|
|
assert http.posts[0]["json"]["command"] == "thread.create"
|
|
|
|
|
|
def test_dispatch_second_command_is_thread_turn_start():
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
_dispatch(http)
|
|
assert http.posts[1]["json"]["command"] == "thread.turn.start"
|
|
|
|
|
|
def test_dispatch_returns_thread_id_from_create_response():
|
|
http = FakeHttp(post_responses=[{"threadId": "thread-xyz"}, _TURN_REPLY])
|
|
assert _dispatch(http) == "thread-xyz"
|
|
|
|
|
|
def test_dispatch_threads_created_id_into_turn_start():
|
|
http = FakeHttp(post_responses=[{"threadId": "thread-xyz"}, _TURN_REPLY])
|
|
_dispatch(http)
|
|
# The second command must target the thread the first call created.
|
|
assert http.posts[1]["json"]["threadId"] == "thread-xyz"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# dispatch — model selection / runtime envelope (the pilot-baked constants).
|
|
# --------------------------------------------------------------------------- #
|
|
def test_dispatch_uses_claude_agent_instance_and_full_access_runtime():
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
_dispatch(http)
|
|
create_body = http.posts[0]["json"]
|
|
assert create_body["modelSelection"]["instanceId"] == "claudeAgent"
|
|
assert create_body["runtimeMode"] == "full-access"
|
|
|
|
|
|
def test_dispatch_create_carries_repo_and_issue():
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
_dispatch(http, repo="claude-agent-service", issue=7)
|
|
create_body = http.posts[0]["json"]
|
|
assert create_body["repo"] == "claude-agent-service"
|
|
assert create_body["issue"] == 7
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# dispatch — the preamble PREPEND (behaviour injection).
|
|
# --------------------------------------------------------------------------- #
|
|
def test_dispatch_prepends_issue_implementer_preamble_to_message_text():
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
_dispatch(http, prompt="Implement issue 42 body here.")
|
|
text = http.posts[1]["json"]["message"]["text"]
|
|
assert text == ISSUE_IMPLEMENTER_PREAMBLE + "Implement issue 42 body here."
|
|
|
|
|
|
def test_dispatch_preamble_comes_strictly_before_the_prompt():
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
_dispatch(http, prompt="UNIQUE-PROMPT-MARKER")
|
|
text = http.posts[1]["json"]["message"]["text"]
|
|
assert text.startswith(ISSUE_IMPLEMENTER_PREAMBLE)
|
|
assert text.index(ISSUE_IMPLEMENTER_PREAMBLE) < text.index("UNIQUE-PROMPT-MARKER")
|
|
# The raw prompt is preserved verbatim after the preamble.
|
|
assert text.endswith("UNIQUE-PROMPT-MARKER")
|
|
|
|
|
|
def test_dispatch_does_not_prepend_preamble_to_create_command():
|
|
# The preamble belongs only on the turn message, not the thread.create call.
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
_dispatch(http)
|
|
assert "message" not in http.posts[0]["json"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Auth — bearer header, read from the injected provider each call.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_dispatch_sends_bearer_on_both_posts():
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
_dispatch(http, token="secret-token")
|
|
for call in http.posts:
|
|
assert call["headers"]["Authorization"] == "Bearer secret-token"
|
|
|
|
|
|
def test_bearer_provider_is_called_per_request_so_refresh_is_honoured():
|
|
# A rotating provider proves the token isn't captured once at construction
|
|
# (T3's orchestration token expires hourly and must be re-read).
|
|
tokens = iter(["tok-A", "tok-B", "tok-C"])
|
|
http = FakeHttp(post_responses=[_CREATE_REPLY, _TURN_REPLY])
|
|
client = t3_client.T3Client(
|
|
base_url="http://t3-afk:8080",
|
|
http=http,
|
|
bearer_provider=lambda: next(tokens),
|
|
)
|
|
client.dispatch(repo="infra", issue=1, prompt="x")
|
|
assert http.posts[0]["headers"]["Authorization"] == "Bearer tok-A"
|
|
assert http.posts[1]["headers"]["Authorization"] == "Bearer tok-B"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# snapshot — GET + parse.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_snapshot_gets_snapshot_endpoint_and_returns_parsed_body():
|
|
fleet = {"threads": [{"id": "thread-abc", "status": "running"}]}
|
|
http = FakeHttp(get_responses=[fleet])
|
|
result = _client(http).snapshot()
|
|
assert result == fleet
|
|
assert len(http.gets) == 1
|
|
assert http.gets[0]["url"] == "http://t3-afk:8080/api/orchestration/snapshot"
|
|
assert http.posts == []
|
|
|
|
|
|
def test_snapshot_sends_bearer():
|
|
http = FakeHttp(get_responses=[{"threads": []}])
|
|
_client(http, token="snap-token").snapshot()
|
|
assert http.gets[0]["headers"]["Authorization"] == "Bearer snap-token"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# base_url handling — a trailing slash must not produce a double slash.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_trailing_slash_in_base_url_is_normalised():
|
|
http = FakeHttp(
|
|
post_responses=[_CREATE_REPLY, _TURN_REPLY],
|
|
get_responses=[{"threads": []}],
|
|
)
|
|
client = _client(http, base_url="http://t3-afk:8080/")
|
|
client.dispatch(repo="infra", issue=1, prompt="x")
|
|
client.snapshot()
|
|
assert http.posts[0]["url"] == "http://t3-afk:8080/api/orchestration/dispatch"
|
|
assert http.gets[0]["url"] == "http://t3-afk:8080/api/orchestration/snapshot"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Error surfacing — a non-2xx response must raise, not be swallowed.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_dispatch_raises_when_a_post_returns_an_error_status():
|
|
class ErroringHttp(FakeHttp):
|
|
def post(self, url: str, json: dict, headers: dict) -> FakeResponse:
|
|
self.posts.append({"url": url, "json": json, "headers": headers})
|
|
return FakeResponse({}, status_code=500)
|
|
|
|
http = ErroringHttp()
|
|
with pytest.raises(RuntimeError):
|
|
_dispatch(http)
|
|
# It failed on the FIRST call — never blindly fired thread.turn.start after
|
|
# a failed thread.create.
|
|
assert len(http.posts) == 1
|