Skip to content

Commit 4936b05

Browse files
willwashburnclaude
andcommitted
fix(sdk): address PR #936 review feedback
EventBus.addListener — stale-closure bug (CodeRabbit + Codex P1): the unsubscribe returned by `addListener` captured `set` by reference. After unsubscribing, registering a new handler for the same event, and calling the original `off` again, the closure would observe the stale (empty) `Set` and call `this.handlers.delete(event)` — silently dropping every fresh listener for that event. Fix: re-read the current Set from `this.handlers` inside the closure and bail out if it isn't the one we originally inserted. `removeListener` already had this shape; bringing `addListener`'s unsubscribe in line. Regression test added — covers off()→addListener→off() → new listener must still be called on emit. spawn-reviewers script — `agentExited` handler had a stray `code?: number` second parameter that's always undefined under the new single-arg listener contract. Read `agent.exitCode` from the Agent payload instead, matching the pattern in `packages/openclaw/src/spawn/process.ts`. CHANGELOG — split the single long `@agent-relay/sdk` breaking-change bullet into three impact-first bullets (listener API migration, channel-event payload shape change, new lifecycle hooks) per the repo's changelog-style rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d67c9af commit 4936b05

4 files changed

Lines changed: 30 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Breaking Changes
1111

12-
- `@agent-relay/sdk` `AgentRelay` now exposes a typed multi-listener registry — `addListener(event, handler) → unsubscribe` / `removeListener(event, handler)` — replacing the 13 single-callback `on*` fields (`onMessageReceived`, `onMessageSent`, `onAgentSpawned`, `onAgentReleased`, `onAgentExited`, `onAgentReady`, `onWorkerOutput`, `onDeliveryUpdate`, `onAgentExitRequested`, `onAgentIdle`, `onAgentActivityChanged`, `onChannelSubscribed`, `onChannelUnsubscribed`). Channel subscribe / unsubscribe handler args change from `(agent, channels)` to a single `{ agent, channels }` object payload. Migration: `relay.onX = handler;``relay.addListener('x', handler);`. New `beforeAgentSpawn` / `afterAgentSpawn` / `beforeAgentRelease` / `afterAgentRelease` call-site hooks fire at the SDK boundary; `beforeAgentSpawn` listeners can return a `SpawnPatch` to mutate the spawn input before POST (e.g. for burn-style session-id preallocation).
12+
- `@agent-relay/sdk`: `AgentRelay` events move to a multi-listener registry. Use `relay.addListener('x', handler)` / `removeListener` in place of `relay.onX = handler` — the 13 `on*` fields (`onMessageReceived`, `onMessageSent`, `onAgentSpawned`, `onAgentReleased`, `onAgentExited`, `onAgentReady`, `onWorkerOutput`, `onDeliveryUpdate`, `onAgentExitRequested`, `onAgentIdle`, `onAgentActivityChanged`, `onChannelSubscribed`, `onChannelUnsubscribed`) are removed.
13+
- `@agent-relay/sdk`: `channelSubscribed` / `channelUnsubscribed` handlers receive a single `{ agent, channels }` object instead of positional `(agent, channels)` args.
14+
- `@agent-relay/sdk`: new `beforeAgentSpawn` / `afterAgentSpawn` / `beforeAgentRelease` / `afterAgentRelease` call-site hooks. `beforeAgentSpawn` listeners may return a `SpawnPatch` (shallow-merged in registration order) to mutate the spawn input before the broker POST.
1315
- Broker/SDK wire protocol is now version 2 for delivery terminal events and lifecycle event shape changes.
1416
- `relay.spawn({ task })` now returns `success: false` and terminates the agent when task delivery fails after retries.
1517
- `agent-relay send` now uses the orchestrator identity by default so `agent-relay replies <worker>` can correlate worker DMs.

packages/sdk/src/__tests__/event-bus.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,22 @@ describe('EventBus', () => {
5252
expect(bus.listenerCount('ping')).toBe(0);
5353
});
5454

55+
it('returned unsubscribe is idempotent and does not clobber later registrations', async () => {
56+
const bus = new EventBus<Events>();
57+
const h1 = vi.fn();
58+
const h2 = vi.fn();
59+
const off = bus.addListener('ping', h1);
60+
off();
61+
bus.addListener('ping', h2);
62+
// The second call to the original unsubscribe must NOT wipe the
63+
// freshly-registered h2 — a stale-closure bug would do exactly that.
64+
off();
65+
expect(bus.listenerCount('ping')).toBe(1);
66+
await bus.emit('ping', { id: 1 });
67+
expect(h1).not.toHaveBeenCalled();
68+
expect(h2).toHaveBeenCalledTimes(1);
69+
});
70+
5571
it('drops the event-name entry when the last listener is removed', () => {
5672
const bus = new EventBus<Events>();
5773
const a = vi.fn();

packages/sdk/src/event-bus.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,15 @@ export class EventBus<E extends EventMap> {
3535
}
3636
set.add(handler as EventHandler<readonly unknown[]>);
3737
return () => {
38-
set!.delete(handler as EventHandler<readonly unknown[]>);
39-
if (set!.size === 0) {
38+
// Re-read the current Set from the map so a stale closure can't blow
39+
// away the new Set if the caller unsubscribes, re-registers a fresh
40+
// handler under the same event, then calls the original `off` again.
41+
// Without this identity check the double-`off()` would silently drop
42+
// every listener for the event.
43+
const current = this.handlers.get(event);
44+
if (current !== set) return;
45+
current.delete(handler as EventHandler<readonly unknown[]>);
46+
if (current.size === 0) {
4047
this.handlers.delete(event);
4148
}
4249
};

scripts/spawn-reviewers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ async function main() {
2020
relay.addListener('agentSpawned', (agent: Agent) => {
2121
console.log(`[spawned] ${agent.name}`);
2222
});
23-
relay.addListener('agentExited', (agent: Agent, code?: number) => {
24-
console.log(`[exited] ${agent.name} code=${code}`);
23+
relay.addListener('agentExited', (agent: Agent) => {
24+
console.log(`[exited] ${agent.name} code=${agent.exitCode ?? 'none'}`);
2525
});
2626

2727
const human = relay.human({ name: 'Human' });

0 commit comments

Comments
 (0)