From 39f86fdbbe0e4cad47212ae7292f256cd7dbb32e Mon Sep 17 00:00:00 2001 From: Charlie Hulcher Date: Tue, 24 Mar 2026 20:33:35 -0700 Subject: [PATCH] feat(connector-openclaw): add lane support for concurrency control (#140) Without a lane field in the request body, all OpenClaw deliveries resolve to the 'nested' lane with hardcoded maxConcurrent: 1, causing 15+ minute delays when multiple routes fire concurrently. Add lane config at both connector level (default for all routes) and per-route level (override). Omitting lane preserves backward-compatible behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- connectors/openclaw/README.md | 6 +- .../openclaw/src/__tests__/target.test.ts | 83 +++++++++++++++++++ connectors/openclaw/src/target.ts | 8 ++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/connectors/openclaw/README.md b/connectors/openclaw/README.md index 02b1f3b..fb18eff 100644 --- a/connectors/openclaw/README.md +++ b/connectors/openclaw/README.md @@ -20,6 +20,7 @@ actors: auth_token_env: "${OPENCLAW_TOKEN}" # optional — bearer token (env var ref) default_channel: "engineering" # optional — default delivery channel default_to: "team-lead" # optional — default recipient + lane: "main" # optional — OpenClaw lane for concurrency control ``` ### Config options @@ -31,6 +32,7 @@ actors: | `auth_token_env` | `string` | no | — | Bearer token for auth. Supports `${ENV_VAR}` syntax | | `default_channel` | `string` | no | — | Default channel for message delivery | | `default_to` | `string` | no | — | Default recipient for message delivery | +| `lane` | `string` | no | — | OpenClaw lane for concurrency control. Can be overridden per-route. | ## Events accepted @@ -48,7 +50,8 @@ The connector builds a message string from the event and sends it as: "wakeMode": "now", "deliver": true, "channel": "engineering", - "to": "team-lead" + "to": "team-lead", + "lane": "main" } ``` @@ -64,6 +67,7 @@ These fields can be set in the route's `then.config`: | `deliver` | `boolean` | `false` | Whether to deliver the message to a channel | | `channel` | `string` | — | Override the actor's `default_channel` for this route | | `to` | `string` | — | Override the actor's `default_to` for this route | +| `lane` | `string` | — | Override the actor's `lane` for this route. Controls OpenClaw concurrency. | | `launch_prompt` | `string` | — | Resolved from route's `with.prompt_file`; appended to the message | ### Template interpolation diff --git a/connectors/openclaw/src/__tests__/target.test.ts b/connectors/openclaw/src/__tests__/target.test.ts index 7c1f8b7..02b4012 100644 --- a/connectors/openclaw/src/__tests__/target.test.ts +++ b/connectors/openclaw/src/__tests__/target.test.ts @@ -275,6 +275,89 @@ describe('OpenClawTarget', () => { expect(result.status).toBe('rejected'); }); + // ─── #140 — Lane support ──────────────────────────────────────────────── + + it('passes lane from connector config in request body', async () => { + const laneTarget = new OpenClawTarget(); + await laneTarget.init({ + id: 'openclaw-agent', + connector: '@orgloop/connector-openclaw', + config: { + base_url: 'http://localhost:18789', + agent_id: 'test-agent', + lane: 'main', + }, + }); + + const event = createTestEvent(); + await laneTarget.deliver(event, {}); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.lane).toBe('main'); + }); + + it('passes lane from route config in request body', async () => { + const event = createTestEvent(); + await target.deliver(event, { lane: 'background' }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.lane).toBe('background'); + }); + + it('route config lane overrides connector config lane', async () => { + const laneTarget = new OpenClawTarget(); + await laneTarget.init({ + id: 'openclaw-agent', + connector: '@orgloop/connector-openclaw', + config: { + base_url: 'http://localhost:18789', + agent_id: 'test-agent', + lane: 'main', + }, + }); + + const event = createTestEvent(); + await laneTarget.deliver(event, { lane: 'background' }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.lane).toBe('background'); + }); + + it('omits lane from request body when not configured', async () => { + const event = createTestEvent(); + await target.deliver(event, {}); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).not.toHaveProperty('lane'); + }); + + it('includes lane in callback-first delivery', async () => { + const laneTarget = new OpenClawTarget(); + await laneTarget.init({ + id: 'openclaw-agent', + connector: '@orgloop/connector-openclaw', + config: { + base_url: 'http://localhost:18789', + agent_id: 'test-agent', + lane: 'main', + }, + }); + + const event = createTestEvent({ + source: 'coding-agent', + type: 'actor.stopped', + payload: { + meta: { openclaw_callback_session_key: 'callback:sess-abc' }, + }, + }); + + await laneTarget.deliver(event, {}); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sessionKey).toBe('callback:sess-abc'); + expect(body.lane).toBe('main'); + }); + // ─── #66 — Dynamic threadId ────────────────────────────────────────────── it('passes threadId when route config has thread_id', async () => { diff --git a/connectors/openclaw/src/target.ts b/connectors/openclaw/src/target.ts index 0ac4933..d7fc236 100644 --- a/connectors/openclaw/src/target.ts +++ b/connectors/openclaw/src/target.ts @@ -64,6 +64,7 @@ interface OpenClawConfig { agent_id?: string; default_channel?: string; default_to?: string; + lane?: string; } export class OpenClawTarget implements ActorConnector { @@ -73,6 +74,7 @@ export class OpenClawTarget implements ActorConnector { private agentId?: string; private defaultChannel?: string; private defaultTo?: string; + private lane?: string; private httpAgent: HttpAgent | null = null; private fetch: typeof globalThis.fetch = globalThis.fetch; @@ -82,6 +84,7 @@ export class OpenClawTarget implements ActorConnector { this.agentId = cfg.agent_id; this.defaultChannel = cfg.default_channel; this.defaultTo = cfg.default_to; + this.lane = cfg.lane; if (cfg.auth_token_env) { this.authToken = resolveEnvVar(cfg.auth_token_env); @@ -104,6 +107,9 @@ export class OpenClawTarget implements ActorConnector { const rawThreadId = routeConfig.thread_id as string | undefined; const threadId = rawThreadId ? interpolateTemplate(rawThreadId, event) : undefined; + // #140 — Lane support: route config overrides connector-level default + const lane = (routeConfig.lane as string) ?? this.lane; + // #91 — Callback-first delivery: check event payload for callback metadata const callbackSessionKey = this.resolveCallbackSessionKey(event); const callbackAgentId = this.resolveCallbackAgentId(event); @@ -117,6 +123,7 @@ export class OpenClawTarget implements ActorConnector { deliver: routeConfig.deliver ?? false, channel: (routeConfig.channel as string) ?? this.defaultChannel, to: (routeConfig.to as string) ?? this.defaultTo, + ...(lane !== undefined ? { lane } : {}), ...(threadId !== undefined ? { threadId } : {}), ...(routeConfig.model ? { model: routeConfig.model } : {}), ...(routeConfig.thinking ? { thinking: routeConfig.thinking } : {}), @@ -136,6 +143,7 @@ export class OpenClawTarget implements ActorConnector { deliver: routeConfig.deliver ?? false, channel: (routeConfig.channel as string) ?? this.defaultChannel, to: (routeConfig.to as string) ?? this.defaultTo, + ...(lane !== undefined ? { lane } : {}), ...(threadId !== undefined ? { threadId } : {}), ...(routeConfig.model ? { model: routeConfig.model } : {}), ...(routeConfig.thinking ? { thinking: routeConfig.thinking } : {}),