Repository navigation
fix(provision): install the LLC agent CLIs on ansible-deployed hosts (#12681) - #14790
Conversation
…12681) docker/backend/Dockerfile installs `claude` and `gh` from signed apt repos and runs `--version` on both at build time, so an install regression fails the build rather than silently breaking dispatch. Nothing did the equivalent on an ansible-provisioned host: zero ansible files mentioned claude at all. So `resolve_cli_binary` returned None for every claude_code / copilot_local agent hired on a systemd deployment. The hire succeeded, the dispatch could not, and the failure surfaced as a skipped run rather than as a provisioning error — the "fails invisibly" symptom the issue reports. Adds the same signed repos and packages to the backend role, fetched with get_url rather than piped into a shell, plus a resolve check on the host so a broken PATH fails provisioning instead of coming up hosting agents that cannot run. backend_claude_cli_version pins an exact version, mirroring the image's existing CLAUDE_CLI_VERSION build arg. gh is included because copilot_local_adapter resolves it the same way; installing only claude would leave that adapter's agents equally unrunnable. The guard asserts the provisioning rather than the resolver — a test over resolve_cli_binary passes whether or not anything ever installs the binary, which is how this stayed invisible. Confirmed it fails against base and passes here. It deliberately contains no shutil.which gate, since a test skipped for a missing binary reads as a pass (#14550).
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
…k its guard (#12681) Two problems, both from review. The tasks sat inside the #12139 restart-guarded block, which has no rescue: and whose always: only force-starts the old binary. They reach two third-party apt repos, so a transient outage at either would have aborted the whole deploy — code sync, config, workers, nginx, health check — after the service was already stopped. That is the exact incident class the block exists to prevent, and a far larger blast radius than "this feature fails loudly". They now run before the block, so a repo hiccup fails this feature and leaves a running backend running. The guard test was vacuous in two places, both confirmed by mutation rather than inspection. `"gh" in text` is satisfied by the word Copyright in the licence header, so it passed with the gh install deleted. The `claude --version` regex matched the explanatory comment rather than the templated `cmd: "{{ item }} --version"`, so deleting the real verify task left it green. Substring checks against a whole file are not evidence. The assertions now parse the task file and inspect the apt package list and the verify loop directly. Every one was mutation-tested: dropping gh from the package list, deleting the verify task, and dropping claude from its loop each turn the corresponding assertion red, and each leaves the others green. Also from review: apt gets a bounded lock_timeout, and the cache refresh now also fires when a version is pinned — keys and sources only change on first install, so a converged host would never refresh and pinning a newly-released version would fail against a stale cache.
Both HIGH findings fixed in
|
…ent-cli # Conflicts: # autobot-slm-backend/ansible/roles/backend/defaults/main.yml
#12681) lock_timeout bounds the dpkg lock, not a hung TCP connection to the two third-party hosts the keys come from. An unbounded fetch stalls the play indefinitely rather than failing it, which is worse than either outcome the placement change was arguing between. ansible.builtin.apt takes no timeout parameter, so the apt calls stay bounded by lock_timeout alone.
Re-review verdict accepted; the one open nit is now fixed tooBounded the key fetch with One limitation worth recording rather than leaving implied: On |
…4797) Hiring already rejected an unregistered adapter_type, and the comment on that check names the failure it prevents: an agent whose every heartbeat is skipped, looking degraded forever. A registered type whose CLI is missing produced exactly that one level down. Nothing new was needed. is_cli_available() and cli_not_found_message() already existed on the subprocess base, and GET /adapters already reported availability from them; the hire path simply never asked. Review caught the gate reproducing the defect it was written to close: codex_subscription is registered, has no probe, and its invoke raises NotImplementedError, so treating a missing probe as in-process let it be hired while the UI greyed it out. Both surfaces now decide from the same hasattr. Of the five registered adapters, four inherit the subprocess base and codex_subscription is the only one without the probe, so a missing probe means unimplemented, never in-process. A crashing probe fails closed, matching GET /adapters. Refs #12681 — item 2 of three; items 1 and 3 landed as #14790 and #14761. The template import path creates agents without these checks: #14800.
Refs #12681 — item 1 of the three the issue covers. Not
Closes; item 2 remains.Thinking Path
The issue reports that setup-wizard provisioning fails invisibly. Item 1 is the
blunt half: nothing installs the
claudeCLI onto the service PATH.docker/backend/Dockerfiledoes it properly — both agent CLIs from official signedapt repos, with
gh --version && claude --versionat build time so an installregression fails the build instead of silently breaking LLC dispatch.
The ansible path had nothing. Searching the whole ansible tree for
claudereturned zero files. So on a systemd-deployed node:
resolve_cli_binary("claude", ...)returnsNonellc/api/agent_hires.pyvalidatesthat the adapter type is registered, never that it is available
SKIPPED, not as a provisioning errorWhich is precisely the invisible-provisioning symptom, and why item 3 (rendering
the skip reason, merged as #14761) was worth doing first: it makes this failure
legible while it exists.
ghis included deliberately.copilot_local_adapter._resolve_gh_cliresolvesit exactly the same way, so installing only
claudewould leave that adapter'sagents equally unrunnable — half-fixing the same defect. The image installs both
together for the same reason.
What Changed
autobot-slm-backend/ansible/roles/backend/tasks/main.yml— the same signed reposas the image:
get_url(not piped into ashell),
dpkg --print-architectureread for the GitHub source linegh+claude-codeinstalled withinstall_recommends: falsegh --version,claude --version)backend_claude_cli_version(defaults, empty) pins an exact package version,mirroring the image's existing
CLAUDE_CLI_VERSIONbuild arg.The resolve check is the point rather than a nicety: installing without checking
lets a broken PATH look identical to a clean provision, which is the failure mode
this issue is about.
Verification
I cannot run ansible here, so this is verified structurally and by rendering — not
against a host.
Jinja compiles and renders, with Ansible's filters stubbed. I checked this
explicitly because on my previous PR (#14784) I validated files as YAML, which says
nothing about Jinja, and shipped a nested-brace
TemplateSyntaxErrorthat asecurity review caught:
The empty pin yields a bare package name rather than a trailing
=, which wouldotherwise be an apt error on every unpinned provision.
The guard fails on base and passes here:
It asserts the provisioning, not the resolver: a test over
resolve_cli_binarypasses whether or not anything ever installs the binary, which is how this stayed
invisible. It contains no
shutil.whichgate anywhere — a test skipped for amissing binary is indistinguishable from a pass (#14550).
What this does not fix
Item 2 —
llc/api/agent_hires.py:365-372still accepts an unavailable adapter.That is deliberate and the ordering matters: today an operator can hire an agent
and hand-install the CLI afterwards. Rejecting the hire while nothing installs the
binary would make provisioning strictly worse. This PR is what makes item 2 safe
to land, and item 2 should follow it, not precede it.
Nothing here touches a live host — it changes what a future provision installs.
Model Used
Claude Opus 5