Skip to content

Commit ce51eb7

Browse files
mpstatonclaude
andcommitted
fix(workspace, transport): invokes can no longer freeze a pane silently — deadlines + receipt logging land
The search frontend froze "with no recourse" (gh #58 recurrence): a lost invoke had NO client-side timeout anywhere, so the pane spun forever. Two of the issue's four named probes ship: - transport.ts: default per-invoke deadline — 120s for normal capabilities, 660s (dispatch ceiling + headroom) for crawl/scan/pack shapes. On expiry the pending entry clears, any queued copy is dropped, and the caller gets a retryable error naming the capability. The eternal spinner is structurally impossible now. - ws.ts: invoke_received / invoke_result_sent / invoke_result_stashed logs (capability + invoke id) — "the frame never reached the server" becomes one grep instead of an inference. Post-instrumentation reproduction: the full 🔍 path works (search.fire received → dispatched → 10 searxng results rendered, full connector palette) after the workspace rebuild + fresh page — confirming the freeze is intermittent, tied to long-degraded sessions. When it recurs, the logs decide client-vs-server in seconds and the UI errors instead of freezing. Also observed in the operator's logs and reported: the Anthropic API account is OUT OF CREDITS (all crawls failing 400) and didi_session had expired (didi_id null on every connect; id-didi-sh itself is healthy). Refs #58. Files changed: - packages/workspace/src/transport.ts - services/workspace/src/ws.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent c0d82d4 commit ce51eb7

2 files changed

Lines changed: 31 additions & 1 deletion

File tree

packages/workspace/src/transport.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,35 @@ export function createTransport(config: TransportConfig): Transport {
185185
}, backoff);
186186
}
187187

188+
// Default client deadline (gh #58 probe 4): a lost invoke previously hung
189+
// its pane FOREVER — no timeout anywhere client-side. Crawl/scan-shaped
190+
// capabilities get the server dispatch ceiling (600s) plus headroom;
191+
// everything else fails loud at 120s with the capability named, so the
192+
// operator sees an error and can retry instead of a frozen spinner.
193+
const DEADLINE_DEFAULT_MS = 120_000;
194+
const DEADLINE_LONG_MS = 660_000;
195+
const deadlineFor = (capability: string): number =>
196+
/crawl|scan|pack\./.test(capability) ? DEADLINE_LONG_MS : DEADLINE_DEFAULT_MS;
197+
188198
async function invoke(capability: string, args: unknown, via?: string): Promise<unknown> {
189199
const id = genId();
190200
const frame: InvokeFrame = { kind: 'invoke', id, capability, args, ...(via ? { via } : {}) };
191201
const promise = new Promise<unknown>((resolve, reject) => {
192-
pending.set(id, { resolve, reject });
202+
const ms = deadlineFor(capability);
203+
const timer = setTimeout(() => {
204+
if (!pending.has(id)) return;
205+
pending.delete(id);
206+
// Drop any still-queued copy so a later flush doesn't resend a
207+
// frame whose caller already gave up.
208+
const qi = sendQueue.findIndex((fr) => fr.kind === 'invoke' && fr.id === id);
209+
if (qi >= 0) sendQueue.splice(qi, 1);
210+
console.warn(`[workspace] invoke deadline (${ms}ms): ${capability} (${id})`);
211+
reject(new Error(`${capability} timed out after ${Math.round(ms / 1000)}s — the workspace did not reply; retry the action`));
212+
}, ms);
213+
pending.set(id, {
214+
resolve: (v) => { clearTimeout(timer); resolve(v); },
215+
reject: (e) => { clearTimeout(timer); reject(e); },
216+
});
193217
});
194218
if (ws && ws.readyState === WebSocket.OPEN) {
195219
ws.send(JSON.stringify(frame));

services/workspace/src/ws.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ export async function registerWebsocket(app: FastifyInstance): Promise<void> {
201201
// hanging the caller forever.
202202
if (f.kind === 'invoke' && f.id && f.capability) {
203203
const invokeId = f.id;
204+
// Receipt log (gh #58 probe 2): "the frame never reached ws.ts" is
205+
// now fact, not inference — grep for invoke_received.
206+
app.log.info({ capability: f.capability, invoke_id: invokeId }, 'invoke_received');
204207
// Actor attribution envelope (build-order step 4) — the verified
205208
// didi.sh identity rides beside the args into dispatch(), never
206209
// client-asserted. See [[Workspaces-as-Tenant-Primitive]] §
@@ -223,11 +226,14 @@ export async function registerWebsocket(app: FastifyInstance): Promise<void> {
223226
if (socket.readyState === 1 /* OPEN */) {
224227
try {
225228
socket.send(resultFrame);
229+
app.log.info({ capability: f.capability, invoke_id: invokeId }, 'invoke_result_sent');
226230
} catch {
227231
stashResult(invokeId, resultFrame);
232+
app.log.warn({ capability: f.capability, invoke_id: invokeId }, 'invoke_result_stashed (send threw)');
228233
}
229234
} else {
230235
stashResult(invokeId, resultFrame);
236+
app.log.warn({ capability: f.capability, invoke_id: invokeId, readyState: socket.readyState }, 'invoke_result_stashed (socket not open)');
231237
}
232238
return;
233239
}

0 commit comments

Comments
 (0)