Let the CLI talk to taskd instead of a second task store - #42
Merged
Conversation
`GET /v1/tasks/{id}` returns the most recent `DEFAULT_EVENT_LIMIT` events
and reports the true total in `event_count`, so a truncated read is
visible rather than silent. That guarantee only pays off for a client
that can tell the window from the total, and the ceiling on `?events=`
is the difference between "ask for more" and "this history is not
reachable through the API at all" -- so both numbers are contract, not
implementation detail.
Exporting them lets a client that has to repeat the values assert
equality against the daemon instead of drifting away from it, the same
anti-drift pattern the token permission constants already use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CLI opened a `FileTaskStore` in process, defaulting to `.andromeda/state` under the working directory, while the installed daemon keeps its records in `/var/lib/andromeda-taskd/state` under a systemd `DynamicUser` at mode 0700. On a real machine those are two disjoint task universes and the daemon's is not merely elsewhere, it is unreadable -- so `andromeda task list` printed an empty list that meant "you are looking at the wrong store", not "there are no tasks" (architecture review #5). Connected mode makes the daemon the single source of truth: `--connect <URL>` (`ANDROMEDA_TASKD_URL`) routes every task subcommand through the HTTP API, posting the same request types the local path builds, so the two modes cannot drift into two contracts. The mode is now a required choice. Removing the default is what makes the silent failure unreachable rather than merely warned about, matching how the rest of this repository turns conventions into mechanisms (`Authenticator` has no anonymous variant, `ensure_loopback_bind` refuses at startup, `hardware check` refuses an unverified tier gate). Naming neither mode prints both commands and the reason there is no default; every task subcommand then echoes its resolved target to stderr -- absolute store path, or endpoint plus token file -- so an empty answer always arrives with the identity of the thing that was empty. stdout stays pure JSON. Authentication is read from a file and from nowhere else. There is no `--auth-token` flag and no `ANDROMEDA_AUTH_TOKEN`: `argv` is readable by every local process through `/proc`, which is exactly the credential the daemon's 0700 runtime directory exists to withhold. `os/files/usr/libexec/andromeda-ci-verify` does put a token on a command line and explains at length why that is acceptable only on a throwaway CI VM with no interactive users. Absent `--auth-token-file`, the token is looked up in `/run/andromeda-taskd/token` then `.andromeda/taskd-token`, and the file that was used is reported. A 401 says which file was presented, that the daemon deliberately does not distinguish missing from wrong, and where each side's default lives; a permission denial says that only the service account and root can read the token by design, so re-run under sudo. The wire contract is honored as it stands rather than papered over: `list` prints taskd's summaries and says they carry no event bodies, `show` prints the bounded window and reports `showing 50 of 137 events; pass --events 137 for the rest`, and past the daemon's ceiling it says the oldest events are unreachable instead of suggesting a value the daemon would silently clamp. `--events` is refused by the local path rather than ignored, because a local read has no window to set. `--connect` accepts loopback endpoints only, refused before any byte is sent: the request carries the local bearer token, so an external host would be handed the credential, which is a stronger reason than merely mirroring taskd's own Host check. No HTTP crate is added. `hyper`'s `client` feature puts `want` and `try-lock` in the lockfile and would make this one-shot CLI async for a single bounded request against a loopback socket, so the client is blocking and built on `std::net`. The only lockfile change is four dev-dependency names on `andromeda-cli`; no package is added, removed, or changed version. `main` now prints `Display` and walks `source()` instead of returning `Result`, whose `Debug` rendering would deliver the multi-line explanation of the two stores as an escaped quoted string. Tests run taskd's real router on a real loopback socket, so they exercise the bytes this client puts on the wire: the token is read from the file and accepted, a wrong one produces the actionable 401, and `list`/`show` parse the current summary and bounded-read shapes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The docs described a single CLI that opened `.andromeda/state`, which is the arrangement architecture review #5 objected to, and said nothing about the daemon's store being a different and unreadable set of tasks. Every place that documents a task command now states which store it acts on, why there is no default, and what the connected mode returns: summaries with no event bodies from `list`, a bounded event window with the true total from `show`, and the fact that `--events` widens only the API's window. The token rules are stated once per document and identically: file only, searched at the image path then the hand-started default, reported in the banner, and never accepted as a value because `argv` is readable through `/proc`. `getting-started.md` gains an end-to-end connected-mode walkthrough against the daemon it has just told the reader to start, which is the shortest path from "the API is running" to "the CLI reads the same tasks". The review documents under `docs/reviews/` are left untouched: they are point-in-time artifacts pinned to the HEAD they were written against, and earlier fixes did not rewrite them either. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
clap counts an environment-supplied value as present for `conflicts_with`, so a developer who exports `ANDROMEDA_STATE_DIR` -- which the developer docs actively suggest -- was told that "the argument '--connect <URL>' cannot be used with '--state-dir <PATH>'" after typing only `--connect`. The message named a flag that never appeared on the command line, in the one area of this CLI whose entire purpose is to stop leaving the reader guessing which task store is in play. The conflict now falls to `resolve_task_target`, alongside every other rule about the two modes, and says what a caller can act on: both values, that exactly one is wanted, and that either can have come from the environment. Nothing silently wins; there is no precedence between them, because picking one would be the CLI deciding which set of tasks the caller meant, which is the habit this change exists to break. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three places still said the two modes "conflict", one of them naming clap's `conflicts_with` as the mechanism. That stopped being true when the check moved into `resolve_task_target` so the message could name the environment variables the caller may not have typed. Each place now states what actually happens: both-given is refused with no precedence, and either value can have come from the environment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
oratis
force-pushed
the
feat/cli-taskd-client
branch
from
August 3, 2026 02:14
4f018f8 to
ab67e86
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves architecture review finding #5 — "CLI 与 taskd 是两套互不连通的前端".
The failure this removes
andromeda-cliopened aFileTaskStorein process, defaulting to.andromeda/stateunder theworking directory. The installed
andromeda-taskdkeeps its records in/var/lib/andromeda-taskd/stateunder a systemdDynamicUserat mode0700. On a real machinethose are two disjoint task universes, and the daemon's is not merely elsewhere — it is
unreadable. So a user running
andromeda task listopened a different, empty store and got:which is indistinguishable from "there are no tasks". Both halves claimed to be the task list, and
nothing in the output said which one you were looking at.
What changed
1. The CLI can be a taskd client.
--connect <URL>(ANDROMEDA_TASKD_URL) routes everytasksubcommand through the HTTP API — create, list, show, evaluate, record-outcome, transition. Each
arm posts the same
CreateTaskRequest/EvaluationRequest/RecordOutcomeRequest/StateTransitionRequestthe local path builds, which is exactly what taskd deserializes, so the twomodes cannot drift into two contracts.
2. The mode is a required choice, not a default. Naming neither store is an error that prints
both commands and why there is no default:
Removing the default is what makes the silent case unreachable rather than merely warned about,
which is how the rest of this repository turns conventions into mechanisms (
Authenticatorhas noanonymous variant;
ensure_loopback_bindrefuses at startup;hardware checkrefuses anunverified tier gate outright).
Naming both is refused too, with no silent precedence — picking one would be the CLI deciding
which set of tasks you meant, which is the habit being broken. That check is not clap's
conflicts_with: clap counts an environment-supplied value as present, so a developer who exportsANDROMEDA_STATE_DIR(which the developer docs suggest) would be told--connect"cannot be usedwith --state-dir" after typing only
--connect. The message now names both values and points atthe two variables as the likely source.
3. Whichever mode runs, the target is printed. One line on stderr before the output, so stdout
stays pure JSON and
| jqis unaffected. An empty answer now always arrives with the identity ofthe thing that was empty:
Both mechanisms are in place on purpose: the required choice makes the original failure structurally
impossible, and the banner keeps any remaining ambiguity visible even when the choice was made
correctly.
4. The wire contract is honored, not papered over.
GET /v1/tasksreturns summaries with noevent bodies and
GET /v1/tasks/{id}returns a bounded window plus the trueevent_count, so theCLI never implies it holds full history:
Past the daemon's ceiling it says the oldest N events are unreachable through the API rather than
suggesting an
--eventsvalue that would be silently clamped.--eventsis refused by the localpath instead of ignored, because a local read has no window to set. The two constants the CLI
repeats (the image token path, the event ceiling) are asserted equal to
andromeda_taskd::auth::SYSTEM_TOKEN_PATHandandromeda_taskd::MAX_EVENT_LIMITby a unit test —the same anti-drift pattern the systemd-unit permission test already uses. Making those bounds
public is the one change to
andromeda-taskd: they are contract, since only a client that can tellthe window from the total benefits from
event_countbeing honest.Authentication
Every taskd route requires the bearer token,
/healthzincluded, so the CLI presents it on everyrequest.
--auth-tokenflag and noANDROMEDA_AUTH_TOKEN.argvis readableby every local process through
/proc, which is precisely the credential the daemon's0700runtime directory exists to withhold.
os/files/usr/libexec/andromeda-ci-verifydoes put a tokenon a command line and justifies it at length as acceptable only on a throwaway CI VM with SSH
disabled and no interactive users; a command users run does not qualify for that exemption.
--auth-token-file/ANDROMEDA_AUTH_TOKEN_FILE, else/run/andromeda-taskd/token(the image path) then
.andromeda/taskd-token(a hand-started daemon's default). The file actuallyused is named in the banner and in every error.
A permission denial says the 0600-in-0700 layout is the boundary working as designed, not a
misconfiguration, and to re-run under sudo. A missing file lists every path searched. No token
value ever appears in an error, a log line, or the client's
Debugoutput.--connect http://example.comis refused before any byte is sent — therequest would carry the local bearer token, which is a stronger reason to refuse than merely
mirroring taskd's own
Hostcheck. Tunnelled endpoints still work, since they present as127.0.0.1locally.Dependency delta
No package added, removed, or changed version. The
Cargo.lockdiff is four dev-dependencynames on
andromeda-cli:All four already exist in the lockfile; they are test-only, so the shipped binary gains nothing.
I checked the HTTP-client options before writing one.
hyperandhyper-utilare already in thegraph via axum, but I measured what turning on the client half costs: enabling
hyper/{client,http1}+hyper-util/{client,client-legacy,tokio}addswantandtry-locktoCargo.lock, and would make this one-shot CLI async for the sake of a single request against aloopback socket. Given that this repository already rejected a change for adding a third major
version of
getrandom, two new packages plus a runtime for one bounded request is not a trade worthmaking. The client is therefore blocking and built on
std::net: request framing,Content-Lengthand chunked decoding, timeouts, and a response cap, in ~200 lines with unit tests for each piece.
Tests
cargo test --workspace --locked— 275 passing, up from 254.andromeda-cligoes from 17 tests to38.
The connected-mode tests spin taskd's real router on a real loopback socket in-process (tokio
runtime +
axum::serveon127.0.0.1:0), so they exercise the bytes the client actually puts on thewire — the
Authorizationheader included — rather than a stub that could agree with a wrong client:the_client_presents_the_token_from_the_token_filea_rejected_token_names_the_file_and_says_what_to_check— asserts the 401 message contains thefile used, both default paths, and the env var, and contains no token
list_parses_the_current_summary_shape— asserts noeventsand noplankeyshow_parses_the_bounded_read_and_reports_the_true_total— 61 events, default window returns 50with
event_count: 61,?events=61widensa_refused_request_reports_the_wire_error_code— 404 surfacesnot_found, not a bare numberthe_token_search_path_matches_the_daemons_own— constants pinned to the daemon'sPlus mode resolution (
neither_mode_is_refused_with_both_options_spelled_out,naming_both_stores_is_refused_without_a_silent_precedence,a_token_file_without_connect_is_refused_rather_than_ignored,the_mode_flags_are_accepted_at_either_level), the banner, the truncation note including thepast-the-ceiling case, endpoint parsing, non-loopback refusal, and HTTP response parsing
(content-length, chunked, truncated, empty, request-target smuggling).
Verified end to end against a live daemon: created a task through
--connect, listed it, grew itshistory to 56 events and saw the truncation note appear and then disappear under
--events 56,drove a
Ready -> Runningtransition, and confirmed the local store stayed empty and said so.cargo fmt --all -- --checkandcargo clippy --workspace --all-targets --locked -- -D warningsare clean.
Judgement calls worth flagging
depends on someone reading stderr; removing the default makes the original failure impossible to
reach. Cost: the documented
andromeda task listform now needs a flag, and the docs are updatedaccordingly. Local-store mode itself is untouched and remains the development path.
--eventsis connected-mode only, andlistoutput differs between modes. Local mode is alibrary view (whole records, unbounded); connected mode is the API view (summaries, bounded
window). I did not mirror taskd's DTO layer into the CLI to force identical shapes —
wire.rsitself argues that re-mirroring a contract duplicates rather than insulates it. The banner and the
per-command notes make which view you got explicit instead.
--auth-token-filein local mode is an error, not ignored. Silently dropping an input thecaller deliberately supplied is the same class of mistake as the silent default.
mainnow printsDisplayand walkssource().fn main() -> Result<_, _>rendersDebug,which would deliver the multi-line explanation of the two stores as an escaped quoted string —
the exact problem commit
a141716fixed forandromeda-taskd.docs/reviews/left untouched. Those are point-in-time artifacts pinned to the HEAD they werewritten against, and earlier fixes (findings docs: add Andromeda OS research and product plan #1, feat: add Andromeda core task and capability contracts #2, feat: add durable Andromeda task runtime and API #3) did not rewrite them either.
🤖 Generated with Claude Code