fix(agents): don't inject A2A turns into isolated-cron sessions_send (#92257)#92283
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 1, 2026, 8:25 PM ET / 00:25 UTC. Summary PR surface: Source +18, Tests +211. Total +229 across 3 files. Reproducibility: yes. at source level. Current main and Review metrics: 1 noteworthy metric.
Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Mantis proof suggestion Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land this canonical cron-run gate after normal required gates, then close the linked bug; request live cron transport proof only if maintainers want extra assurance. Do we have a high-confidence way to reproduce the issue? Yes, at source level. Current main and Is this the best way to solve the issue? Yes. The caller-side canonical cron-run gate is the narrowest maintainable fix I found because it preserves target announce delivery and normal requester ping-pong, unlike a timeout-based or whole-flow skip. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 3558391a75fd. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +18, Tests +211. Total +229 across 3 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
3da89f0 to
a32947d
Compare
a32947d to
034a80a
Compare
|
Addressed the Codex [P2] finding (narrow the cron requester detector).
Local proof: @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
…penclaw#92257) Address Codex [P2] review: replace the raw `:cron:` substring detector with the canonical isCronRunSessionKey so a non-canonical cron-like requester key (e.g. agent:main:slack:cron:job:run:uuid) keeps its intended cross-session ping-pong. Keep the requesterChannel === "cron" arm (isolated cron runs set that channel in src/cron/isolated-agent/run.ts). Add a regression covering the non-canonical key.
…ey only (openclaw#92257) The requesterChannel === "cron" arm was unreachable: agentChannel is always a DeliverableMessageChannel from resolveGatewayMessageChannel(messageProvider), never the literal "cron". The channel: "cron" in src/cron/isolated-agent/run.ts labels diagnostics events/lifecycle, not the tool channel. Gate on isCronRunSessionKey alone and fix the misleading comment. Tests drove the dead arm via agentChannel/requesterChannel "cron" plus a non-canonical key (agent:main:cron:run:abc, which isCronRunSessionKey rejects). Switch them to a canonical cron-run key (agent:main:cron:job:run:abc) and a normal delivery channel so they exercise the real production gate.
426774a to
1f040bd
Compare
|
Merged via squash.
|
* origin/main: test(gateway): isolate live release agent state test(plugins): repair prerelease validation fixtures fix(discord): guard JSON.parse against malformed API response bodies (#97889) fix(codex): preserve app approvals in side forks (#98812) fix(agents): don't inject A2A turns into isolated-cron sessions_send (#92257) (#92283) fix(browser): guard setDeep against empty keys array (#98138) docs(telegram): move maintainer decisions into scoped AGENTS.md fix(ios): generate light app store screenshots only fix(gateway): distinguish reachable gateway from failed status probe (#98183)
…penclaw#92257) (openclaw#92283) * fix(agents): don't inject A2A turns into isolated-cron sessions_send (openclaw#92257) Fire-and-forget sessions_send (timeoutSeconds === 0) with announce delivery runs the A2A ping-pong loop. For a cross-session send (requester != target) the loop's first iteration feeds the target agent's reply back into the requester session as a new turn. For a normal requester that roundtrip is intended, but for an *isolated cron* requester it injects reply context into the isolated run and causes an agent feedback loop. Narrow the fix to isolated-cron requesters only (detected by a session key containing ":cron:" or channel "cron" -- the same signal used by src/agents/subagent-registry.ts and set in src/cron/isolated-agent/run.ts), NOT by timeoutSeconds. Gating on timeoutSeconds was too broad: it disabled the intended ping-pong for normal cross-session fire-and-forget sends. Two hunks in src/agents/tools/sessions-send-tool.ts, both reusing one `isIsolatedCronRequester` gate: 1. Force maxPingPongTurns to 0 in the runSessionsSendA2AFlow invocation only for an isolated-cron requester. The a2a flow's ping-pong guard (`maxPingPongTurns > 0`) then skips the requester-injection loop and proceeds straight to the announce step in the TARGET session, preserving fire-and-forget announce delivery. Normal requesters keep the configured turn count. 2. Read baselineReply for fire-and-forget sends when same-session (prior behavior) OR isolated-cron. Without a baseline fingerprint, a2a.ts would treat pre-existing assistant text in the target session (e.g. an unrelated concurrent cron's output) as "the reply" and misattribute it. The normal cross-session fire leg's history-call count is unchanged from origin/main. Read failures are tolerated so a snapshot error never blocks accepting the send. Tests (sessions.test.ts): an isolated-cron cross-session fire-and-forget forwards maxPingPongTurns: 0; a normal-requester regression guard (discord:group:req / channel discord) forwards the configured turn count (not 0), proving the normal ping-pong is preserved. a2a flow tests (sessions-send-tool.a2a.test.ts): with turns=0 + requester!=target the requester is never stepped but the target is still announced, and a baseline-matching reply is neither injected nor announced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agents): gate isolated-cron A2A on canonical cron-run classifier (openclaw#92257) Address Codex [P2] review: replace the raw `:cron:` substring detector with the canonical isCronRunSessionKey so a non-canonical cron-like requester key (e.g. agent:main:slack:cron:job:run:uuid) keeps its intended cross-session ping-pong. Keep the requesterChannel === "cron" arm (isolated cron runs set that channel in src/cron/isolated-agent/run.ts). Add a regression covering the non-canonical key. * refactor(agents): drop dead cron-channel A2A arm, gate on canonical key only (openclaw#92257) The requesterChannel === "cron" arm was unreachable: agentChannel is always a DeliverableMessageChannel from resolveGatewayMessageChannel(messageProvider), never the literal "cron". The channel: "cron" in src/cron/isolated-agent/run.ts labels diagnostics events/lifecycle, not the tool channel. Gate on isCronRunSessionKey alone and fix the misleading comment. Tests drove the dead arm via agentChannel/requesterChannel "cron" plus a non-canonical key (agent:main:cron:run:abc, which isCronRunSessionKey rejects). Switch them to a canonical cron-run key (agent:main:cron:job:run:abc) and a normal delivery channel so they exercise the real production gate. * fix(agents): align cron A2A fallback baselines * chore: prepare branch refresh --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
…penclaw#92257) (openclaw#92283) * fix(agents): don't inject A2A turns into isolated-cron sessions_send (openclaw#92257) Fire-and-forget sessions_send (timeoutSeconds === 0) with announce delivery runs the A2A ping-pong loop. For a cross-session send (requester != target) the loop's first iteration feeds the target agent's reply back into the requester session as a new turn. For a normal requester that roundtrip is intended, but for an *isolated cron* requester it injects reply context into the isolated run and causes an agent feedback loop. Narrow the fix to isolated-cron requesters only (detected by a session key containing ":cron:" or channel "cron" -- the same signal used by src/agents/subagent-registry.ts and set in src/cron/isolated-agent/run.ts), NOT by timeoutSeconds. Gating on timeoutSeconds was too broad: it disabled the intended ping-pong for normal cross-session fire-and-forget sends. Two hunks in src/agents/tools/sessions-send-tool.ts, both reusing one `isIsolatedCronRequester` gate: 1. Force maxPingPongTurns to 0 in the runSessionsSendA2AFlow invocation only for an isolated-cron requester. The a2a flow's ping-pong guard (`maxPingPongTurns > 0`) then skips the requester-injection loop and proceeds straight to the announce step in the TARGET session, preserving fire-and-forget announce delivery. Normal requesters keep the configured turn count. 2. Read baselineReply for fire-and-forget sends when same-session (prior behavior) OR isolated-cron. Without a baseline fingerprint, a2a.ts would treat pre-existing assistant text in the target session (e.g. an unrelated concurrent cron's output) as "the reply" and misattribute it. The normal cross-session fire leg's history-call count is unchanged from origin/main. Read failures are tolerated so a snapshot error never blocks accepting the send. Tests (sessions.test.ts): an isolated-cron cross-session fire-and-forget forwards maxPingPongTurns: 0; a normal-requester regression guard (discord:group:req / channel discord) forwards the configured turn count (not 0), proving the normal ping-pong is preserved. a2a flow tests (sessions-send-tool.a2a.test.ts): with turns=0 + requester!=target the requester is never stepped but the target is still announced, and a baseline-matching reply is neither injected nor announced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agents): gate isolated-cron A2A on canonical cron-run classifier (openclaw#92257) Address Codex [P2] review: replace the raw `:cron:` substring detector with the canonical isCronRunSessionKey so a non-canonical cron-like requester key (e.g. agent:main:slack:cron:job:run:uuid) keeps its intended cross-session ping-pong. Keep the requesterChannel === "cron" arm (isolated cron runs set that channel in src/cron/isolated-agent/run.ts). Add a regression covering the non-canonical key. * refactor(agents): drop dead cron-channel A2A arm, gate on canonical key only (openclaw#92257) The requesterChannel === "cron" arm was unreachable: agentChannel is always a DeliverableMessageChannel from resolveGatewayMessageChannel(messageProvider), never the literal "cron". The channel: "cron" in src/cron/isolated-agent/run.ts labels diagnostics events/lifecycle, not the tool channel. Gate on isCronRunSessionKey alone and fix the misleading comment. Tests drove the dead arm via agentChannel/requesterChannel "cron" plus a non-canonical key (agent:main:cron:run:abc, which isCronRunSessionKey rejects). Switch them to a canonical cron-run key (agent:main:cron:job:run:abc) and a normal delivery channel so they exercise the real production gate. * fix(agents): align cron A2A fallback baselines * chore: prepare branch refresh --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
…penclaw#92257) (openclaw#92283) * fix(agents): don't inject A2A turns into isolated-cron sessions_send (openclaw#92257) Fire-and-forget sessions_send (timeoutSeconds === 0) with announce delivery runs the A2A ping-pong loop. For a cross-session send (requester != target) the loop's first iteration feeds the target agent's reply back into the requester session as a new turn. For a normal requester that roundtrip is intended, but for an *isolated cron* requester it injects reply context into the isolated run and causes an agent feedback loop. Narrow the fix to isolated-cron requesters only (detected by a session key containing ":cron:" or channel "cron" -- the same signal used by src/agents/subagent-registry.ts and set in src/cron/isolated-agent/run.ts), NOT by timeoutSeconds. Gating on timeoutSeconds was too broad: it disabled the intended ping-pong for normal cross-session fire-and-forget sends. Two hunks in src/agents/tools/sessions-send-tool.ts, both reusing one `isIsolatedCronRequester` gate: 1. Force maxPingPongTurns to 0 in the runSessionsSendA2AFlow invocation only for an isolated-cron requester. The a2a flow's ping-pong guard (`maxPingPongTurns > 0`) then skips the requester-injection loop and proceeds straight to the announce step in the TARGET session, preserving fire-and-forget announce delivery. Normal requesters keep the configured turn count. 2. Read baselineReply for fire-and-forget sends when same-session (prior behavior) OR isolated-cron. Without a baseline fingerprint, a2a.ts would treat pre-existing assistant text in the target session (e.g. an unrelated concurrent cron's output) as "the reply" and misattribute it. The normal cross-session fire leg's history-call count is unchanged from origin/main. Read failures are tolerated so a snapshot error never blocks accepting the send. Tests (sessions.test.ts): an isolated-cron cross-session fire-and-forget forwards maxPingPongTurns: 0; a normal-requester regression guard (discord:group:req / channel discord) forwards the configured turn count (not 0), proving the normal ping-pong is preserved. a2a flow tests (sessions-send-tool.a2a.test.ts): with turns=0 + requester!=target the requester is never stepped but the target is still announced, and a baseline-matching reply is neither injected nor announced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agents): gate isolated-cron A2A on canonical cron-run classifier (openclaw#92257) Address Codex [P2] review: replace the raw `:cron:` substring detector with the canonical isCronRunSessionKey so a non-canonical cron-like requester key (e.g. agent:main:slack:cron:job:run:uuid) keeps its intended cross-session ping-pong. Keep the requesterChannel === "cron" arm (isolated cron runs set that channel in src/cron/isolated-agent/run.ts). Add a regression covering the non-canonical key. * refactor(agents): drop dead cron-channel A2A arm, gate on canonical key only (openclaw#92257) The requesterChannel === "cron" arm was unreachable: agentChannel is always a DeliverableMessageChannel from resolveGatewayMessageChannel(messageProvider), never the literal "cron". The channel: "cron" in src/cron/isolated-agent/run.ts labels diagnostics events/lifecycle, not the tool channel. Gate on isCronRunSessionKey alone and fix the misleading comment. Tests drove the dead arm via agentChannel/requesterChannel "cron" plus a non-canonical key (agent:main:cron:run:abc, which isCronRunSessionKey rejects). Switch them to a canonical cron-run key (agent:main:cron:job:run:abc) and a normal delivery channel so they exercise the real production gate. * fix(agents): align cron A2A fallback baselines * chore: prepare branch refresh --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Summary
sessions_sendran the A2A ping-pong loop for every requester, and for an isolated cron requester its first iteration fed the target agent's reply back into the isolated cron session as a new turn, corrupting that run ([Bug]: sessions_send with announce delivery injects Argus reply context into isolated cron session, causing agent feedback loop #92257).:cron:session-key marker orcronchannel (the same signal used bysrc/cron/isolated-agent/run.tsandsrc/agents/subagent-registry.ts), NOT bytimeoutSeconds. Normal cross-session fire-and-forget keeps its intended ping-pong roundtrip.sessions_sendonly announces in the target; it never injects the target's reply back into the cron run.isIsolatedCronRequestergate and that it does not over-reach into normal requesters.Linked context
Closes #92257
Related #91271 (open — edits
sessions-send-tool.tsnear this area; this PR adds anisIsolatedCronRequestergate around the existingmaxPingPongTurns/baseline lines, so a textual rebase against whichever lands first is expected), #76490 (open — rewritessessions-send-tool.a2a.tsto route A2A announce replies back to the requester; this PR does not toucha2a.ts), #83094 (open — touches nearby sessions-send files). None of these fixes the isolated-cron injection in #92257.Was this requested by a maintainer or owner? No.
Real behavior proof (required for external PRs)
Behavior or issue addressed: Fire-and-forget cross-session
sessions_sendwith announce delivery ([Bug]: sessions_send with announce delivery injects Argus reply context into isolated cron session, causing agent feedback loop #92257). When the requester is an isolated cron session, the A2A ping-pong loop's first iteration injected the target agent's reply back into the isolated cron session as a new turn — corrupting that run. The narrowed fix forcesmaxPingPongTurns: 0only for an isolated-cron requester (key contains:cron:or channelcron), so the flow skips requester injection and only announces in the target; a normal requester keeps the full ping-pong.Real environment tested: A real
nodev22.19.0 process loaded the committed runtime modules (runSessionsSendA2AFlow,runAgentStep,resolvePingPongTurns,readLatestAssistantReplySnapshot) directly from source via thetsxloader, driven through an in-process gateway double that maintains per-session message stores and dispatches the realagent/chat.history/agent.wait/sendmethods. The harness reproduces the exact committed gate fromsrc/agents/tools/sessions-send-tool.ts—isIsolatedCronRequester = requesterSessionKey?.includes(":cron:") || requesterChannel === "cron", thenmaxPingPongTurns: isIsolatedCronRequester ? 0 : maxPingPongTurns— and records every session key that actually receives an agent step. This is genuine runtime execution of the production code path. Two requester legs were driven against the branch: an isolated-cron requesteragent:youtube:cron:bf05dd0f:run:b41f8df6(channelcron) and a normal requesterdiscord:group:req(channeldiscord), both to a different targetagent:main:main. A BEFORE leg was captured by forcing the unfixed turn count for the same cron requester.Exact steps or command run after this patch: From the worktree, with the real Node runtime, ran the harness once per leg —
source ~/.nvm/nvm.sh && nvm use 22.19 && node --import tsx /tmp/proof-92257-narrow/harness.mjs /tmp/openclaw-impl-92257 "agent:youtube:cron:bf05dd0f:run:b41f8df6" cronfor the isolated-cron leg andnode --import tsx /tmp/proof-92257-narrow/harness.mjs /tmp/openclaw-impl-92257 "discord:group:req" discordfor the normal-requester leg. Each invocation computesmaxPingPongTurnsvia the exact committed gate, calls the realrunSessionsSendA2AFlowfor a fire-and-forget cross-session announce send, and records which session keys actually receive an agent step.Evidence after fix (screenshot, recording, terminal capture, console output, redacted runtime log, linked artifact, or copied live output): Copied live console output from the real
noderuns.LEG A — isolated-cron requester, the bug (
agent:youtube:cron:bf05dd0f:run:b41f8df6, channelcron):command:
node --import tsx /tmp/proof-92257-narrow/harness.mjs /tmp/openclaw-impl-92257 "agent:youtube:cron:bf05dd0f:run:b41f8df6" cronLEG B — normal requester, regression guard (
discord:group:req, channeldiscord):command:
node --import tsx /tmp/proof-92257-narrow/harness.mjs /tmp/openclaw-impl-92257 "discord:group:req" discordObserved result after fix: LEG A — the isolated-cron requester
agent:youtube:cron:bf05dd0f:run:b41f8df6receives 0 agent steps (the injection loop is skipped because the:cron:marker forces the turn count to 0), while the targetagent:main:mainstill receives its single announce step. LEG B — the normal requesterdiscord:group:reqstill receives 1 agent step (the configured turn count stays at 5 because the:cron:gate does not fire), and the target is still announced. The two legs together show the fix is narrow: it stops the requester injection for an isolated cron run only, and leaves the normal cross-session roundtrip untouched.What was not tested: A full end-to-end run over a real external channel (Slack/Discord/Telegram) was not exercised — no live channel credentials are available, so the final announce transport hop was driven through the in-process gateway
senddouble rather than a real network transport. The requester-injection behavior that [Bug]: sessions_send with announce delivery injects Argus reply context into isolated cron session, causing agent feedback loop #92257 fixes (the agent step into the requester session) is fully exercised at runtime above.Proof limitations or environment constraints: Modules were loaded from TypeScript source via the
tsxloader rather than a prebuiltdist; the source is what the build emits and runs in a realnodev22.19.0 process. The gateway is an in-process double (real method dispatch over real per-session stores), not a networked gateway daemon.Before evidence (optional but encouraged): Same isolated-cron requester with the unfixed turn count, captured via the harness's
origin-mainmode (node --import tsx /tmp/proof-92257-narrow/harness.mjs /tmp/openclaw-impl-92257 "agent:youtube:cron:bf05dd0f:run:b41f8df6" cron origin-main):Tests and validation
node scripts/run-vitest.mjs src/agents/openclaw-tools.sessions.test.ts src/agents/tools/sessions.test.ts src/agents/tools/sessions-send-tool.a2a.test.ts→ 3 files, 91 cases green.pnpm check:test-types(tsgo over the touched test files) → clean.:cron:key /cronchannel) forwardsmaxPingPongTurns: 0and the requester is never stepped while the target is still announced; (2) a normaldiscord:group:reqrequester keeps the configuredmaxPingPongTurns(not 0), pinning that the narrowing does not over-reach; (3) the pre-existingopenclaw-tools.sessions.test.ts"supports fire-and-forget and wait" test (which asserts the normal-requester ping-pong) stays green.timeoutSeconds === 0and made the normal-requester fire-and-forget skip its ping-pong, failingopenclaw-tools.sessions.test.ts; narrowing the gate to isolated-cron requesters resolves that.Risk checklist
sessions_sendannounce no longer injects the target's reply back into the cron run. Normal requester behavior is unchanged.isIsolatedCronRequesterdetection could in principle be too broad/narrow. Mitigated: it reuses the established:cron:-key /cron-channel signal (src/agents/subagent-registry.ts,src/cron/isolated-agent/run.ts), and the normal-requester regression test + the two-leg runtime proof both confirm normal requesters are untouched.Current review state
sessions-send-tool.ts).