9.7 KiB
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 + per-target results (inline) | 200, 401/404 |
DELETE /v1/batch/{id} |
cancel | 200, 401/404 |
POST /v1/batch request — each target is the /v1/fetch field set minus the outer URL:
{"targets": [{"url": "…", "mode": "clean", "question": "…", "pages": "…", "grep": "…",
"jq": "…", "context": true, "recover": false}, …]}
Response 202:
{"success": true, "job_id": "…", "status": "queued", "targets": 3, "reused": false}
GET /v1/batch/{id}:
{"job_id": "…", "status": "done", "targets_total": 3, "targets_done": 3,
"created_at": "…", "targets": [
{"idx": 0, "url": "…", "status": "done", "result": { …/v1/fetch envelope… }},
{"idx": 1, "url": "…", "status": "failed", "error": "blocked: private address …", "hint": "browser"}
]}
Results are inline regardless of job status, once a target is finished. A pending target has
status: "pending" and no result/error. 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)—resultis the JSON envelope,NULLuntil 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
idplus a derivedidem_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 to422/429. - Serial. Targets drain in
idxorder; targeti+1does not start untiliis written. - Failure isolation. One target's error is stored per-target and the job continues; the job
status is
done(all ok),failed(all failed), orpartial(mixed). - Cancel.
DELETEflips the jobcancelled, 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 worker runs in the background, and every target still goes through the normal acquire gate
(8 global, 2 per-host). On 429 busy the worker waits and retries instead of failing — nobody
is holding a socket open. Interactive requests are served by HTTP handlers that grab slots
immediately, so they win by construction; the batch is lower priority for free.
7. Retention and TTL
A job carries a single sliding lease, bumped on submit, on every target completion, and on
every status/collect read. 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|restartsift_batch_targets_total{outcome}—ok|blocked|timeout|busy|upstream_4xx|upstream_5xx| unsupported|too_long|cancelsift_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; printsjob_idandreused).sift batch status <job_id>(prints the full status body).sift batch cancel <job_id>.skill/SKILL.md: usesift batch submitfor a list of targets instead of a loop ofsift fetch;sift batch statusto collect.[batch]config block:targets_per_job,jobs_per_caller,jobs_total,ttl,max_bytes. The/v1/batchbody 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 FetchFunc call. 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/.
dcache: create/read round trip, idempotency lookup (lease expiry + caller scope), live-job counts, ordered targets, exactly-oncetargets_done/lease bump, sweep, byte-eviction (oldest-first, never a live job),MarkInFlightFailed,BatchNbytes.batchrunner: serial order (peak in-flight 1), failure isolation (partial), idempotent resubmit (reused, no double-drain), the three caps, cancel (in-flight stored, later targetspending), panic recovery.api: submit202+ idempotent resubmit; a guard-pre-check rejection (non-80/443 port) is422at submit; 33 targets422; a private-address target (passes pre-check, blocked at connect) is a per-targetfailed, aggregatepartial; ownership (GETby another caller →404); cancel →cancelled.metrics: exact series names, label values, gauge values, histogram counts, and raw-outcome folding toother.cmd/sift:parseBatchArgsverb/arg pairs and error cases.
13. Rollout
- Implementer builds P6 on a branch; the reviewer (Claude) checks §11, the
v1fetchrefactor, and the caps/lease semantics. - Orion pin bump with the
[batch]config block; noMemoryMaxchange. - Skill update ships with the pin bump.
- 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
(none yet)