Skip to content

Commit fccaadf

Browse files
gavrielcclaude
andcommitted
fix(channels,db): exact instance dispatch, FK-check scoping, migration-safe skill snippets
Review-round fixes on the instance dimension: - delivery/typing resolve adapters by exact registry key, never the channelType fallback — a named instance with an offline adapter gets offline handling, not a cross-identity send through a sibling bot; the fallback scan (channelType-only callers) now warns when it resolves through a differently-keyed instance - migration runner only fails on FK violations a migration introduced: pre-existing latent orphans (FK-OFF CLI surgery) are logged and carried, not turned into a boot crash-loop - typing re-trigger updates the full address (channelType, platformId, threadId, instance) together — no torn entries on agent-shared sessions spanning instances - bridge rejects empty/whitespace instance names (URL-route and state-namespace safety) - add-github / add-linear SKILL.md wiring inserts include the NOT NULL instance column - drop the 10s same-platform boot stagger: operational policy, not substrate — reintroducible skill-side for gateway-mode installs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1c024bc commit fccaadf

12 files changed

Lines changed: 287 additions & 125 deletions

File tree

.claude/skills/add-github/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
111111

112112
```sql
113113
-- Create messaging group (one per repo)
114-
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
115-
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
114+
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
115+
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
116116

117117
-- Wire to agent group
118118
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)

.claude/skills/add-linear/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
119119

120120
```sql
121121
-- Create messaging group (one per team)
122-
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
123-
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
122+
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
123+
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
124124

125125
-- Wire to agent group
126126
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)

src/channels/channel-registry.test.ts

