Skip to content

Commit e616f38

Browse files
longfinclaude
andauthored
fix(server): fail in-flight bindings on same-token reconnect (#365) (#386)
* fix(server): fail in-flight bindings on same-token reconnect (#365) When a daemon reconnects with the same CLIENT_TOKEN, `registerAgent` closes the old WS and immediately swaps the `agents` map to the new connection. The old socket's `close` handler eventually calls `unregisterAgent(agentId, oldWs)`, but by then the map points at the new conn, so the ws-identity guard (`existing.ws !== ws`) makes it a no-op — and the displaced connection's in-flight task bindings are never failed. Those tasks' HTTP streams hang forever, since the bound sink's `finish()` (which closes `AsyncEventQueue.iterate()`) is never called, and the new daemon process never knew the old taskId so it can't complete them either. Extract the binding-fail loop from `unregisterAgent` into a reusable `failBindingsForAgent` helper and invoke it from the reconnect replacement branch *before* the map swap (while the bindings still belong to the old conn). Superseded tasks now receive a terminal `failed` status with a `superseded` error code; the normal disconnect path keeps its existing `disconnected` code and message verbatim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(server): strengthen #365 reconnect test (multi-binding + messageId) Per code review: assert the per-path messageId suffix (`-superseded`) that failBindingsForAgent wires through, and cover the multiple-in-flight-binding case so a future early-return in the fail loop regresses loudly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5988023 commit e616f38

2 files changed

Lines changed: 142 additions & 4 deletions

File tree

packages/server/src/registry.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,111 @@ test('registerAgent emits client_collision and closes the prior ws with the desc
401401
assert.equal(parsed.previousConnectedAt, 1000);
402402
});
403403

404+
test('same-token reconnect fails the displaced connection in-flight bindings (issue #365)', () => {
405+
// A second daemon authenticating with the same CLIENT_TOKEN replaces the
406+
// incumbent. The old connection's in-flight task must receive a terminal
407+
// `failed` status and have its sink finished + binding dropped — otherwise
408+
// the task's HTTP stream hangs forever (the new daemon is a separate process
409+
// that never knew the old taskId, so it can't complete it either).
410+
const registry = new Registry();
411+
const oldWs = makeWs();
412+
const base = {
413+
agentId: 'a1',
414+
clientId: 'c1',
415+
ownerPrincipal: 'eth:0x0',
416+
agentCard: makeCard(false),
417+
allowedCallers: [],
418+
connectedAt: 0,
419+
};
420+
registry.registerAgent({ ...base, ws: oldWs });
421+
422+
// Two in-flight tasks on the displaced connection — both must be failed, so
423+
// a future reintroduction of an early-return in the loop regresses loudly.
424+
const statuses: Array<{
425+
status: { state?: string; message?: { messageId?: string; metadata?: unknown } };
426+
}> = [];
427+
const finished = new Set<string>();
428+
for (const taskId of ['t-live', 't-live-2']) {
429+
registry.bindTask({
430+
agentId: 'a1',
431+
taskId,
432+
contextId: `ctx-${taskId}`,
433+
requestedExtensions: [OPENAI_COMPAT_EXTENSION_URI],
434+
sink: {
435+
pushStatus: (event) => statuses.push(event),
436+
pushArtifact: () => undefined,
437+
finish: () => {
438+
finished.add(taskId);
439+
},
440+
},
441+
});
442+
}
443+
444+
// Same token (clientId) reconnects on a fresh socket → replacement branch.
445+
registry.registerAgent({ ...base, ws: makeWs(), connectedAt: 1 });
446+
447+
assert.deepEqual([...finished].sort(), ['t-live', 't-live-2'], 'every displaced sink must be finished');
448+
assert.equal(registry.getBinding('t-live'), undefined, 'binding must be dropped');
449+
assert.equal(registry.getBinding('t-live-2'), undefined, 'binding must be dropped');
450+
assert.equal(statuses[0]?.status.state, 'failed');
451+
// messageId carries the per-path suffix wired through failBindingsForAgent —
452+
// the disconnect path uses `-disc`, this reconnect path uses `-superseded`.
453+
assert.equal(statuses[0]?.status.message?.messageId, 't-live-superseded');
454+
assert.deepEqual(statuses[0]?.status.message?.metadata, {
455+
[OPENAI_COMPAT_EXTENSION_URI]: {
456+
terminal_error: {
457+
code: 'superseded',
458+
message: 'superseded by a reconnect from the same client token',
459+
},
460+
},
461+
error: {
462+
code: 'superseded',
463+
message: 'superseded by a reconnect from the same client token',
464+
},
465+
});
466+
});
467+
468+
test('a late close from the superseded socket does not double-fail the new connection bindings (issue #365)', () => {
469+
// After the reconnect path has already terminated the old connection's
470+
// bindings, the old socket's close handler eventually fires
471+
// unregisterAgent(agentId, oldWs). The ws-identity guard must make that a
472+
// no-op so it can't touch a task the *new* connection has since bound.
473+
const registry = new Registry();
474+
const oldWs = makeWs();
475+
const base = {
476+
agentId: 'a1',
477+
clientId: 'c1',
478+
ownerPrincipal: 'eth:0x0',
479+
agentCard: makeCard(false),
480+
allowedCallers: [],
481+
connectedAt: 0,
482+
};
483+
registry.registerAgent({ ...base, ws: oldWs });
484+
const newWs = makeWs();
485+
registry.registerAgent({ ...base, ws: newWs, connectedAt: 1 });
486+
487+
// New connection binds a fresh task after the swap.
488+
let finished = false;
489+
registry.bindTask({
490+
agentId: 'a1',
491+
taskId: 't-new',
492+
contextId: 'ctx-new',
493+
sink: {
494+
pushStatus: () => undefined,
495+
pushArtifact: () => undefined,
496+
finish: () => {
497+
finished = true;
498+
},
499+
},
500+
});
501+
502+
// Late close from the displaced socket — must not disturb t-new.
503+
registry.unregisterAgent('a1', oldWs);
504+
505+
assert.equal(finished, false, 'new connection binding must survive a stale unregister');
506+
assert.ok(registry.getBinding('t-new'), 'new binding must still be present');
507+
});
508+
404509
test('disconnectClient returns 0 when no agents are bound to the client', () => {
405510
const registry = new Registry();
406511
registry.registerAgent({

packages/server/src/registry.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ export class Registry {
8888
// the foreground log can recognize the duplicate-token scenario
8989
// without cross-referencing close codes.
9090
existing.ws.close(4009, 'another client with the same token connected');
91+
// Fail the displaced connection's in-flight tasks *before* swapping the
92+
// map. The old socket's close fires asynchronously and, by the time its
93+
// unregisterAgent runs, the map already points at `conn` — so the
94+
// ws-identity guard makes it a no-op and it can't clean these up. Doing
95+
// it here (while the bindings still belong to the old conn) is the only
96+
// point that sees them. The new conn hasn't bound any tasks yet, so
97+
// this only terminates the superseded ones.
98+
this.failBindingsForAgent(conn.agentId, {
99+
code: 'superseded',
100+
message: 'superseded by a reconnect from the same client token',
101+
messageIdSuffix: 'superseded',
102+
});
91103
this.agents.set(conn.agentId, conn);
92104
this.notifyAgentChange(conn.agentId);
93105
return { ok: true };
@@ -139,6 +151,27 @@ export class Registry {
139151
if (!existing || existing.ws !== ws) return;
140152
this.agents.delete(agentId);
141153
this.notifyAgentChange(agentId);
154+
this.failBindingsForAgent(agentId, {
155+
code: 'disconnected',
156+
message: 'client disconnected mid-task',
157+
messageIdSuffix: 'disc',
158+
});
159+
}
160+
161+
// Push a terminal `failed` status to every in-flight task bound to this
162+
// agent, finish its sink, and drop the binding. Both the normal disconnect
163+
// (unregisterAgent) and the same-token reconnect replacement (registerAgent)
164+
// funnel through here so an in-flight task is never left without a terminal
165+
// event — the HTTP stream for that task would otherwise hang forever waiting
166+
// on AsyncEventQueue.iterate(), since the bound sink's finish() is what
167+
// closes the iterator. The reconnect path in particular MUST call this
168+
// explicitly before swapping the agents map: once the map points at the new
169+
// conn, the old socket's late close handler hits the ws-identity guard in
170+
// unregisterAgent and early-returns, so it can no longer fail these bindings.
171+
private failBindingsForAgent(
172+
agentId: string,
173+
error: { code: string; message: string; messageIdSuffix: string },
174+
): void {
142175
for (const binding of [...this.bindings.values()]) {
143176
if (binding.agentId !== agentId) continue;
144177
binding.sink.pushStatus({
@@ -154,13 +187,13 @@ export class Registry {
154187
state: 'failed' as never,
155188
timestamp: new Date().toISOString(),
156189
message: {
157-
messageId: `${binding.taskId}-disc`,
190+
messageId: `${binding.taskId}-${error.messageIdSuffix}`,
158191
role: 'agent',
159-
parts: [{ text: 'client disconnected mid-task' }],
192+
parts: [{ text: error.message }],
160193
...terminalErrorMessageFields(
161194
{
162-
code: 'disconnected',
163-
message: 'client disconnected mid-task',
195+
code: error.code,
196+
message: error.message,
164197
},
165198
binding.requestedExtensions,
166199
),

0 commit comments

Comments
 (0)