Task 02 (the whole runner) ended at 21:08 with no file and no commit: Ornith wrote the
implementation out in its reply (~700 lines), OpenCode compacted the session, and the model
then emitted a malformed tool call. It had also looked for the given test at a wrong path
("_files/…" relative to the plan: my ambiguity).
- 02-runner → 02-runner-core (types, Submit, Status, Touch, Cancel; no goroutines) and
03-runner-workers; the given test split to match (runner_core_test.go gains three tests:
queued storage, cancel while queued, Status never moves the lease); later tasks renumbered
04-09 and every cross-reference checked.
- Copy paths spelled from the repo root.
- guard.PreCheck (+ its test) is committed here by the owner: the old task 05 told the
implementer to edit internal/guard/, which AGENTS.md forbids. My contradiction.
- AGENTS.md rule 6: write code into files early, in small steps.
Both runner test files compile against a panic-only skeleton (the core file also alone) and
fail there on panic: todo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
16 KiB
Markdown
269 lines
16 KiB
Markdown
# Sift P6 — asynchronous batch fetch
|
||
|
||
Drafted 2026-09-26 by straylight (Hermes agent) from the design discussion with Kyle. Reviewer:
|
||
Claude (when it frees up this evening). Implementer: Kyle's call.
|
||
|
||
## 1. Scope
|
||
|
||
In: an asynchronous batch fetch endpoint. An agent submits a list of targets (URLs, each with the
|
||
same per-target options as `/v1/fetch`) and gets a job ID back immediately; it polls for per-target
|
||
results later. One job drains **serially** — one target at a time — in the background.
|
||
|
||
Out:
|
||
|
||
- Parallel fan-out. A batch is deliberately slow; it is the tool for research threads that are
|
||
already expected to take a long time, not for interactive fetches.
|
||
- Crawling, retry-with-backoff at the target level, and anything that requires a headless browser.
|
||
- Firecrawl compatibility. This is a native-only feature; no Hermes plugin.
|
||
|
||
The guard, auth and restricted-caller rules do not change: each target is fetched exactly like a
|
||
page, through the same SSRF guard and the same per-caller/host gates.
|
||
|
||
## 2. Why agents need this (2026-09-26)
|
||
|
||
`docs/PLAN.md` §13 (Kyle): "an agent that has collected a series of targets and wants them fetched
|
||
without holding a request open per target." Today that means a loop of `sift fetch` calls — one
|
||
request held open per URL, each its own tool call and gate. A batch lets the agent submit the whole
|
||
list, walk away, and collect once. It builds on the P5 infrastructure that already exists: the
|
||
`dcache` SQLite store, the `global`/`per` concurrency gates, and the `doFetch` pipeline.
|
||
|
||
## 3. Endpoints and wire shapes
|
||
|
||
| Method | Path | Result |
|
||
|---|---|---|
|
||
| `POST /v1/batch` | submit a list of targets | `202` created/reused, `400` malformed, `422` refused up front or over a cap, `429` caller/jobs busy |
|
||
| `GET /v1/batch/{id}` | status summary; `?results=1` inlines results | `200`, `401`/`404` |
|
||
| `GET /v1/batch/{id}/{idx}` | one target's status and result | `200`, `401`/`404` |
|
||
| `DELETE /v1/batch/{id}` | cancel | `200`, `409` already finished, `401`/`404` |
|
||
|
||
With `[batch] disabled = true` every batch endpoint answers `503 batch disabled`.
|
||
|
||
`POST /v1/batch` request — each target is the `/v1/fetch` field set minus the outer URL:
|
||
|
||
```json
|
||
{"targets": [{"url": "…", "mode": "clean", "question": "…", "pages": "…", "grep": "…",
|
||
"jq": "…", "context": true, "recover": false}, …]}
|
||
```
|
||
|
||
Response `202`:
|
||
```json
|
||
{"success": true, "job_id": "…", "status": "queued", "targets": 3, "reused": false}
|
||
```
|
||
|
||
`GET /v1/batch/{id}` (a summary: no results, since 32 inline results can be ~6 MB in one agent
|
||
tool call):
|
||
```json
|
||
{"job_id": "…", "status": "partial", "targets_total": 3, "targets_done": 3,
|
||
"created_at": "2026-09-27T01:02:03Z", "targets": [
|
||
{"idx": 0, "url": "…", "status": "done"},
|
||
{"idx": 1, "url": "…", "status": "failed", "error": "blocked: private address …", "hint": "browser"}
|
||
]}
|
||
```
|
||
|
||
`?results=1` adds `"result"` (the `/v1/fetch` envelope) to every `done` target.
|
||
`GET /v1/batch/{id}/{idx}` returns `{"job_id", "idx", "url", "status", "result"?, "error"?,
|
||
"hint"?}`. A `pending` target has `status: "pending"` and no `result`/`error`. A `202` for a new
|
||
job says `"status": "queued"`; a reused one says the job's current status. `DELETE` → `200 {"success": true, "status":
|
||
"cancelled"}`.
|
||
|
||
Job status vocabulary: `queued → running → done | partial | failed | cancelled`. Target status:
|
||
`pending → running → done | failed`.
|
||
|
||
## 4. Storage (`dcache`)
|
||
|
||
Two tables, added to `internal/dcache` (which owns the single SQLite connection):
|
||
|
||
- `batch_job(id, caller, caller_class, idem_key, status, error, targets_total, targets_done,
|
||
created_at, lease, nbytes)`
|
||
- `batch_target(job_id, idx, url, options, status, result, error, hint)` — `result` is the JSON
|
||
envelope, `NULL` until the target finishes.
|
||
|
||
Batch storage is a coherent whole: it is bounded by its own lease GC and a byte budget, **not** by
|
||
the content cache's LRU. `EvictBatchOldest` evicts whole jobs oldest-`lease`-first; a
|
||
`queued`/`running` job is never byte-evicted. `MarkInFlightFailed(reason, now)` is the restart hook.
|
||
|
||
## 5. The runner (`internal/batch`)
|
||
|
||
A new package with the job lifecycle and the serial state machine. Persistence is the `*dcache.DB`;
|
||
the only injected behavior is a `FetchFunc func(ctx, Target) (Result, error)`.
|
||
|
||
- **Idempotency (in v1).** A job has a random opaque `id` plus a derived `idem_key =
|
||
SHA256(caller + "\x00" + canonical target list)`. Resubmitting the same (caller, targets) while
|
||
the job's lease has not expired returns the existing job (`reused: true`) instead of a new one.
|
||
Idempotency is lease-scoped, so it is "reuse within TTL, fresh after".
|
||
- **Caps.** targets/job 32; live jobs/caller 1; live jobs total 8. "Live" is by status
|
||
(`queued`/`running`), not lease. Exceeding a cap is a typed error the API maps to `422`/`429`.
|
||
- **The caller.** `Submit` takes the `*auth.Caller`; the runner keeps it in memory with the job
|
||
and passes it to every `FetchFunc` call (`func(ctx, *auth.Caller, Target)`), so the fetch runs
|
||
under the caller's gates, pool slot, cache class and redaction. Only its name and class are
|
||
stored.
|
||
- **Workers.** `workers` (default 1, at most 2) share one round-robin over the live jobs, one
|
||
target at a time. A job is `queued` until a worker first takes it, then `running`.
|
||
- **Serial.** Targets drain in `idx` order; target `i+1` does not start until `i` is written.
|
||
- **Busy.** A `FetchFunc` error wrapping `ErrRetryLater` (the API maps fetch `429`/`503` to it)
|
||
leaves the target pending; it is tried again after 2 s, for up to 10 min, then failed.
|
||
- **Failure isolation.** One target's error is stored per-target and the job continues; the job
|
||
status is `done` (all ok), `failed` (all failed), or `partial` (mixed).
|
||
- **Cancel.** `DELETE` flips the job `cancelled`, stops before the next target, and lets the
|
||
in-flight target finish and store. Completed targets stay readable until the lease lapses.
|
||
|
||
## 6. Scheduling and fairness
|
||
|
||
The workers run in the background, and every target still goes through the normal `acquire` gate
|
||
(8 global, 2 per-host). With 1–2 workers the batch holds at most 1–2 of the 8 global slots, so
|
||
interactive requests always have room. (The first draft let 8 live jobs each run a target at
|
||
once, which could fill the whole gate.) On `429 busy` the worker **waits and retries** instead of
|
||
failing: nobody is holding a socket open.
|
||
|
||
Two shared resources get the same treatment:
|
||
- **The PDF pool.** A batch extraction uses the pool caller name `<caller>/batch`, so it never
|
||
takes the caller's own interactive slot. The single-flight key is unchanged, so a batch and an
|
||
interactive request for the same PDF still share one child.
|
||
- **The digest gate.** A batch question queues on it like any other digest. With one worker
|
||
that is at most one batch digest at a time.
|
||
|
||
Each batch fetch runs under the same 210 s deadline as a `/v1/fetch` request.
|
||
|
||
## 7. Retention and TTL
|
||
|
||
A job carries a single sliding **lease**, bumped on submit, on every target completion, and on
|
||
every read by its owner (the API calls `Touch` after the ownership check; the store's reads
|
||
never write). All targets therefore share one expiry (the last completion's), and a
|
||
slow consumer's reads keep a finished batch alive. GC deletes a job and its targets once the lease
|
||
is older than the TTL (24 h). Completed results survive a restart and stay readable until the lease
|
||
lapses; `queued`/`running` jobs are marked `failed` (`restart`) on startup and never resumed.
|
||
|
||
## 8. Restricted callers
|
||
|
||
Compartmentalized-access systems: their usage is redacted as much as possible. The existing
|
||
`logHost`/`logReason`/`logErr` machinery already blanks host, reason and error detail for
|
||
`PermitRestricted` callers, and the digest/embed caches partition by caller class — the batch
|
||
inherits all of it by passing the caller through. Two additions: batch log lines never carry a
|
||
target URL or hostname for **any** caller, and `GET`/`DELETE` enforce job ownership, returning
|
||
`404` (not `403`) for a job owned by another caller so a caller cannot probe for its existence.
|
||
The `idem_key` is stored as a hash and never logged alongside its target list.
|
||
|
||
## 9. Caps and metrics (sized empirically)
|
||
|
||
The caps are starting values, instrumented so they can be re-sized from observed data:
|
||
|
||
- `sift_batch_jobs_total{outcome}` — `created|reused|done|partial|failed|cancelled|restart`
|
||
- `sift_batch_targets_total{outcome}` — `ok|blocked|timeout|busy|upstream_4xx|upstream_5xx|
|
||
unsupported|too_long|cancel`
|
||
- `sift_batch_queue_depth`, `sift_batch_targets_inflight` (gauges)
|
||
- `sift_batch_job_seconds`, `sift_batch_target_seconds`, `sift_batch_result_bytes` (histograms)
|
||
|
||
Both `outcome` counters fold any value outside their known set to `other`, so a raw string never
|
||
becomes a label. `result_bytes` is what sizes the byte budget (64 MiB).
|
||
|
||
## 10. CLI and skill
|
||
|
||
- `sift batch submit <targets.json>` (`-` reads stdin; prints `job_id` and `reused`).
|
||
- `sift batch status [--results] <job_id>` (the summary; `--results` inlines every result).
|
||
- `sift batch get <job_id> <idx>` (one result).
|
||
- `sift batch cancel <job_id>`.
|
||
- `skill/SKILL.md`: use `sift batch submit` for a list of targets instead of a loop of
|
||
`sift fetch`; `sift batch status` to collect.
|
||
- `[batch]` config block: `disabled`, `targets_per_job`, `jobs_per_caller`, `jobs_total`,
|
||
`workers`, `ttl`, `max_bytes`.
|
||
The `/v1/batch` body cap (256 KiB, vs the 64 KiB global) is a constant, not config.
|
||
|
||
## 11. Refactor of existing code (explicit)
|
||
|
||
`v1fetch` today inlines the whole per-target pipeline (guard → `doFetch` → `recover` →
|
||
render/digest → context → envelope). The batch worker needs the same pipeline for one target. It is
|
||
extracted into one shared method, `func (s *Server) fetchTarget(ctx, *auth.Caller, batch.Target)
|
||
(batch.Result, error)`, that both `v1fetch` and the runner's adapter (`batchFetch`) call. It is its
|
||
own task (04), done before any batch endpoint exists, so the diff is a pure move. `v1fetch` keeps its
|
||
exact response shape and behavior — its existing tests must pass unchanged. This is the one change
|
||
to working code in P6 and is called out for reviewer attention.
|
||
|
||
## 12. Acceptance (contract) tests
|
||
|
||
Acceptance-first, as in P5a: given tests compiled against a panic-only skeleton, failing for the
|
||
intended reasons. See `docs/plans/p6/_files/` and the task table in `docs/plans/p6/README.md`.
|
||
|
||
1. `dcache`: round trip, idempotency lookup (lease expiry, caller scope, status filter), live
|
||
counts, ordered targets, exactly-once `targets_done` and lease bumps, sweep, byte eviction
|
||
(oldest first, never a live job), `MarkInFlightFailed`, `BatchNbytes`.
|
||
2. `batch` runner:
|
||
- serial order (peak in flight 1), with the caller passed to every fetch;
|
||
- round-robin between jobs, and `queued` → `running`;
|
||
- failure isolation (`partial`, `failed`);
|
||
- idempotent resubmit, including after `done`, but a new job after a cancel;
|
||
- all three caps and `ErrNoTargets`; cancel (the in-flight target stored, later ones `pending`);
|
||
- busy retry and its limit; panic recovery (the panic value not echoed); the hooks.
|
||
3. `metrics`: series names, labels, folding to `other`.
|
||
4. `api` `fetchTarget`: the same content and envelope as `/v1/fetch`; its errors as `*fetch.Error`.
|
||
5. `api` wiring: the runner through `fetchTarget`, a private-resolving name blocked per target,
|
||
metrics through the hooks, outcome classes, busy → `ErrRetryLater`.
|
||
6. `guard.PreCheck`: no resolution; the port, scheme, userinfo and literal rules; the allow list.
|
||
7. `api` endpoints:
|
||
- submit `202` and idempotent resubmit;
|
||
- `400`/`422` refusals (the guard pre-check naming the index, 33 targets);
|
||
- the 256 KiB body; the per-target `blocked` failure giving `partial`;
|
||
- summary, one result, `?results=1`;
|
||
- ownership `404` (GET, one result, DELETE); cancel and `409`;
|
||
- no job id in `/metrics`.
|
||
8. `config`: the `[batch]` block, `ParseSize`. `cmd/sift`: `parseBatchArgs`.
|
||
|
||
## 13. Rollout
|
||
|
||
1. Implementer builds P6 on a branch; the reviewer (Claude) checks §11, the `v1fetch` refactor,
|
||
and the caps/lease semantics.
|
||
2. Orion pin bump with the `[batch]` config block; no `MemoryMax` change.
|
||
3. Skill update ships with the pin bump.
|
||
4. Watch `sift_batch_*` and tirith for a week; the success measure is that a research thread's
|
||
one-request-per-URL fetch loop collapses into a single batch submit.
|
||
|
||
## 14. Changes after the plan was written
|
||
|
||
**2026-09-26 evening, owner review (Claude), at Kyle's direction ("fold them into the plan").**
|
||
The design stands; these were implementation-blocking or would have hurt agents:
|
||
|
||
1. **The caller was lost.** `FetchFunc` had no caller and the runner was built once, so the given
|
||
api test (`batch.New(db, s.fetchTarget, …)`) could not compile against the specified
|
||
`fetchTarget(ctx, *auth.Caller, Target)`. Made to compile, it would have fetched every target
|
||
with no caller: no per-caller gates, pool limit, cache class or redaction. Now `Submit` takes
|
||
the `*auth.Caller` and `FetchFunc` receives it (§5).
|
||
2. **Task 01 rule 1 contradicted its test.** Store reads no longer bump the lease; the API calls
|
||
`Touch` after an owner's read (§7).
|
||
3. **Batch could starve interactive fetches.** 8 live jobs × 1 target filled the 8-slot global
|
||
gate. The busy-retry of §6 was in no task. Now there are 1–2 round-robin workers, busy-retry
|
||
is in the runner, a batch PDF uses its own pool name, and a per-target deadline applies (§5, §6).
|
||
4. **Flaky, networked API tests.** Two tests fetched `https://example.com/`, and the cancel test
|
||
raced the fetch. Every api test is now local, using held `httptest` servers and a fake DNS
|
||
name for the private-address case.
|
||
5. **A resubmit could return a dead job.** A failed or cancelled job was reused for 24 h, and
|
||
polling it kept extending that. Now only `queued`/`running`/`done`/`partial` jobs are reused.
|
||
6. **Reads were too large.** `GET` inlined every result (up to ~6 MB). It is now a summary, with
|
||
`GET …/{idx}` and `?results=1` (§3). The CLI gains `get` and `status --results`.
|
||
7. **Body cap.** The global 64 KiB `MaxBytesReader` runs in `ServeHTTP`, so a handler cannot
|
||
raise it. `ServeHTTP` now picks 256 KiB for `/v1/batch`. Request metrics use a fixed
|
||
`/v1/batch/{id}` label, so a job id never becomes a label value.
|
||
8. **Submit pre-check.** `guard.ParseURL` rejects the test's own loopback port and knows nothing
|
||
of the allow list. New `Guard.PreCheck` is `CheckURL` without resolution (an owner commit on
|
||
master, since `AGENTS.md` keeps the implementer out of `internal/guard/`). A private IP literal is now refused at submit; a *name* that resolves privately
|
||
still fails per target at fetch time.
|
||
9. **Task shape.** 5 tasks → 9 (the last split came during the run, below), one package each where possible:
|
||
01 dcache, 02 runner core, 03 runner workers, 04 metrics, 05 the `fetchTarget` refactor alone,
|
||
06 runner wiring, 07 endpoints, 08 serve/config, 09 CLI/docs. Every task restates the APIs it
|
||
uses from earlier tasks. Every given test compiles against a panic-only skeleton and fails
|
||
there on `panic: todo`.
|
||
10. **Also fixed:**
|
||
- the `ErrBusy` (jobs total) cap now has a test;
|
||
- the `202` status is `queued`, consistent with the workers;
|
||
- the `restart` outcome is counted by `serve`;
|
||
- `Options.Context` is `*bool`, keeping `/v1/fetch`'s default.
|
||
|
||
**Sequencing.** P6 starts after P5c merges. P5b/P5c change `v1fetch` (the digest meta gains
|
||
`retrieval`/`embedded`), which is the code task 05 moves. Recheck the api given tests against
|
||
a skeleton built on post-P5c master before the run.
|
||
|
||
**During the run (2026-09-26 21:08).** Task 02 (the whole runner) ended with no file: the model
|
||
wrote the implementation out in its reply, the session was compacted, and it then emitted a
|
||
malformed tool call. The runner is now two tasks (02 core, 03 workers), each with its own given
|
||
test. `AGENTS.md` gains a rule to write code into files early. Copy paths are spelled from the
|
||
repo root. Details in `docs/plans/p6/README.md`, "Changes during the run".
|
||
|