fix(agents): scope nested lane per target session to stop cross-agent blocking (#67502)#67785
Conversation
Greptile SummaryThis PR fixes a head-of-line blocking bug where all nested agent operations across every session shared a single The implementation is clean: The acknowledged lane-state Confidence Score: 5/5Safe to merge — targeted, well-tested reliability fix with no correctness issues found. All findings are informational. The logic in the new helpers is correct, all call sites are consistently updated, tests cover the critical edge cases, and backward compatibility is preserved. No P0 or P1 issues identified. No files require special attention. Reviews (1): Last reviewed commit: "fix(agents): scope nested lane per targe..." | Re-trigger Greptile |
0d854b1 to
730b535
Compare
suboss87
left a comment
There was a problem hiding this comment.
Clean fix for a real concurrency bug. The per-session lane scoping makes sense and the test coverage is thorough. A few observations:
Lane lifecycle: resolveNestedAgentLaneForSession creates a new lane key per distinct session key. If the lane manager holds onto queue state per lane string for the process lifetime, long-running gateways with many sessions could accumulate unbounded queue entries. Is there a reap mechanism for idle lanes, or does the queue manager self-clean when a lane drains? Might be worth a comment noting the assumption.
Type widening: AGENT_LANE_NESTED: string widens the export from CommandLane enum to string to avoid no-unsafe-enum-comparison. This works but loosens the type contract at the boundary. A narrower alternative would be a comparison helper like isLaneEqual(a: CommandLane | string, b: CommandLane) that keeps the export typed while allowing the runtime string comparison. Not a blocker, just a thought.
Test assertion in sessions_send: The relaxed /^nested(?::|$)/ regex match is a nice touch for forward compatibility. Good call.
Overall looks solid to me.
stainlu
left a comment
There was a problem hiding this comment.
on lane lifecycle — no reaper, same story as the existing session:<key> lanes in src/agents/pi-embedded-runner/lanes.ts. ~200 bytes per distinct key, persists for process lifetime. pushed ca44e3c8 noting the parity on resolveNestedAgentLaneForSession — an idle-lane reap is a reasonable follow-up if it ever bites.
on the widening — you're right that isLaneEqual(a, b: CommandLane) is the narrower shape. went with widening plus isNestedAgentLane because the semantic i actually needed at command/delivery.ts:302 was "any nested lane, scoped or not", which an equality helper doesn't cover. happy to narrow AGENT_LANE_NESTED back to the enum and route the pointwise equality check through isLaneEqual if the maintainer prefers; small cleanup.
thanks for the careful read.
ca44e3c to
2fe635a
Compare
14bf283 to
f125207
Compare
obviyus
left a comment
There was a problem hiding this comment.
Verified the cross-session nested-lane head-of-line blocking path and confirmed the fix scopes nested work by target session while preserving the shared nested logging behavior.
Maintainer follow-up: rebased onto latest main, tightened the nested-lane helper/comments, and added the Unreleased changelog entry for #67785.
Local gate: pnpm test src/agents/lanes.test.ts src/commands/agent.delivery.test.ts src/agents/openclaw-tools.sessions.test.ts src/gateway/server.sessions-send.test.ts; pnpm lint src/agents/lanes.ts src/agents/lanes.test.ts src/agents/openclaw-tools.sessions.test.ts src/commands/agent.delivery.test.ts src/gateway/server.sessions-send.test.ts; pnpm tsgo.
…anks @stainlu) * fix(agents): scope nested lane per target session to stop cross-agent blocking * docs(agents): note per-session nested-lane lifecycle parity with session:* lanes * refactor(agents): distill nested lane helpers * fix: scope nested agent lanes per target session (openclaw#67785) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
…anks @stainlu) * fix(agents): scope nested lane per target session to stop cross-agent blocking * docs(agents): note per-session nested-lane lifecycle parity with session:* lanes * refactor(agents): distill nested lane helpers * fix: scope nested agent lanes per target session (openclaw#67785) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
…anks @stainlu) * fix(agents): scope nested lane per target session to stop cross-agent blocking * docs(agents): note per-session nested-lane lifecycle parity with session:* lanes * refactor(agents): distill nested lane helpers * fix: scope nested agent lanes per target session (openclaw#67785) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
…anks @stainlu) * fix(agents): scope nested lane per target session to stop cross-agent blocking * docs(agents): note per-session nested-lane lifecycle parity with session:* lanes * refactor(agents): distill nested lane helpers * fix: scope nested agent lanes per target session (openclaw#67785) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
…anks @stainlu) * fix(agents): scope nested lane per target session to stop cross-agent blocking * docs(agents): note per-session nested-lane lifecycle parity with session:* lanes * refactor(agents): distill nested lane helpers * fix: scope nested agent lanes per target session (openclaw#67785) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
…anks @stainlu) * fix(agents): scope nested lane per target session to stop cross-agent blocking * docs(agents): note per-session nested-lane lifecycle parity with session:* lanes * refactor(agents): distill nested lane helpers * fix: scope nested agent lanes per target session (openclaw#67785) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
…openclaw#67502) When a subagent completion's delivery origin carried a non-deliverable (internal) channel that got stripped by stripNonDeliverableChannelForCompletionOrigin, and neither directOrigin nor requesterSessionOrigin provided a deliverable channel, the effectiveDirectOrigin resolved to no channel. This caused responses to be silently lost — the agent generated a reply but it never reached the external channel (Telegram, Discord, etc.). Fix: recover the delivery channel from the requester session entry (channel/lastChannel/origin.provider) when the merged effective origin lacks a channel after stripping. This ensures the original external channel is preserved through nested ACP runs. Also: - Add warning log when completion delivery context has no deliverable channel, making silent message loss detectable in gateway logs. - Enrich message-tool-only delivery failure error with channel and requester info for easier debugging. Addresses the remaining sub-issues from openclaw#67502 (delivery context corruption + detectability) that were explicitly out of scope in PR openclaw#67785. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…anks @stainlu) * fix(agents): scope nested lane per target session to stop cross-agent blocking * docs(agents): note per-session nested-lane lifecycle parity with session:* lanes * refactor(agents): distill nested lane helpers * fix: scope nested agent lanes per target session (openclaw#67785) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
Summary
Problem: A single long-running nested agent run (e.g. an ACP Claude Code run on session A) blocks every other nested operation across the gateway, because all nested agent runs share one global queue with
maxConcurrent=1. In the reported incident, a 7-14 minute ACP run onebao-nextblocked three other agent sessions (ebao,ebao-vue, and follow-up nested work) for 7-14 minutes each. Assistant responses generated during the backlog were never delivered to Discord.Why:
CommandLane.Nested = "nested"is a single lane name used by every nested operation —sessions_send,runAgentStep, ACP dispatches — regardless of which session they target. The command queue keyed off that bare string serializes everything behind a single head-of-line run.What changed: Nested lanes are now scoped to the target session key.
sessions_send,runAgentStep, and the A2A ping-pong flow now compute their lane asnested:<targetSessionKey>. A long run on session A still serializes subsequent nested work against A (concurrency invariant preserved), but nested work against B, C, D now runs in parallel on their own lanes.What did NOT change:
CommandLane.Nestedis still exported; callers without a known target session (legacy cron paths viaresolveNestedAgentLane(lane)) still fall back to the unscoped lane.isNestedAgentLane(lane)treats bothnestedandnested:<key>as nested for delivery logging, metrics, and future switchboards.maxConcurrent=1by default (same as before for the unscopednestedlane).Change Type
Bug fix (reliability / data loss prevention).
Scope
Linked Issue
Closes #67502.
Root Cause
Architecture before this PR:
resolveNestedAgentLane(lane?)insrc/agents/lanes.tsreturned the bare string\"nested\"for every caller that did not supply an explicit lane.src/agents/tools/sessions-send-tool.ts:279,src/agents/tools/sessions-send-tool.a2a.ts:95,126,src/agents/tools/agent-step.ts:39) passedlane: AGENT_LANE_NESTEDdirectly.src/process/command-queue.tskeysLaneStateby lane name in aMap<string, LaneState>(getLaneState(lane)) with a defaultmaxConcurrent: 1.src/gateway/server-lanes.tsonly setsCron,Main,Subagentconcurrency).Result: every nested run across every session contends on a single lane with a hard cap of 1 in-flight task. When one session's nested work takes ~10 minutes (ACP + Claude Code, long tool loops), every other session's follow-up work queues behind it and surfaces as
lane wait exceeded: lane=nested waitedMs=576497 queueAhead=1.Fix
resolveNestedAgentLaneForSession(sessionKey)insrc/agents/lanes.ts. Returnsnested:<sessionKey>when a key is supplied, and falls back to the unscopednestedlane when it is empty (preserves cron/legacy semantics).isNestedAgentLane(lane)so delivery logging (src/agents/command/delivery.ts:302) and any future consumer can recognise both the unscoped and scoped variants.resolveNestedAgentLaneForSession:src/agents/tools/sessions-send-tool.ts(top-level sessions_send)src/agents/tools/sessions-send-tool.a2a.ts(A2A ping-pong replies + announce step)src/agents/tools/agent-step.ts(default lane when caller does not override)src/agents/command/delivery.tsfromopts.lane === AGENT_LANE_NESTEDtoisNestedAgentLane(opts.lane)so nested logging still fires on scoped lanes.AGENT_LANE_NESTEDandAGENT_LANE_SUBAGENTconstants to plainstringtypes so callers that compare against arbitrary lane labels (per-session suffixes, user-supplied lanes) do not tripno-unsafe-enum-comparison.Regression Test Plan
src/agents/lanes.test.ts— newdescribeblocksresolveNestedAgentLaneForSession (#67502)(5 cases) andisNestedAgentLane(5 cases), covering: fallback to unscoped when no key, per-session scoping, distinct lanes for distinct sessions, deterministic output, whitespace trimming, substring-guard against accidental matches like\"deeply-nested-lane\"/\"nestedfoo\".src/commands/agent.delivery.test.ts— new case verifying[agent:nested]log prefix fires for scoped lanes (nested:agent:ebao-next:discord:channel:1), not just the unscoped lane.src/agents/openclaw-tools.sessions.test.ts— two assertions now matchexpect.stringMatching(/^nested(?::|$)/)instead of the literal\"nested\".src/gateway/server.sessions-send.test.ts— E2E assertion matches the same regex.User-visible Changes
Users running multiple agents with
sessions_send(timeoutSeconds: 0)no longer see cross-agent stalls when one agent is running a long nested tool loop (ACP, Claude Code, subagent chains). Gateway logs continue to show[agent:nested]prefixed output regardless of the new lane suffix.Diagram
Security Impact
Repro + Verification
sessions_send(timeoutSeconds: 0). The two targets each invoke a 7-14 minute ACP coding task. Meanwhile, the orchestrator agent also needs to emit a summary.nested:<its-own-sessionKey>. The orchestrator's follow-up runs onnested:<orchestrator-sessionKey>. No cross-session blocking.nested; the orchestrator's summary is queued behind both ACP runs and surfaces minutes late or not at all.Evidence
Failing before: targeting distinct sessions still produced a single
Map<string, LaneState>entry\"nested\"; existing tests literal-matchedlane: \"nested\"so cross-session isolation wasn't covered by the suite.Passing after:
pnpm test src/agents/lanes.test.ts— 13 tests green (8 new, 5 existing).pnpm test src/agents/tools/sessions-send-tool.a2a.test.ts src/agents/command/delivery.test.ts src/agents/openclaw-tools.sessions.test.ts— 20 tests green across the touched consumers.pnpm test src/gateway/server.sessions-send.test.ts src/commands/agent.delivery.test.ts— 14 tests green with the updated pattern matcher + new per-session nested lane logging case.pnpm test src/cron/isolated-agent.lane.test.ts— 3 tests green (cron still uses the unscoped fallback path).pnpm test 'src/agents/**/*.test.ts'sweep — 3841 tests green, 2 unrelated SSRF/network failures inweb-fetch.provider-fallback.test.ts(environment-specific; pre-exist on upstream main).Human Verification
pnpm test 'src/agents/**/*.test.ts'sweep except unrelated SSRF tests;pnpm lintclean on touched files (unrelated pre-existing failures inextensions/qa-labandextensions/discordnot introduced by this change);pnpm tsgoclean on touched files (unrelated pre-existing failures inextensions/discord/src/monitor/gateway-plugin.*not introduced by this change).pnpm build— fails on upstream main with an unrelated@clawdbot/lobster/corerolldown resolution error; did not address.resolveNestedAgentLane(lane)(unchanged; still unscoped fallback), substring guards to ensure lane names like\"deeply-nested-lane\"or\"nestedfoo\"are not misclassified.Review Conversations
Compatibility / Migration
AGENT_LANE_NESTEDis still exported; callers that continue to pass it directly keep their existing (unscoped) behaviour. Cron integration (resolveNestedAgentLane) was intentionally left unchanged.Risks and Mitigations
Risk: The lane-state
Mapgrows as new session keys appear. Lane state is ~200 bytes per entry and is never GC'd today.session:*lanes (src/agents/pi-embedded-runner/lanes.ts). The realistic upper bound is the number of active sessions, which is already bounded. A future "reap idle nested lanes" pass can be added if it becomes a concern; deliberately out of scope for this fix.Risk: A lane label collision between the scoped nested lane and a user-supplied lane named
\"nested:something\".resolveNestedAgentLanestill honours caller-supplied lanes verbatim (first-call-wins via?? resolveNestedAgentLaneForSession(...)), and the new prefixnested:is namespaced to mirror the existingsession:<key>convention.Risk: Out-of-scope sub-issues from the same report (delivery-context corruption where the session's
channelfield degrades from\"discord\"to\"webchat\", and the absence of delivery retry / DLQ) remain.AI-assisted: This PR was developed with AI assistance.