Skip to content

feat(http-api): add GET /agent-task/runs/:id as a pure read - #11894

Merged
chubes4 merged 1 commit into
mainfrom
feat/agent-task-http-api
Aug 7, 2026
Merged

feat(http-api): add GET /agent-task/runs/:id as a pure read#11894
chubes4 merged 1 commit into
mainfrom
feat/agent-task-http-api

Conversation

@chubes4

@chubes4 chubes4 commented Aug 7, 2026

Copy link
Copy Markdown
Member

The finding overstated the gap, and the headline is what already works. Two of its five "missing" items exist, one of them backwards. Reporting that rather than building past it.

What already works — because cook and fanout are controller jobs now

Finding claimed missing Reality
submit Exists. POST /controller/jobsenqueue_controller_jobdriver("agent-task.cook", v). Idempotency-keyed; validate_secret_references fails closed on any inline prompt/env/token via deny_unknown_fields.
cancel Exists — and the finding was backwards. POST /controller/jobs/:id/cancel works; the generic POST /jobs/:id/cancel deliberately refuses controller jobs (store/mod.rs:1564) with a hint pointing at the right route, so the driver's cancel() can stop the work it owns.
cook report / gate evidence Reachable. GET /jobs/:id/events carries driver-projected public_progress/public_result; evidence URIs hydrate through GET /runs/:id/artifacts/:id/content.
retry Genuinely absent. Correctly out of scope.
read a run by id Genuinely absent — but GET /activity/:id already resolved agent-task run ids, including Cook aliases.

#11857 and #11871 closed most of this as a side effect.

Existing redaction is already sound and needed no change: ControllerJobState (holding the private request) hangs off StoredJob, not Job, and job_store.get() returns stored.job.clone() — so GET /jobs/:id cannot leak it. CookJobDriver::public_error already collapses to a typed code plus "controller-owned cook supervision failed", precisely because cook error text quotes provider output.

The one real gap, and why it is a pure read

GET /agent-task/runs/:id — an agent-task-scoped read that fails closed on a non-agent-task id.

It does not call agent_task_service's status function. It could not, and should not.

  • Could not: homeboy-agents/Cargo.toml states it — "Depends on homeboy-core; core does not depend on it." http_api.rs is core. That call is a dependency cycle.
  • Should not: status_with_options does ~8 store::write_record calls and, for a non-controller-local record, reconcile_runner_job_statea live network round trip to the runner. On a serial accept loop that stalls the entire daemon. This file already refused exactly this once: require_run's doc comment (Adopt runs_service/evidence_report facade across observation-store consumers (single activity-aware surface) #6768) declines the reconciling facade for the same reason.

