Skip to content

Commit a62d3dc

Browse files
mpstatonclaude
andcommitted
fix(crawl, workspace): the eternal spinner — crawl invokes get a client deadline, dispatch ceiling doubles
The Curry Foundation crawl completed server-side in 87s while the tab spun forever: workspace.invoke has no client-side timeout, WS reconnects (e.g. a workspace-service rebuild mid-crawl — three today) drop pending invokes, and the 300s dispatch ceiling sat one slow team crawl away from biting (211s observed live on Quell). crawlSearch and crawlTeam now race a 660s deadline — the spinner always resolves to a retryable error that names the run may still have finished server-side — and organization.crawl's NATS timeout moves to 600s (pack.fan_out's precedent). Transport-level invoke re-correlation across reconnects folds into the liveness sweep (#21); crawl progress frames (#35) would surface a dead reply path within seconds. Closes #40. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UvYzx7vDWeafnkAi2nEQeb
1 parent 914037d commit a62d3dc

4 files changed

Lines changed: 100 additions & 8 deletions

File tree

apps/org-workbench/src/lib/org-client.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ export async function saveBrief(client: string, brief: string): Promise<void> {
4747
if (!r.ok) throw new Error(r.error || 'client.brief.set failed');
4848
}
4949

50+
// The workspace invoke has NO client-side timeout, and a WS reconnect drops
51+
// pending invokes — a lost reply means an eternal spinner. Long-running
52+
// calls race a deadline so the UI always resolves to retryable state.
53+
function withDeadline<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
54+
return Promise.race([
55+
p,
56+
new Promise<T>((_, reject) =>
57+
setTimeout(
58+
() => reject(new Error(`${label} got no reply in ${Math.round(ms / 1000)}s — the run may still finish server-side; re-fire to retry`)),
59+
ms,
60+
),
61+
),
62+
]);
63+
}
64+
5065
// didi's team crawl — candidates only; staging + accept live in PeopleReveal.
5166
export type CrawledPerson = {
5267
name: string;
@@ -60,11 +75,11 @@ export async function crawlTeam(
6075
org_slug: string,
6176
client: string,
6277
): Promise<{ people: CrawledPerson[]; filtered_note: string; source_urls: string[] }> {
63-
const r = (await workspace.invoke('organization.crawl', {
64-
org_slug,
65-
target: 'team',
66-
client,
67-
})) as {
78+
const r = (await withDeadline(
79+
workspace.invoke('organization.crawl', { org_slug, target: 'team', client }),
80+
660_000,
81+
'didi team crawl',
82+
)) as {
6883
ok: boolean;
6984
people?: CrawledPerson[];
7085
filtered_note?: string;

apps/search-and-add/src/lib/search-client.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ export async function scanStream(args: {
5252
};
5353
}
5454

55+
// The workspace invoke has NO client-side timeout, and a WS reconnect drops
56+
// pending invokes — a lost reply means an eternal spinner. Long-running
57+
// calls race a deadline so the UI always resolves to retryable state.
58+
function withDeadline<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
59+
return Promise.race([
60+
p,
61+
new Promise<T>((_, reject) =>
62+
setTimeout(
63+
() => reject(new Error(`${label} got no reply in ${Math.round(ms / 1000)}s — the run may still finish server-side; re-fire to retry`)),
64+
ms,
65+
),
66+
),
67+
]);
68+
}
69+
5570
// v1.2 crawl mode — didi's web crawl for the launching org. One model turn
5671
// with server-side web search; slow (tens of seconds); candidates only.
5772
export async function crawlSearch(args: {
@@ -60,7 +75,11 @@ export async function crawlSearch(args: {
6075
client: string;
6176
max_results?: number;
6277
}): Promise<{ provider: string; results: ConnectorResult[] }> {
63-
const r = (await workspace.invoke('organization.crawl', args)) as {
78+
const r = (await withDeadline(
79+
workspace.invoke('organization.crawl', args),
80+
660_000,
81+
'didi crawl',
82+
)) as {
6483
ok: boolean;
6584
provider?: string;
6685
results?: ConnectorResult[];
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
title: "Crawl replies can be lost — the eternal 'crawling…' spinner (no client timeout, reconnect-dropped invokes, tight dispatch ceiling)"
3+
lede: "The Curry Foundation crawl finished server-side in 87 seconds — and the tab spun forever anyway. Three gaps stacked: the browser invoke has no deadline, a WS reconnect (e.g. a workspace-service rebuild mid-crawl) silently drops pending invokes, and the 300s dispatch ceiling was one slow team-crawl away from biting (211s observed)."
4+
date_created: 2026-07-24
5+
date_modified: 2026-07-24
6+
authors:
7+
- Michael Staton
8+
augmented_with:
9+
- Claude Code on Claude Fable 5
10+
semantic_version: 0.0.0.1
11+
tags:
12+
- Issue
13+
- Augment-It
14+
- Didi-Crawl
15+
- Workspace
16+
- Transport
17+
- Reliability
18+
status: Shipped
19+
date_first_published: 2026-07-24
20+
post_ship_note: "Mitigations shipped 2026-07-24 — 660s client-side deadline on both crawl invokes (spinner always resolves to a retryable error naming that the run may still finish server-side), dispatch ceiling 300s→600s. The root transport gap (re-correlating pending invokes across WS reconnects) is folded into the legibility/liveness sweep scope ([[Live-Not-Live-Indicator-Tooling-And-Cross-Service-Error-Surfacing]]); [[Crawl-Progress-Is-A-Black-Box-Needs-Traces-The-Operator-Can-Watch]]'s progress frames would also make a lost reply visible within seconds. gh #40 closed."
21+
---
22+
23+
# Crawl replies can be lost
24+
25+
## The incident (2026-07-24 evening, logs-confirmed)
26+
27+
Operator fires a links crawl on The Beth & Ravenel Curry Foundation → UI
28+
stuck on "crawling…". prompt-runner logs show the truth: `crawl completed
29+
… ms: 87542`. The work succeeded; the reply never made it back. Earlier
30+
team crawls (Quell 211s, Truist 147s) completed too — 211s uncomfortably
31+
close to the 300s dispatch ceiling.
32+
33+
## The three stacked gaps
34+
35+
1. **No client-side deadline.** `workspace.invoke` waits forever; a lost
36+
reply is an eternal spinner with a disabled button.
37+
2. **WS reconnects drop pending invokes.** The transport reconnects with
38+
backoff, but in-flight invokes don't survive the new socket — and
39+
workspace-service container rebuilds (three today) sever every session.
40+
Any crawl pending across one is orphaned.
41+
3. **300s dispatch ceiling vs multi-minute crawls.** The NATS
42+
request timeout was set before live team-crawl timings existed.
43+
44+
## Shipped mitigations
45+
46+
- 660s `withDeadline` race on `crawlSearch` and `crawlTeam` — the spinner
47+
always resolves; the error names that the run may still have finished
48+
server-side and to re-fire.
49+
- `organization.crawl` dispatch ceiling 300s → 600s (pack.fan_out's
50+
precedent).
51+
52+
## Remaining (folded elsewhere)
53+
54+
- Transport-level invoke re-correlation across reconnects (or
55+
server-held results claimable by request id) → the liveness sweep (#21).
56+
- Crawl progress frames (#35) would surface a dead reply path within
57+
seconds instead of minutes.

services/workspace/src/capabilities.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,9 @@ const CAPABILITY_TIMEOUTS_MS: Record<string, number> = {
306306
'client.brief.get': 30_000,
307307
'client.brief.set': 30_000,
308308
// A crawl is one model turn with multiple server-side web searches (plus
309-
// pause_turn continuations) — minutes, not seconds.
310-
'organization.crawl': 300_000,
309+
// pause_turn continuations) — minutes, not seconds. Team crawls have been
310+
// observed at 211s live; 600s matches the pack.fan_out ceiling.
311+
'organization.crawl': 600_000,
311312
// One query, one provider — pack.search's budget.
312313
'search.fire': 30_000,
313314
// Multi-stage (Firecrawl index harvest + per-post dates + dedup read) —

0 commit comments

Comments
 (0)