Skip to content

Commit 56191ce

Browse files
mpstatonclaude
andcommitted
fix(workspace, transport): invokes survive reconnects — the claim protocol
Twice in one evening a crawl finished server-side while the tab spun forever, both times severed by a routine workspace-service rebuild. Root cause: the transport rejected and CLEARED its pending invokes on every socket close, and the server had nowhere to put a result whose socket had died. Client: pending invokes now survive the drop — undelivered queued frames re-send on reconnect; delivered ones re-attach via a new claim frame per pending id, answered through the normal result path. Chat turns stay fail-fast. Server: every invoke's serialized result is tracked in-flight and stashed (15-min TTL) when its socket is gone; claims return the stash, attach to running work, or fail fast with an explicit "service restarted; retry" when the process lost the maps. The 660s client deadline stays as backstop; durable-across-restart results park with the liveness sweep (#21). Closes #41. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UvYzx7vDWeafnkAi2nEQeb
1 parent a62d3dc commit 56191ce

4 files changed

Lines changed: 189 additions & 22 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
title: "Invokes survive reconnects — the claim protocol closes the eternal-spinner root cause"
3+
lede: "Twice in one evening a crawl finished server-side while the tab spun forever — both times because a workspace-service rebuild severed the socket and the transport threw away its pending invokes. Pending invokes now survive the drop and re-attach by id: the server stashes results whose socket died, and reconnecting clients send claim frames to collect them."
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+
- Workspace
15+
- Transport
16+
- Reliability
17+
- Didi-Crawl
18+
status: Shipped
19+
date_first_published: 2026-07-24
20+
post_ship_note: "Shipped 2026-07-24 — successor to [[Crawl-Replies-Can-Be-Lost-Eternal-Spinner-No-Client-Timeout]]'s mitigations, landing the root cure the same evening after the Atlas Network crawl reproduced the loss. gh #41 closed."
21+
---
22+
23+
# The claim protocol
24+
25+
## Why (twice in one evening)
26+
27+
The Curry Foundation crawl completed in 87s server-side; the tab spun
28+
forever. Mitigations landed (client deadline, bigger dispatch ceiling) — and
29+
then the Atlas Network crawl reproduced the loss within the hour, this time
30+
severed by the very rebuild that shipped the mitigation. Container rebuilds
31+
are ROUTINE in this stack; pending invokes must survive them.
32+
33+
## The design
34+
35+
- **Client** (`packages/workspace/src/transport.ts`): on socket close,
36+
pending invokes are NOT rejected (previously: reject + clear — the root
37+
cause). Undelivered queued frames re-send on reconnect; delivered ones
38+
re-attach by sending a `claim` frame per pending id. The claim's answer is
39+
a normal result frame — same id, same resolution path. Chat turns stay
40+
fail-fast (cheap to resend).
41+
- **Server** (`services/workspace/src/ws.ts`): every invoke's serialized
42+
result is tracked in-flight; if the owning socket is gone when the result
43+
lands, it's stashed (15-min TTL). A `claim` returns the stash, attaches to
44+
still-running work, or — when the process restarted and knows nothing —
45+
replies with an explicit "workspace service restarted; retry" error so the
46+
caller fails fast instead of hanging.
47+
48+
## What this covers, and what it doesn't
49+
50+
Covered: network blips, browser sleep/wake, idle disconnects, and
51+
mid-dispatch workspace-service RESTARTS where the service comes back before
52+
the client gives up (in-flight work is lost with the process, but the claim
53+
gets an immediate explicit error). Not covered: durable results across
54+
restarts — the responder services (prompt-runner) reply over core NATS to a
55+
requestor that no longer exists; true durability needs persisted results or
56+
JetStream-style delivery, parked with the liveness sweep
57+
([[Live-Not-Live-Indicator-Tooling-And-Cross-Service-Error-Surfacing]]).
58+
The 660s client deadline from the mitigation pass stays as the backstop.
59+
60+
## Rider observation (operator, same session)
61+
62+
`services/workspace/src/ws.ts` is a poor name — terse, collides mentally
63+
with both "workspace" and the `ws` npm package. `frame-router.ts` or
64+
`websocket-router.ts` would say what it is. Not renamed mid-fix (drift
65+
policy); a candidate for the conventions/component cleanup (#22-adjacent).

packages/workspace/src/transport.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,21 @@ export function createTransport(config: TransportConfig): Transport {
9292
ws.addEventListener('open', () => {
9393
backoff = RECONNECT_INITIAL_MS;
9494
config.onStatus?.('open');
95+
// Frames still in the queue were never delivered — flush re-sends
96+
// them as ordinary invokes. Pending entries NOT in the queue were
97+
// delivered before a drop: re-attach to their (possibly finished)
98+
// server-side work with claim frames (gh #41). The server answers a
99+
// claim with the normal result frame — same id, same resolution path
100+
// — or an explicit not-found error if it restarted meanwhile.
101+
const queuedIds = new Set(
102+
sendQueue.filter((fr) => fr.kind === 'invoke').map((fr) => fr.id),
103+
);
95104
flushSendQueue();
105+
for (const id of pending.keys()) {
106+
if (!queuedIds.has(id)) {
107+
ws!.send(JSON.stringify({ kind: 'claim', id }));
108+
}
109+
}
96110
});
97111

98112
ws.addEventListener('message', (evt: MessageEvent) => {
@@ -137,17 +151,24 @@ export function createTransport(config: TransportConfig): Transport {
137151

138152
ws.addEventListener('close', () => {
139153
config.onStatus?.('closed');
140-
// Reject any in-flight invokes (already on the wire or still
141-
// queued) — callers will receive a clean 'socket closed' and can
142-
// retry. Clearing the sendQueue too keeps pending and queue in
143-
// lockstep; a stray queued frame surviving a reconnect would
144-
// produce a server reply that no longer has a pending entry to
145-
// resolve, wasting server work.
146-
for (const [, p] of pending) p.reject(new Error('socket closed'));
147-
pending.clear();
154+
// In-flight INVOKES survive the drop (gh #41): their pending entries
155+
// stay put and the next 'open' re-attaches via claim frames — the
156+
// server holds results for invokes whose socket died. Long-running
157+
// work (didi crawls run minutes) no longer strands an eternal
158+
// spinner because a container rebuild or network blip severed the
159+
// socket. Chat turns stay fail-fast: cheap to resend, and the rail
160+
// shows the error inline.
161+
if (closing) {
162+
for (const [, p] of pending) p.reject(new Error('socket closed'));
163+
pending.clear();
164+
}
148165
for (const [, p] of chatPending) p.reject(new Error('socket closed'));
149166
chatPending.clear();
167+
// Keep undelivered invoke frames for re-send on reconnect; drop
168+
// queued chat frames (their pending entries were just rejected).
169+
const keep = sendQueue.filter((fr) => fr.kind === 'invoke' && pending.has(fr.id));
150170
sendQueue.length = 0;
171+
sendQueue.push(...keep);
151172
if (!closing) scheduleReconnect();
152173
});
153174

packages/workspace/src/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,13 @@ export type ChatErrorFrame = {
309309
error: string;
310310
};
311311

312+
// Re-attach to an invoke after a reconnect (gh #41) — the server replies
313+
// with the normal ResultFrame for that id (possibly stashed from before the
314+
// drop), or ok:false when it restarted and no longer knows the invoke.
315+
export type ClaimFrame = { kind: 'claim'; id: string };
316+
312317
export type ServerFrame = ResultFrame | EventFrame | SessionFrame | ChatResponseFrame | ChatErrorFrame;
313-
export type ClientFrame = InvokeFrame | ChatTurnFrame;
318+
export type ClientFrame = InvokeFrame | ChatTurnFrame | ClaimFrame;
314319

315320
// --- request-reviewer / response-reviewer surfaces ---
316321
// See context-v/specs/Request-Reviewer-Pre-Flight-Surface.md and

services/workspace/src/ws.ts

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,27 @@ import { dispatch } from './capabilities';
1919
import { dispatchChatTurn } from './chat';
2020
import { getNats } from './nats';
2121

22+
// Invoke durability across reconnects (gh #41). Results for invokes whose
23+
// socket died before delivery are stashed here, keyed by invoke id, and
24+
// handed over when the reconnected client sends a `claim` frame. In-flight
25+
// dispatches are tracked so a claim can attach to work still running.
26+
// Process-local by design — a workspace restart loses both maps, and the
27+
// claim then fails fast with an explicit retry message instead of hanging.
28+
const inflightInvokes = new Map<string, Promise<string>>();
29+
const completedInvokes = new Map<string, { frame: string; expires: number }>();
30+
const INVOKE_RESULT_TTL_MS = 15 * 60_000;
31+
32+
function stashResult(id: string, frame: string): void {
33+
completedInvokes.set(id, { frame, expires: Date.now() + INVOKE_RESULT_TTL_MS });
34+
}
35+
36+
setInterval(() => {
37+
const now = Date.now();
38+
for (const [id, entry] of completedInvokes) {
39+
if (entry.expires < now) completedInvokes.delete(id);
40+
}
41+
}, 60_000).unref();
42+
2243
const BROADCAST_SUBJECTS = [
2344
'record_set.created',
2445
'record_set.deleted',
@@ -168,21 +189,76 @@ export async function registerWebsocket(app: FastifyInstance): Promise<void> {
168189
};
169190

170191
// --- invoke frame: existing capability dispatch path. ---
192+
// Long dispatches (didi crawls run minutes) must survive the caller's
193+
// socket dropping mid-flight: the serialized result frame is tracked
194+
// in-flight and, if the socket is gone when it lands, stashed for a
195+
// post-reconnect `claim` frame (gh #41). A workspace restart still
196+
// loses both maps — the claim then fails fast and explicit instead of
197+
// hanging the caller forever.
171198
if (f.kind === 'invoke' && f.id && f.capability) {
172-
try {
173-
// Actor attribution envelope (build-order step 4) — the verified
174-
// didi.sh identity rides beside the args into dispatch(), never
175-
// client-asserted. See [[Workspaces-as-Tenant-Primitive]] §
176-
// "Tenant-aware envelope" for the sibling client_id pattern.
177-
const actor = session.didi
178-
? { didi_id: session.didi.didi_id, ...(f.via ? { via: f.via } : {}) }
179-
: undefined;
180-
const result = await dispatch(f.capability, f.args ?? {}, actor);
181-
socket.send(JSON.stringify({ kind: 'result', id: f.id, ok: true, result }));
182-
} catch (err: unknown) {
183-
const error = err instanceof Error ? err.message : String(err);
184-
socket.send(JSON.stringify({ kind: 'result', id: f.id, ok: false, error }));
199+
const invokeId = f.id;
200+
// Actor attribution envelope (build-order step 4) — the verified
201+
// didi.sh identity rides beside the args into dispatch(), never
202+
// client-asserted. See [[Workspaces-as-Tenant-Primitive]] §
203+
// "Tenant-aware envelope" for the sibling client_id pattern.
204+
const actor = session.didi
205+
? { didi_id: session.didi.didi_id, ...(f.via ? { via: f.via } : {}) }
206+
: undefined;
207+
const resultPromise = (async () => {
208+
try {
209+
const result = await dispatch(f.capability as string, f.args ?? {}, actor);
210+
return JSON.stringify({ kind: 'result', id: invokeId, ok: true, result });
211+
} catch (err: unknown) {
212+
const error = err instanceof Error ? err.message : String(err);
213+
return JSON.stringify({ kind: 'result', id: invokeId, ok: false, error });
214+
}
215+
})();
216+
inflightInvokes.set(invokeId, resultPromise);
217+
const resultFrame = await resultPromise;
218+
inflightInvokes.delete(invokeId);
219+
if (socket.readyState === 1 /* OPEN */) {
220+
try {
221+
socket.send(resultFrame);
222+
} catch {
223+
stashResult(invokeId, resultFrame);
224+
}
225+
} else {
226+
stashResult(invokeId, resultFrame);
227+
}
228+
return;
229+
}
230+
231+
// --- claim frame: re-attach to an invoke after a reconnect. ---
232+
if (f.kind === 'claim' && f.id) {
233+
const claimId = f.id;
234+
const done = completedInvokes.get(claimId);
235+
if (done) {
236+
completedInvokes.delete(claimId);
237+
socket.send(done.frame);
238+
return;
239+
}
240+
const inflight = inflightInvokes.get(claimId);
241+
if (inflight) {
242+
const resultFrame = await inflight;
243+
// The original waiter may have stashed it between our lookup and
244+
// resolution — drop any duplicate stash and deliver here.
245+
completedInvokes.delete(claimId);
246+
try {
247+
socket.send(resultFrame);
248+
} catch {
249+
stashResult(claimId, resultFrame);
250+
}
251+
return;
185252
}
253+
socket.send(
254+
JSON.stringify({
255+
kind: 'result',
256+
id: claimId,
257+
ok: false,
258+
error:
259+
'invoke not found — the workspace service restarted while it was in flight; retry the action',
260+
}),
261+
);
186262
return;
187263
}
188264

0 commit comments

Comments
 (0)