So it resolves via activity::agent_task_provider::probe_by_id — an existing, registered, indexed, explicitly non-mutating lookup (#10308) that already handles Cook-id aliasing, returning ActivityItem, the same projection GET /activity/:id serves. Not an invented shape.

Deliberately not activity::show_activity, which falls back to a full-corpus scan of up to 1000 records across three stores when probes miss.

The response carries reconciles: false and reconcile_with: "homeboy agent-task status" rather than letting a caller assume freshness.

SSE — not feasible, and not attempted

daemon/mod.rs:1155:

for stream in listener.incoming() {
    Ok(stream) => { let _ = handle_connection(stream, &job_store, ...); }

Inline, single-threaded, no per-connection spawn. One SSE client would hold the accept loop for the life of the stream, blocking /health, daemon status, and the reverse-broker /runner/jobs/* routes remote runners depend on. That is a total daemon outage, not degradation. Zero hits for event-stream / chunked / Transfer-Encoding anywhere.

Making it safe needs: per-connection concurrency at 1155; a streaming writer (write_http_response emits Content-Length + Connection: close and returns after one write!); bounds that do not exist (max streams, max lifetime, keepalive frames); and socket timeouts.

Pre-existing hazard found while reading: read_http_request blocks on stream.read() with no timeout, so today a client that connects and sends nothing wedges the entire daemon. Filed separately — it deserves attention independent of this work.

Cheaper alternative, proposed not built: JobEvent already carries sequence: u64, but job_store.events() returns the whole Vec under a mutex every call. A GET /jobs/:id/events?after=N cursor is a pure request/response change needing zero restructuring, bounds the payload, and turns O(n²) polling into O(n).

Auth and redaction

Auth: identical to neighbours by inheritance. broker_auth applies only to /files/* and /runner/*; the read-only API is reached via _ => route_read_only_api(...) without it. Protection is the daemon's hard loopback-only bind validation. This route sits in the same fallthrough — not less protected, and deliberately not more, since a lone authenticated route in an unauthenticated block is confusing rather than safer.

Redaction, three layers: ActivityItem is a typed allowlist and hardcodes command/cwd to None for agent-task records, so no prompt or provider output is reachable; a failing probe reports probe_failed: true and never its message, because the daemon copies err.message straight into the body; and the id is length-capped at 256 bytes before the store lookup.

No write path added — tests assert POST /agent-task/runs/:id, /agent-task/runs, .../cancel, .../retry all fail to route.

Verification

cargo check --workspace --tests clean. http_api tests: 36 passed, 1 failedartifact_content_serves_encoded_artifact_store_locator, which I confirmed fails identically on origin/main. Pre-existing, filed separately, unrelated to this change.

Flagging

  • The happy path is untested and I could not test it in core. Core's tests depend on no agent-task provider being registered (activity.rs:572), and registration is process-global and irreversible, so a fake would poison the shared lib-test binary. It needs a homeboy-agents-level test.
  • I edited http_api/types.rs to add the HttpEndpoint variant — impossible otherwise.
  • probe_failed conflates a store outage with a miss (both 404). Deliberate — it matches resolve_activity_item, and the alternative leaks error text.
  • requested_id and run_id may differ when a Cook alias resolves to an attempt record; both are exposed.
  • daemon/mod.rs needed zero changes — the _ => route_read_only_api(...) fallthrough picks the route up, and no daemon arm shadows /agent-task.

An out-of-process orchestrator could already list, inspect, watch and cancel a
detached cook through the generic controller-job surface — cook and fanout are
both `ControllerJobDriver`s (#11857, #11871) — but had no way to read the
durable agent-task *run* the job supervises. A run is not a job.

Resolve it through the activity agent-task provider's `probe_by_id`, which is
already documented as an indexed, non-mutating lookup (#10308) and already
understands Cook-id aliasing.

Deliberately NOT `agent_task_lifecycle::status()`, the reconciling read behind
the CLI:

- it rewrites the durable record on the way out, and a GET that mutates is a
  decision, not something to replicate silently
- for a non-controller-local record it performs a live runner round trip. The
  daemon accept loop handles one connection inline before accepting the next,
  so an unbounded read stalls every other client of a shared process
- `require_run` in this module already refuses the same reconciling facade for
  the same reason (#6768)

The response says `reconciles: false` and names the command that does, rather
than letting a caller assume freshness.

Bounding: exactly one indexed probe, no fallback. `activity::show_activity`
would fall back to a full-corpus scan of up to 1000 records across three
stores; that is unbounded work on a serial loop, and wrong on an agent-task
route anyway. The id is length-capped before it reaches the record store.

Redaction: the `ActivityItem` projection is a typed field-by-field allowlist —
ids, timestamps, state, and evidence *references*. `command`/`cwd` are `None`
for an agent-task record, so no prompt or provider output is reachable. A
failing probe is reported as a flag, never as its message, because error text
from this subsystem can quote record contents and the daemon copies
`message`/`details` straight into the response body.

Auth posture is identical to its neighbours: the read-only API takes no bearer
token and is protected by the daemon's hard loopback-only bind validation.

No submit or retry route: submission has its own safety review.
@chubes4
chubes4 merged commit 12dc930 into main Aug 7, 2026
3 of 10 checks passed
@chubes4
chubes4 deleted the feat/agent-task-http-api branch August 7, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant