feat(gateway): support per-channel and per-session queue policy overrides
This commit is contained in:
@@ -807,6 +807,15 @@ server:
|
|||||||
mode: collect # collect | steer | interrupt
|
mode: collect # collect | steer | interrupt
|
||||||
cap: 50 # max pending requests per session lane
|
cap: 50 # max pending requests per session lane
|
||||||
overflow: drop_old # drop_old | drop_new
|
overflow: drop_old # drop_old | drop_new
|
||||||
|
overrides:
|
||||||
|
channels:
|
||||||
|
ws:
|
||||||
|
mode: steer
|
||||||
|
cap: 10
|
||||||
|
sessions:
|
||||||
|
ws:vip-user:
|
||||||
|
mode: interrupt
|
||||||
|
overflow: drop_new
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@@ -814,6 +823,7 @@ Notes:
|
|||||||
- `steer` and `interrupt` keep only the latest pending request while one is active.
|
- `steer` and `interrupt` keep only the latest pending request while one is active.
|
||||||
- `interrupt` currently does not force-stop already running work; use `agent.cancel` for active cancellation.
|
- `interrupt` currently does not force-stop already running work; use `agent.cancel` for active cancellation.
|
||||||
- On overflow, `drop_old` evicts the oldest pending request, `drop_new` rejects the new request.
|
- On overflow, `drop_old` evicts the oldest pending request, `drop_new` rejects the new request.
|
||||||
|
- Override precedence: exact `sessions` match first, then `channels`.
|
||||||
|
|
||||||
## Gateway Request Body Limit
|
## Gateway Request Body Limit
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ server:
|
|||||||
mode: collect # collect | steer | interrupt
|
mode: collect # collect | steer | interrupt
|
||||||
cap: 50 # max queued (pending) requests per session lane
|
cap: 50 # max queued (pending) requests per session lane
|
||||||
overflow: drop_old # drop_old | drop_new
|
overflow: drop_old # drop_old | drop_new
|
||||||
|
overrides:
|
||||||
|
channels: {} # e.g. ws: { mode: steer, cap: 10 }
|
||||||
|
sessions: {} # e.g. ws:vip-user: { mode: interrupt, overflow: drop_new }
|
||||||
# Local-network service discovery (mDNS/Bonjour). Keep disabled by default.
|
# Local-network service discovery (mDNS/Bonjour). Keep disabled by default.
|
||||||
# Requires server.localhost: false so LAN clients can actually connect.
|
# Requires server.localhost: false so LAN clients can actually connect.
|
||||||
discovery:
|
discovery:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
"date": "2026-02-16",
|
"date": "2026-02-16",
|
||||||
"updated": "2026-02-16",
|
"updated": "2026-02-16",
|
||||||
"summary": "Added configurable gateway lane queue policy (`server.queue`) with mode (`collect|steer|interrupt`), per-lane cap, and overflow behavior (`drop_old|drop_new`). Wired through daemon -> gateway runtime, expanded LaneQueue behavior/tests, and documented the new config in README + default config.",
|
"summary": "Added configurable gateway lane queue policy (`server.queue`) with mode (`collect|steer|interrupt`), per-lane cap, overflow behavior (`drop_old|drop_new`), and per-channel/per-session overrides. Wired through daemon -> gateway runtime, applied override resolution in `agent.send`, expanded queue + handler tests, and documented config usage in README + default config.",
|
||||||
"files_modified": [
|
"files_modified": [
|
||||||
"src/gateway/lane-queue.ts",
|
"src/gateway/lane-queue.ts",
|
||||||
"src/gateway/lane-queue.test.ts",
|
"src/gateway/lane-queue.test.ts",
|
||||||
@@ -26,9 +26,11 @@
|
|||||||
"src/config/schema.ts",
|
"src/config/schema.ts",
|
||||||
"src/config/schema.test.ts",
|
"src/config/schema.test.ts",
|
||||||
"config/default.yaml",
|
"config/default.yaml",
|
||||||
"README.md"
|
"README.md",
|
||||||
|
"src/gateway/handlers/agent.ts",
|
||||||
|
"src/gateway/handlers/agent.test.ts"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/gateway/lane-queue.test.ts src/config/schema.test.ts + pnpm typecheck passing"
|
"test_status": "pnpm test:run src/gateway/lane-queue.test.ts src/gateway/handlers/agent.test.ts src/config/schema.test.ts + pnpm typecheck passing"
|
||||||
},
|
},
|
||||||
"docs-gateway-auth-config-keys": {
|
"docs-gateway-auth-config-keys": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ describe('configSchema — server', () => {
|
|||||||
expect(result.server.queue.mode).toBe('collect');
|
expect(result.server.queue.mode).toBe('collect');
|
||||||
expect(result.server.queue.cap).toBe(50);
|
expect(result.server.queue.cap).toBe(50);
|
||||||
expect(result.server.queue.overflow).toBe('drop_old');
|
expect(result.server.queue.overflow).toBe('drop_old');
|
||||||
|
expect(result.server.queue.overrides.channels).toEqual({});
|
||||||
|
expect(result.server.queue.overrides.sessions).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts custom queue settings', () => {
|
it('accepts custom queue settings', () => {
|
||||||
@@ -98,6 +100,28 @@ describe('configSchema — server', () => {
|
|||||||
expect(result.server.queue.overflow).toBe('drop_new');
|
expect(result.server.queue.overflow).toBe('drop_new');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts queue override settings', () => {
|
||||||
|
const result = configSchema.parse({
|
||||||
|
...minimalConfig,
|
||||||
|
server: {
|
||||||
|
queue: {
|
||||||
|
overrides: {
|
||||||
|
channels: {
|
||||||
|
ws: { mode: 'collect', cap: 5 },
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
'ws:vip-user': { mode: 'interrupt', overflow: 'drop_new' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.server.queue.overrides.channels.ws.mode).toBe('collect');
|
||||||
|
expect(result.server.queue.overrides.channels.ws.cap).toBe(5);
|
||||||
|
expect(result.server.queue.overrides.sessions['ws:vip-user'].mode).toBe('interrupt');
|
||||||
|
expect(result.server.queue.overrides.sessions['ws:vip-user'].overflow).toBe('drop_new');
|
||||||
|
});
|
||||||
|
|
||||||
it('defaults discovery settings', () => {
|
it('defaults discovery settings', () => {
|
||||||
const result = configSchema.parse(minimalConfig);
|
const result = configSchema.parse(minimalConfig);
|
||||||
expect(result.server.discovery.enabled).toBe(false);
|
expect(result.server.discovery.enabled).toBe(false);
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ const laneQueueSchema = z.object({
|
|||||||
cap: z.number().min(1).max(1000).default(50),
|
cap: z.number().min(1).max(1000).default(50),
|
||||||
/** Overflow strategy when cap is reached. */
|
/** Overflow strategy when cap is reached. */
|
||||||
overflow: z.enum(['drop_old', 'drop_new']).default('drop_old'),
|
overflow: z.enum(['drop_old', 'drop_new']).default('drop_old'),
|
||||||
|
/** Optional per-channel/per-session queue policy overrides. */
|
||||||
|
overrides: z.object({
|
||||||
|
channels: z.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
mode: z.enum(['collect', 'steer', 'interrupt']).optional(),
|
||||||
|
cap: z.number().min(1).max(1000).optional(),
|
||||||
|
overflow: z.enum(['drop_old', 'drop_new']).optional(),
|
||||||
|
}),
|
||||||
|
).default({}),
|
||||||
|
sessions: z.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
mode: z.enum(['collect', 'steer', 'interrupt']).optional(),
|
||||||
|
cap: z.number().min(1).max(1000).optional(),
|
||||||
|
overflow: z.enum(['drop_old', 'drop_new']).optional(),
|
||||||
|
}),
|
||||||
|
).default({}),
|
||||||
|
}).default({}),
|
||||||
}).default({});
|
}).default({});
|
||||||
|
|
||||||
const serverDiscoverySchema = z.object({
|
const serverDiscoverySchema = z.object({
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ export function createGateway(deps: GatewayDeps): GatewayServer {
|
|||||||
mode: config.server.queue.mode,
|
mode: config.server.queue.mode,
|
||||||
cap: config.server.queue.cap,
|
cap: config.server.queue.cap,
|
||||||
overflow: config.server.queue.overflow,
|
overflow: config.server.queue.overflow,
|
||||||
|
overrides: {
|
||||||
|
channels: config.server.queue.overrides.channels,
|
||||||
|
sessions: config.server.queue.overrides.sessions,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
discovery: {
|
discovery: {
|
||||||
enabled: config.server.discovery.enabled,
|
enabled: config.server.discovery.enabled,
|
||||||
|
|||||||
@@ -124,3 +124,62 @@ describe('createAgentHandlers command fast-path', () => {
|
|||||||
expect(((sent[0] as GatewayEvent).data as { content: string }).content).toBe('agent response');
|
expect(((sent[0] as GatewayEvent).data as { content: string }).content).toBe('agent response');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createAgentHandlers queue policy resolution', () => {
|
||||||
|
it('passes resolved per-request queue policy into lane enqueue', async () => {
|
||||||
|
const mockAgent = {
|
||||||
|
process: vi.fn(async () => 'ok'),
|
||||||
|
getUsage: vi.fn(() => ({
|
||||||
|
primary: { inputTokens: 0, outputTokens: 0, calls: 0 },
|
||||||
|
delegation: {},
|
||||||
|
total: { inputTokens: 0, outputTokens: 0, calls: 0, estimatedCost: 0 },
|
||||||
|
})),
|
||||||
|
getModelTier: vi.fn(() => 'default'),
|
||||||
|
setModelTier: vi.fn(),
|
||||||
|
compact: vi.fn(async () => null),
|
||||||
|
reset: vi.fn(),
|
||||||
|
setOnToolUse: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionBridge = {
|
||||||
|
getAgent: vi.fn(() => mockAgent),
|
||||||
|
getSessionId: vi.fn(() => 'ws:s1'),
|
||||||
|
setBusy: vi.fn(),
|
||||||
|
setOnToolUse: vi.fn(),
|
||||||
|
isBusy: vi.fn(() => false),
|
||||||
|
};
|
||||||
|
|
||||||
|
const laneQueue = {
|
||||||
|
enqueue: vi.fn(async (_laneId: string, work: () => Promise<unknown>) => work()),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
} as unknown as LaneQueue;
|
||||||
|
|
||||||
|
const resolveQueuePolicy = vi.fn(() => ({ mode: 'steer' as const, cap: 3 }));
|
||||||
|
|
||||||
|
const handlers = createAgentHandlers({
|
||||||
|
sessionBridge: sessionBridge as unknown as AgentHandlerDeps['sessionBridge'],
|
||||||
|
laneQueue,
|
||||||
|
resolveQueuePolicy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent: OutboundMessage[] = [];
|
||||||
|
const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
|
||||||
|
|
||||||
|
await handlers['agent.send']({
|
||||||
|
id: 1,
|
||||||
|
method: 'agent.send',
|
||||||
|
params: { message: 'hello', connectionId: 'conn-1' },
|
||||||
|
}, send);
|
||||||
|
|
||||||
|
expect(resolveQueuePolicy).toHaveBeenCalledWith({
|
||||||
|
laneId: 'ws:s1',
|
||||||
|
sessionId: 'ws:s1',
|
||||||
|
connectionId: 'conn-1',
|
||||||
|
channel: 'ws',
|
||||||
|
});
|
||||||
|
expect((laneQueue.enqueue as unknown as ReturnType<typeof vi.fn>).mock.calls[0][2]).toEqual({
|
||||||
|
mode: 'steer',
|
||||||
|
cap: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { SendFn } from '../router.js';
|
|||||||
import { makeEvent, makeError, ErrorCode } from '../protocol.js';
|
import { makeEvent, makeError, ErrorCode } from '../protocol.js';
|
||||||
import type { SessionBridge } from '../session-bridge.js';
|
import type { SessionBridge } from '../session-bridge.js';
|
||||||
import type { LaneQueue } from '../lane-queue.js';
|
import type { LaneQueue } from '../lane-queue.js';
|
||||||
|
import type { LaneQueueConfig } from '../lane-queue.js';
|
||||||
import type { MetricsCollector } from '../metrics.js';
|
import type { MetricsCollector } from '../metrics.js';
|
||||||
import type { Attachment } from '../../channels/types.js';
|
import type { Attachment } from '../../channels/types.js';
|
||||||
import type { SessionManager } from '../../session/manager.js';
|
import type { SessionManager } from '../../session/manager.js';
|
||||||
@@ -14,6 +15,12 @@ import { randomUUID } from 'crypto';
|
|||||||
export interface AgentHandlerDeps {
|
export interface AgentHandlerDeps {
|
||||||
sessionBridge: SessionBridge;
|
sessionBridge: SessionBridge;
|
||||||
laneQueue: LaneQueue;
|
laneQueue: LaneQueue;
|
||||||
|
resolveQueuePolicy?: (ctx: {
|
||||||
|
laneId: string;
|
||||||
|
sessionId?: string;
|
||||||
|
connectionId: string;
|
||||||
|
channel: string;
|
||||||
|
}) => Partial<LaneQueueConfig> | undefined;
|
||||||
metrics?: MetricsCollector;
|
metrics?: MetricsCollector;
|
||||||
sessionManager?: SessionManager;
|
sessionManager?: SessionManager;
|
||||||
commandRegistry?: CommandRegistry;
|
commandRegistry?: CommandRegistry;
|
||||||
@@ -48,6 +55,7 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
|
|||||||
// Falls back to connectionId if session lookup fails (shouldn't happen).
|
// Falls back to connectionId if session lookup fails (shouldn't happen).
|
||||||
const sessionId = deps.sessionBridge.getSessionId(connectionId);
|
const sessionId = deps.sessionBridge.getSessionId(connectionId);
|
||||||
const laneId = sessionId ?? connectionId;
|
const laneId = sessionId ?? connectionId;
|
||||||
|
const channel = sessionId?.split(':', 1)[0] ?? 'ws';
|
||||||
|
|
||||||
// Enqueue the work — if the lane is idle it runs immediately,
|
// Enqueue the work — if the lane is idle it runs immediately,
|
||||||
// otherwise it waits for earlier requests on the same session to finish.
|
// otherwise it waits for earlier requests on the same session to finish.
|
||||||
@@ -312,7 +320,7 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
|
|||||||
deps.sessionBridge.setOnToolUse(connectionId, undefined);
|
deps.sessionBridge.setOnToolUse(connectionId, undefined);
|
||||||
deps.metrics?.endRequest(requestId);
|
deps.metrics?.endRequest(requestId);
|
||||||
}
|
}
|
||||||
});
|
}, deps.resolveQueuePolicy?.({ laneId, sessionId, connectionId, channel }));
|
||||||
},
|
},
|
||||||
|
|
||||||
'agent.cancel': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
'agent.cancel': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||||
|
|||||||
@@ -248,4 +248,23 @@ describe('LaneQueue', () => {
|
|||||||
await expect(p1).resolves.toBe('active');
|
await expect(p1).resolves.toBe('active');
|
||||||
await expect(p3).resolves.toBe('new-pending');
|
await expect(p3).resolves.toBe('new-pending');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports per-enqueue policy overrides', async () => {
|
||||||
|
const queue = new LaneQueue({ mode: 'collect', cap: 10, overflow: 'drop_old' });
|
||||||
|
let resolveFirst!: () => void;
|
||||||
|
const firstBlocks = new Promise<void>((r) => { resolveFirst = r; });
|
||||||
|
|
||||||
|
const p1 = queue.enqueue('lane-a', async () => {
|
||||||
|
await firstBlocks;
|
||||||
|
return 'active';
|
||||||
|
});
|
||||||
|
const p2 = queue.enqueue('lane-a', async () => 'old-pending', { mode: 'steer' });
|
||||||
|
const p3 = queue.enqueue('lane-a', async () => 'latest-pending', { mode: 'steer' });
|
||||||
|
|
||||||
|
await expect(p2).rejects.toThrow('Superseded by newer request');
|
||||||
|
resolveFirst();
|
||||||
|
|
||||||
|
await expect(p1).resolves.toBe('active');
|
||||||
|
await expect(p3).resolves.toBe('latest-pending');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export class LaneQueue {
|
|||||||
* Returns a promise that resolves with the work's return value
|
* Returns a promise that resolves with the work's return value
|
||||||
* once it has been executed (which may be immediately if the lane is idle).
|
* once it has been executed (which may be immediately if the lane is idle).
|
||||||
*/
|
*/
|
||||||
async enqueue<T>(laneId: string, work: () => Promise<T>): Promise<T> {
|
async enqueue<T>(laneId: string, work: () => Promise<T>, policy?: Partial<LaneQueueConfig>): Promise<T> {
|
||||||
|
const effective = this.resolvePolicy(policy);
|
||||||
let lane = this.lanes.get(laneId);
|
let lane = this.lanes.get(laneId);
|
||||||
if (!lane) {
|
if (!lane) {
|
||||||
lane = { active: false, queue: [] };
|
lane = { active: false, queue: [] };
|
||||||
@@ -65,12 +66,12 @@ export class LaneQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.mode === 'steer' || this.config.mode === 'interrupt') {
|
if (effective.mode === 'steer' || effective.mode === 'interrupt') {
|
||||||
this.rejectPending(lane, 'Superseded by newer request');
|
this.rejectPending(lane, 'Superseded by newer request');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lane.queue.length >= this.config.cap) {
|
if (lane.queue.length >= effective.cap) {
|
||||||
if (this.config.overflow === 'drop_new') {
|
if (effective.overflow === 'drop_new') {
|
||||||
return Promise.reject(new Error('Lane queue full (drop_new)'));
|
return Promise.reject(new Error('Lane queue full (drop_new)'));
|
||||||
}
|
}
|
||||||
// drop_old
|
// drop_old
|
||||||
@@ -131,6 +132,14 @@ export class LaneQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolvePolicy(policy?: Partial<LaneQueueConfig>): LaneQueueConfig {
|
||||||
|
return {
|
||||||
|
mode: policy?.mode ?? this.config.mode,
|
||||||
|
cap: Math.max(1, policy?.cap ?? this.config.cap),
|
||||||
|
overflow: policy?.overflow ?? this.config.overflow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process the next queued entry for a lane (called after current work finishes).
|
* Process the next queued entry for a lane (called after current work finishes).
|
||||||
* Runs asynchronously so the caller's finally block completes first.
|
* Runs asynchronously so the caller's finally block completes first.
|
||||||
|
|||||||
+16
-1
@@ -85,7 +85,12 @@ export interface GatewayServerConfig {
|
|||||||
maxViolations?: number;
|
maxViolations?: number;
|
||||||
violationWindowMs?: number;
|
violationWindowMs?: number;
|
||||||
};
|
};
|
||||||
queue?: Partial<LaneQueueConfig>;
|
queue?: Partial<LaneQueueConfig> & {
|
||||||
|
overrides?: {
|
||||||
|
channels?: Record<string, Partial<LaneQueueConfig>>;
|
||||||
|
sessions?: Record<string, Partial<LaneQueueConfig>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
/** Optional pairing manager for DM pairing code management via gateway. */
|
/** Optional pairing manager for DM pairing code management via gateway. */
|
||||||
pairingManager?: PairingManager;
|
pairingManager?: PairingManager;
|
||||||
memoryStore?: MemoryStore;
|
memoryStore?: MemoryStore;
|
||||||
@@ -199,6 +204,16 @@ export class GatewayServer {
|
|||||||
const agentHandlers = createAgentHandlers({
|
const agentHandlers = createAgentHandlers({
|
||||||
sessionBridge: this.sessionBridge,
|
sessionBridge: this.sessionBridge,
|
||||||
laneQueue: this.laneQueue,
|
laneQueue: this.laneQueue,
|
||||||
|
resolveQueuePolicy: ({ sessionId, channel }) => {
|
||||||
|
const sessionPolicy = sessionId
|
||||||
|
? this.config.queue?.overrides?.sessions?.[sessionId]
|
||||||
|
: undefined;
|
||||||
|
if (sessionPolicy) {
|
||||||
|
return sessionPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.config.queue?.overrides?.channels?.[channel];
|
||||||
|
},
|
||||||
metrics: this.metrics,
|
metrics: this.metrics,
|
||||||
sessionManager: this.config.sessionManager,
|
sessionManager: this.config.sessionManager,
|
||||||
commandRegistry: this.config.commandRegistry,
|
commandRegistry: this.config.commandRegistry,
|
||||||
|
|||||||
Reference in New Issue
Block a user