Lines changed: 47 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,9 @@ describe('channel registry — instance keying', () => {
133133
afterEach(async () => {
134134
const { teardownChannelAdapters } = await import('./channel-registry.js');
135135
await teardownChannelAdapters();
136-
vi.useRealTimers();
137136
// Drop this test's registrations so later describe blocks (which import
138137
// the registry without resetting) start from an empty registry instead
139-
// of inheriting same-channelType pairs (and their 10s stagger).
138+
// of inheriting same-channelType pairs.
140139
vi.resetModules();
141140
});
142141

@@ -154,11 +153,7 @@ describe('channel registry — instance keying', () => {
154153
reg.registerChannelAdapter('slack-worker', { factory: () => worker });
155154
reg.registerChannelAdapter('slack-tester', { factory: () => tester });
156155

157-
vi.useFakeTimers();
158-
const init = reg.initChannelAdapters(mockSetup);
159-
await vi.runAllTimersAsync();
160-
await init;
161-
vi.useRealTimers();
156+
await reg.initChannelAdapters(mockSetup);
162157

163158
expect(reg.getChannelAdapter('slack-worker')).toBe(worker);
164159
expect(reg.getChannelAdapter('slack-tester')).toBe(tester);
@@ -172,11 +167,7 @@ describe('channel registry — instance keying', () => {
172167
reg.registerChannelAdapter('slack-tester', { factory: () => named });
173168
reg.registerChannelAdapter('slack', { factory: () => unnamed });
174169

175-
vi.useFakeTimers();
176-
const init = reg.initChannelAdapters(mockSetup);
177-
await vi.runAllTimersAsync();
178-
await init;
179-
vi.useRealTimers();
170+
await reg.initChannelAdapters(mockSetup);
180171

181172
// Exact key (default instance keyed by channelType) beats the fallback
182173
// scan, even though the named sibling registered first.
@@ -191,44 +182,54 @@ describe('channel registry — instance keying', () => {
191182
const second = createMockAdapter('slack', 'slack-worker');
192183
reg2.registerChannelAdapter('slack-tester', { factory: () => first });
193184
reg2.registerChannelAdapter('slack-worker', { factory: () => second });
194-
vi.useFakeTimers();
195-
const init2 = reg2.initChannelAdapters(mockSetup);
196-
await vi.runAllTimersAsync();
197-
await init2;
198-
vi.useRealTimers();
185+
await reg2.initChannelAdapters(mockSetup);
199186
expect(reg2.getChannelAdapter('slack')).toBe(first);
200187
});
201188

202-
it('staggers the second same-channelType adapter setup by 10s; different platforms unstaggered', async () => {
203-
vi.useFakeTimers();
189+
it('does NOT reroute default-instance outbound through a named sibling when the default adapter is missing', async () => {
190+
// The default Slack app is offline (token rotated, factory returned
191+
// null, …) while a named sibling boots fine. Outbound for the default
192+
// instance must get the offline-adapter handling (drop into the retry
193+
// path) — NEVER a cross-identity send through the sibling bot.
204194
const reg = await import('./channel-registry.js');
205-
const a = createMockAdapter('slack', 'slack-a');
206-
const b = createMockAdapter('slack', 'slack-b');
207-
const c = createMockAdapter('discord');
208-
reg.registerChannelAdapter('slack-a', { factory: () => a });
209-
reg.registerChannelAdapter('slack-b', { factory: () => b });
210-
reg.registerChannelAdapter('discord', { factory: () => c });
211-
212-
const init = reg.initChannelAdapters(mockSetup);
213-
214-
// Flush microtasks without advancing the clock: a sets up, b is held.
215-
await vi.advanceTimersByTimeAsync(0);
216-
expect(a.setupTimes).toHaveLength(1);
217-
expect(b.setupTimes).toHaveLength(0);
218-
expect(c.setupTimes).toHaveLength(0);
219-
220-
// Not yet at the stagger window.
221-
await vi.advanceTimersByTimeAsync(9_000);
222-
expect(b.setupTimes).toHaveLength(0);
223-
224-
// Crossing 10s releases b; discord (different platform) follows with
225-
// no additional stagger of its own.
226-
await vi.advanceTimersByTimeAsync(1_000);
227-
expect(b.setupTimes).toHaveLength(1);
228-
await vi.advanceTimersByTimeAsync(0);
229-
expect(c.setupTimes).toHaveLength(1);
230-
231-
await init;
195+
const tester = createMockAdapter('slack', 'slack-tester');
196+
reg.registerChannelAdapter('slack-tester', { factory: () => tester });
197+
reg.registerChannelAdapter('slack', { factory: () => null });
198+
199+
await reg.initChannelAdapters(mockSetup);
200+
201+
// Exact lookup (delivery/typing path): the default key resolves nothing.
202+
expect(reg.getChannelAdapterExact('slack')).toBeUndefined();
203+
// Fallback-capable lookup (channelType-only callers) still resolves.
204+
expect(reg.getChannelAdapter('slack')).toBe(tester);
205+
206+
// The delivery bridge dispatches by exact key: a default-instance
207+
// message (instance === channelType after backfill) is dropped, not
208+
// delivered through the sibling's identity.
209+
const bridge = reg.createChannelDeliveryAdapter();
210+
const result = await bridge.deliver(
211+
'slack',
212+
'slack:C1',
213+
null,
214+
'chat',
215+
JSON.stringify({ text: 'to the default bot' }),
216+
undefined,
217+
'slack',
218+
);
219+
expect(result).toBeUndefined();
220+
expect(tester.delivered).toHaveLength(0);
221+
222+
// Sanity: the same bridge DOES deliver when the exact instance is live.
223+
await bridge.deliver(
224+
'slack',
225+
'slack:C1',
226+
null,
227+
'chat',
228+
JSON.stringify({ text: 'to the tester bot' }),
229+
undefined,
230+
'slack-tester',
231+
);
232+
expect(tester.delivered).toHaveLength(1);
232233
});
233234
});
234235

src/channels/channel-registry.ts

Lines changed: 69 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* Channels self-register on import. The host calls initChannelAdapters() at startup
55
* to instantiate and set up all registered adapters.
66
*/
7-
import type { ChannelAdapter, ChannelRegistration, ChannelSetup } from './adapter.js';
7+
import type { ChannelAdapter, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
8+
import type { ChannelDeliveryAdapter } from '../delivery.js';
89
import { log } from '../log.js';
910

1011
const SETUP_RETRY_DELAYS_MS = [2000, 5000, 10000];
@@ -18,14 +19,6 @@ function isNetworkError(err: unknown): err is Error {
1819

1920
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
2021

21-
/**
22-
* Two gateway instances of one platform (e.g. two Discord bots) identifying
23-
* simultaneously from one IP trip platform rate limits at boot. Stagger the
24-
* second (and later) same-platform adapter's setup. Inert for installs with
25-
* one adapter per platform — no two registrations share a channelType.
26-
*/
27-
const SAME_CHANNEL_SETUP_STAGGER_MS = 10_000;
28-
2922
const registry = new Map<string, ChannelRegistration>();
3023
const activeAdapters = new Map<string, ChannelAdapter>();
3124

@@ -34,22 +27,81 @@ export function registerChannelAdapter(name: string, registration: ChannelRegist
3427
registry.set(name, registration);
3528
}
3629

30+
/** Get a live adapter by its EXACT registry key (instance name; default
31+
* instances are keyed by channelType itself). No channelType fallback —
32+
* callers that address a specific instance (outbound delivery, typing)
33+
* must never be rerouted through a sibling instance: that would send
34+
* through the wrong bot identity with the wrong token. A missing key
35+
* means the owning adapter is offline; callers apply their normal
36+
* offline-adapter handling. */
37+
export function getChannelAdapterExact(key: string): ChannelAdapter | undefined {
38+
return activeAdapters.get(key);
39+
}
40+
3741
/** Get a live adapter by instance name, falling back to any adapter of the
38-
* given channel type. channelType-only callers (user-id prefix resolution
39-
* and cold DMs in user-dm.ts, approval delivery in channel-approval.ts)
40-
* must still resolve when every instance of a platform is named — first
41-
* registered wins (Map insertion order, deterministic). Default instances
42-
* are keyed by channelType itself, so single-instance installs always hit
43-
* the exact-key path. */
42+
* given channel type. The fallback exists ONLY for channelType-only callers
43+
* (user-id prefix resolution and cold DMs in user-dm.ts, approval delivery
44+
* in channel-approval.ts, the router's thread-policy probe when an event
45+
* carries no instance) — they must still resolve when every instance of a
46+
* platform is named. First registered wins (Map insertion order,
47+
* deterministic). Default instances are keyed by channelType itself, so
48+
* single-instance installs always hit the exact-key path. Instance-addressed
49+
* dispatch (delivery, typing) must use getChannelAdapterExact instead. */
4450
export function getChannelAdapter(key: string): ChannelAdapter | undefined {
4551
const exact = activeAdapters.get(key);
4652
if (exact) return exact;
47-
for (const adapter of activeAdapters.values()) {
48-
if (adapter.channelType === key) return adapter;
53+
for (const [registryKey, adapter] of activeAdapters) {
54+
if (adapter.channelType === key) {
55+
log.warn('Channel adapter fallback: requested key resolved through a differently-keyed instance', {
56+
requested: key,
57+
resolvedKey: registryKey,
58+
});
59+
return adapter;
60+
}
4961
}
5062
return undefined;
5163
}
5264

65+
/**
66+
* Build the host's outbound delivery bridge: dispatches delivery-poll and
67+
* typing traffic into the adapter registry. Resolution is EXACT-key only —
68+
* `instance ?? channelType`. For default-instance messaging_groups rows the
69+
* stored instance IS the channelType, which matches default-registered
70+
* adapters, so single-instance behavior is unchanged. A named instance whose
71+
* adapter is offline gets the normal offline-adapter handling (warn + drop
72+
* into the delivery retry path) — never a cross-identity send through a
73+
* sibling bot of the same platform.
74+
*/
75+
export function createChannelDeliveryAdapter(): ChannelDeliveryAdapter {
76+
return {
77+
async deliver(
78+
channelType: string,
79+
platformId: string,
80+
threadId: string | null,
81+
kind: string,
82+
content: string,
83+
files?: OutboundFile[],
84+
instance?: string,
85+
): Promise<string | undefined> {
86+
const adapter = getChannelAdapterExact(instance ?? channelType);
87+
if (!adapter) {
88+
log.warn('No adapter for channel type', { channelType, instance });
89+
return;
90+
}
91+
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
92+
},
93+
async setTyping(
94+
channelType: string,
95+
platformId: string,
96+
threadId: string | null,
97+
instance?: string,
98+
): Promise<void> {
99+
const adapter = getChannelAdapterExact(instance ?? channelType);
100+
await adapter?.setTyping?.(platformId, threadId);
101+
},
102+
};
103+
}
104+
53105
/** Get all active adapters. */
54106
export function getActiveAdapters(): ChannelAdapter[] {
55107
return [...activeAdapters.values()];
@@ -70,7 +122,6 @@ export function getChannelContainerConfig(name: string): ChannelRegistration['co
70122
* Skips adapters that return null (missing credentials).
71123
*/
72124
export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) => ChannelSetup): Promise<void> {
73-
const activeChannelTypes = new Set<string>();
74125
for (const [name, registration] of registry) {
75126
try {
76127
const adapter = await registration.factory();
@@ -79,17 +130,6 @@ export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) =>
79130
continue;
80131
}
81132

82-
// Same-platform stagger: a second instance of an already-active
83-
// platform waits before identifying (gateway logins from one IP).
84-
if (activeChannelTypes.has(adapter.channelType)) {
85-
log.info('Staggering same-platform adapter setup', {
86-
channel: name,
87-
type: adapter.channelType,
88-
delayMs: SAME_CHANNEL_SETUP_STAGGER_MS,
89-
});
90-
await sleep(SAME_CHANNEL_SETUP_STAGGER_MS);
91-
}
92-
93133
const setup = setupFn(adapter);
94134
// Transient network failures during adapter init (e.g. Telegram deleteWebhook
95135
// hitting a DNS hiccup at boot) would otherwise leave the channel permanently
@@ -125,7 +165,6 @@ export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) =>
125165
log.warn('Duplicate adapter instance key — overwriting previous adapter', { key, channel: name });
126166
}
127167
activeAdapters.set(key, adapter);
128-
activeChannelTypes.add(adapter.channelType);
129168
log.info('Channel adapter started', { channel: name, type: adapter.channelType, instance: key });
130169
} catch (err) {
131170
log.error('Failed to start channel adapter', { channel: name, err });

src/channels/chat-sdk-bridge.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,18 @@ describe('createChatSdkBridge — instance identity', () => {
126126
).toThrow(/URL-safe/);
127127
}
128128
});
129+
130+
it('rejects empty and whitespace-only instance names (config bug — fail loud)', () => {
131+
// '' is falsy: a truthiness guard would skip it, dead-ending the
132+
// webhook route ('/webhook/' + '') and collapsing the state namespace
133+
// into the default instance's unprefixed keyspace — the exact
134+
// cross-bot dedupe/lock collisions the namespace exists to prevent.
135+
for (const bad of ['', ' ', ' ', '\t']) {
136+
expect(() =>
137+
createChatSdkBridge({ adapter: stubAdapter({ name: 'slack' }), instance: bad, supportsThreads: true }),
138+
).toThrow(/URL-safe/);
139+
}
140+
});
129141
});
130142

131143
describe('createChatSdkBridge.setup — webhook route and state namespace', () => {
@@ -137,8 +149,7 @@ describe('createChatSdkBridge.setup — webhook route and state namespace', () =
137149
return stubAdapter({
138150
name: 'slack',
139151
initialize: async () => {},
140-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
141-
} as unknown as Partial<Adapter>) as any;
152+
} as unknown as Partial<Adapter>);
142153
}
143154

144155
beforeEach(async () => {

src/channels/chat-sdk-bridge.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export interface ChatSdkBridgeConfig {
5353
* name. Drives the registry key, the webhook route (/webhook/<instance>),
5454
* and the Chat SDK state namespace. channelType is NOT affected — user
5555
* identity, formatting, and container config stay keyed on the platform.
56-
* Must be URL-safe: no '/', '?', ':' or whitespace.
56+
* Must be URL-safe: non-empty, only letters, digits, '.', '_' or '-'.
5757
*/
5858
instance?: string;
5959
concurrency?: ConcurrencyStrategy;
@@ -133,9 +133,14 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
133133
// The instance name becomes a webhook route segment (the route regex is
134134
// [^/?]+) and ':' is the state-namespace delimiter — reject anything that
135135
// would break either, at construction time rather than at first webhook.
136-
if (config.instance && /[/?:\s]/.test(config.instance)) {
136+
// Positive allow-list (not a deny-list): also rejects '' and
137+
// whitespace-only names, which are config bugs — '' is falsy, so it
138+
// would skip a truthiness guard, dead-end the webhook route, and
139+
// collapse the state namespace into the default instance's keyspace.
140+
if (config.instance !== undefined && !/^[A-Za-z0-9._-]+$/.test(config.instance)) {
137141
throw new Error(
138-
`chat-sdk bridge instance ${JSON.stringify(config.instance)} must be URL-safe: no '/', '?', ':' or whitespace`,
142+
`chat-sdk bridge instance ${JSON.stringify(config.instance)} must be URL-safe: ` +
143+
`non-empty, only letters, digits, '.', '_' or '-'`,
139144
);
140145
}
141146
const transformText = (t: string): string => (config.transformOutboundText ? config.transformOutboundText(t) : t);

0 commit comments

Comments
 (0)