Searches Become Async Jobs — the Queue Rail Lands: Fire Many, Walk Away, Triage on Arrival
Agent searches run minutes; the operator no longer does. Every 🤖 now returns instantly, the search lands as a card in a persistent right rail, and the card signals when candidates arrive — expand, accept, dismiss.
Searches Become Async Jobs — the Queue Rail Lands
Why Care?
Until today, asking didi to crawl the web for an org's links, streams, or team meant watching a disabled button for one to three and a half minutes — per search, per org. The operator was the slowest part of a pipeline whose whole point is that the agent does the waiting.
Now a search is a job. Click the 🤖, get your attention back immediately, click four more on four other orgs if you like. Each search becomes a card in a new right-hand queue rail: status, elapsed time against the typical duration for that kind of search ("running · 1:06 / typically ~3:20" — the antidote to the frozen "crawling…" line), and a signal when candidates arrive. Expand the card, ➕ the links that belong or accept the staged people through the same match-or-create gates as before, mark it complete, and it leaves the queue. Refresh the tab, close the browser, come back — the queue is still there, because it never lived in the browser.
What's New?
search.submit/search.list/search.results/search.dismiss— four new capabilities served by the workspace service itself. Submit validates, registers the search, returns asearch_idin milliseconds (4ms observed live), and dispatches the existing crawl over NATS without awaiting it. prompt-runner is untouched — the async boundary moved into the workspace service, not the runner.A durable server-side registry (
searches.ts) — in-memory map with write-through JSON on the same volume as sessions and the active workspace. A workspace restart mid-crawl marks the stranded searchfailedwith a Retry affordance instead of spinning forever — the same honesty the claim protocol (#41) brought to long invokes.search.updatedbroadcasts — the rail refetches on events, never polls. Submit, start, settle, and dismiss all announce themselves to every connected session.The
search-resultsremote (apps/search-results, :3018) — the chat rail's right-side mirror, with its own🔎 queuetoggle in the shell header and a done-count badge that stays visible while the rail is collapsed. Cards carry provenance (target chip, org, timestamps); the accept surfaces are per-target: links/streams rows with ➕ carrying the crawl's inferredkind(and a stream's real name) through the write, and a team surface with the full staged-people candidate gates, consume-on-accept (#37).Door switchover — the org card's links/streams 🤖 and the People 🤖 now enqueue instead of hijacking the search-and-add column; search-and-add's crawl mode (and the envelope
crawlflag) is retired. Its term-search and stream-scan modes are unchanged.Dismiss with a conscience — marking a done card complete while it still holds unreviewed candidates asks once, inline: "7 candidates not reviewed — dismiss anyway?"
scripts/prove-search-queue.mjs— the acceptance proof over the real WS: submit → running event → settle event → results → dismiss.
How It Went
The spec (context-v/specs/Search-Results-Queue-Remote.md) called this in
three phases and they landed in order. Phase 1 proved out over the live
stack against the Aspen safe target: submit returned in 4ms, the running
and settle events both arrived, search.results returned 7 candidates,
dismiss emptied the registry — all green, first run.
The browser drive then walked the spec's named click-path: fire the links 🤖 and the team 🤖 on The Aspen Institute back to back, watch both cards tick in the rail ("2 in flight"), navigate away and back mid-crawl (cards still there — server-side registry doing its job), expand the finished links card into eight kind-tagged candidates, accept the LinkedIn row, and dismiss through the unreviewed-candidates confirm.
One correction to the spec along the way: it assigned the remote port 3017, which strategy-curator had already claimed. The remote lives on :3018; the spec has been annotated.
Under the Hood
The registry stores the crawl reply verbatim on the entry;
search.listreturns cards only (D5 — collapsed cards render without fetching results),search.resultsspreads the stored reply on expand.Typical durations are hardcoded v1 from live observations (links/streams ~90s, team ~200s); a rolling per-target average is the cheap v2.
Actor attribution now rides through local capabilities too — the submit-time didi identity is forwarded into the crawl's NATS request, so the async boundary doesn't lose who asked.
The shell tracks the badge count itself (the rail unmounts when hidden), refetching
search.liston everysearch.updatedevent.TTL sweep: entries older than 24h are dropped hourly; dismissed is the normal exit, the sweep catches the abandoned tail.
What's Next
Phase 4 hooks
search.progressbeats into the card's reserved progress line, jointly with the crawl-traces build (#35).The chat door: didi learning
search.submitso chat-fired crawls land in the rail too — one slab edit, deliberately deferred.New-remote rule reminder: the rail loads in the shell after the next frontend restart (
dev.shnow lists :3018) and a fresh browser load.
Spec of record: [[Search-Results-Queue-Remote]] · issue #42.
