fix(router): coalesce PM create→update webhooks to stop JIRA double-firing agents#1179
fix(router): coalesce PM create→update webhooks to stop JIRA double-firing agents#1179zbigniewsobiecki merged 1 commit intodevfrom
Conversation
… double-firing agents
When a user creates a JIRA issue directly in a non-default workflow column,
JIRA emits two webhooks ~hundreds of ms apart: `issue_created` at the
workflow's initial status, then `issue_updated` transitioning to the target
column. Each webhook resolves a different agent via `STATUS_TO_AGENT`, so
both fire on the same work item (observed as UA-11: implementation +
planning running concurrently).
Fix: router-level coalesce window keyed by `${projectId}:${workItemId}`.
A `pm:status-changed` create trigger waits `PM_CREATE_COALESCE_WINDOW_MS`
(default 2000, 0 disables) before enqueue; an update for the same key
within the window supersedes the create — no ack posted, no job queued,
`onBlocked` called to clear any dedup markers. Trivially also protects
Linear against the same code-shape risk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
The coalescing logic is clean, robust, and correctly solves the JIRA double-enqueue problem for mapped columns. The create-coalesce-window.ts implementation handles race conditions and cleanup perfectly. However, there is a subtle correctness bug in how TriggerHandler.handle() returns null for unmapped JIRA status updates that defeats the fix when a user creates an issue directly into a non-triggering column (like "Done").
Architecture & Design
- [BLOCKING] Coalesce bypass on unmapped updates: JIRA issues created in non-triggering columns will still erroneously fire the default column's agent because the follow-up
updatewebhook gets dropped by the trigger handler before it can clear the coalesce window.
Code Issues
Blocking
-
src/triggers/jira/status-changed.ts — The current implementation of
handle()returnsnullwhen a status update does not map to an enabled agent. Because it returnsnull,processRouterWebhookreceives noTriggerResult, exits early, and never executes Step 7b (clearPendingCreate).The Bug Scenario: If a user creates a JIRA issue directly in a non-triggering column (e.g. "Done"), JIRA emits
issue_createdfor the default initial workflow column (e.g. "To Do", which maps toimplementation), immediately followed byissue_updatedfor the target "Done" column.issue_created("To Do") matches theimplementationagent, returns aTriggerResultwithcoalesceRole: 'create', and enters the 2-second wait window.issue_updated("Done") is received. Since "Done" doesn't map to an enabled agent,handle()returnsnull.processRouterWebhookexits without callingclearPendingCreate().- After 2 seconds, the
implementationagent fires for the issue that was created in "Done"!
The Fix: When
handle()processes a status update that doesn't trigger an agent, it should return a "coalesce-only"TriggerResultwithagentType: nullinstead ofnull. This ensuresprocessRouterWebhookstill clears the pending create, and then safely exits at theif (!result.agentType)check without queueing a job.
Should Fix
- src/triggers/linear/status-changed.ts — The exact same issue exists for Linear. Even though Linear bundles creation today, since we are adding belt-and-braces protection here,
LinearStatusChangedTrigger.handle()should also return anagentType: nullcoalesce result instead ofnullwhen a state transition doesn't match an agent, to maintain symmetry and robustness.
Nitpicks
src/pm/create-coalesce-window.ts: Excellent test isolation mechanism with__resetCoalesceWindowForTests(). Timer state management is very clean.
🕵️ opencode · google/gemini-3.1-pro-preview · run details
nhopeatall
left a comment
There was a problem hiding this comment.
Summary
The coalescing logic is clean, robust, and correctly solves the JIRA double-enqueue problem for mapped columns. The create-coalesce-window.ts implementation handles race conditions and cleanup perfectly. However, there is a subtle correctness bug in how TriggerHandler.handle() returns null for unmapped JIRA status updates that defeats the fix when a user creates an issue directly into a non-triggering column (like "Done").
Architecture & Design
- [BLOCKING] Coalesce bypass on unmapped updates: JIRA issues created in non-triggering columns will still erroneously fire the default column's agent because the follow-up
updatewebhook gets dropped by the trigger handler before it can clear the coalesce window.
Code Issues
Blocking
-
src/triggers/jira/status-changed.ts:98 — The current implementation of
handle()returnsnullwhen a status update does not map to an enabled agent. Because it returnsnull,processRouterWebhookreceives noTriggerResult, exits early, and never executes Step 7b (clearPendingCreate).The Bug Scenario: If a user creates a JIRA issue directly in a non-triggering column (e.g. "Done"), JIRA emits
issue_createdfor the default initial workflow column (e.g. "To Do", which maps toimplementation), immediately followed byissue_updatedfor the target "Done" column.issue_created("To Do") matches theimplementationagent, returns aTriggerResultwithcoalesceRole: 'create', and enters the 2-second wait window.issue_updated("Done") is received. Since "Done" doesn't map to an enabled agent,handle()returnsnull.processRouterWebhookexits without callingclearPendingCreate().- After 2 seconds, the
implementationagent fires for the issue that was created in "Done"!
The Fix: When
handle()processes a status update that doesn't trigger an agent, it should return a "coalesce-only"TriggerResultwithagentType: nullinstead ofnull. This ensuresprocessRouterWebhookstill clears the pending create, and then safely exits at theif (!result.agentType)check without queueing a job.// Example for JiraStatusChangedTrigger.handle() const coalesceUpdateResult = isCreateEvent(payload) ? null : { agentType: null, agentInput: { workItemId: issueKey }, // minimal valid AgentInput coalesceKey: `${ctx.project.id}:${issueKey}`, coalesceRole: 'update' as const, }; const agentType = resolveAgentType(newStatus, jiraConfig.statuses); if (!agentType) return coalesceUpdateResult; // Instead of return null; const { enabled, parameters } = await checkTriggerEnabledWithParams(...); if (!enabled) return coalesceUpdateResult; // Instead of return null; const isCreate = isCreateEvent(payload); if (!shouldFireOnEvent(isCreate, parameters)) return coalesceUpdateResult; // Instead of return null;
Should Fix
- src/triggers/linear/status-changed.ts:88 — The exact same issue exists for Linear. Even though Linear bundles creation today, since we are adding belt-and-braces protection here,
LinearStatusChangedTrigger.handle()should also return anagentType: nullcoalesce result instead ofnullwhen a state transition doesn't match an agent, to maintain symmetry and robustness.
Nitpicks
- src/pm/create-coalesce-window.ts — Excellent test isolation mechanism with
__resetCoalesceWindowForTests(). Timer state management is very clean.
🕵️ opencode · google/gemini-3.1-pro-preview · run details
Summary
implementationandplanning) on the same work item.issue_createdat the workflow's initial status, thenissue_updatedtransitioning to the target column. Each resolves a differentSTATUS_TO_AGENTentry, each enqueues one job.Evidence
Loki capture of UA-11 (ua-store) reproduces the bug exactly:
Scope
Files
Short-term mitigation (before this ships)
`cascade projects trigger-set --agent implementation --event pm:status-changed --enable --params '{"onCreate":false,"onMove":true}'` — stops implementation firing on the ephemeral initial status; planning still fires on the transition.
Test plan
🤖 Generated with Claude Code