Skip to content

fix(task-board): don't stomp a human reassignment when un-delegating a column-rule claim - #6828

Merged
pedrofrxncx merged 2 commits into
mainfrom
fix/column-automation-undelegate-race-w1
Sep 1, 2026
Merged

fix(task-board): don't stomp a human reassignment when un-delegating a column-rule claim#6828
pedrofrxncx merged 2 commits into
mainfrom
fix/column-automation-undelegate-race-w1

Conversation

@pedrofrxncx

@pedrofrxncx pedrofrxncx commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Follows #6800 (runColumnAutomation), which introduced the shared column-rule claim path both the Jira sync and a manual drag now go through.

Bug: runColumnAutomation() claims an unassigned card for the Super Agent with a conditional UPDATE (WHERE assignee_id IS NULL), then calls reactToSuperAgentDelegation(), which can take a while (dispatch/quota check) or throw TaskQuotaError. On that throw it un-delegated with a plain update({ assigneeId: null, assignedBy: null }) — unconditional. If a human reassigned the card in that window (e.g. via TASK_BOARD_ITEM_UPDATE, in the gap between the claim and the quota check), this write silently wiped their reassignment back to null, losing the human's edit.

Fix: storage.taskBoard already has the right primitive for exactly this race — unassignSuperAgent(), a conditional UPDATE (WHERE assignee_id = SUPER_AGENT_ASSIGNEE_ID) already used by handTaskToHuman() to solve the identical problem (see its doc comment: "a plain update() would stomp that human's reassignment back to null"). Swapped the plain update() for it in run-column-automation.ts too, so un-delegation only takes effect if the card is still assigned to the Super Agent; otherwise it leaves the human's reassignment alone.

Failure scenario: column rule claims card X for the Super Agent → dispatch is mid-flight and the org's quota is exhausted → a teammate manually reassigns X to themselves in the meantime → the quota rejection's un-delegate write lands after and clobbers the teammate's assignment back to unassigned, silently losing their action.

How a reviewer confirms: rg unassignSuperAgent apps/api/src/tools/task-board — both call sites (run-reactions.ts's handTaskToHuman, and now run-column-automation.ts) share the same conditional fence and doc-comment rationale.

Checks run locally: bun run fmt, cd apps/api && bunx tsc --noEmit (clean), bunx oxlint apps/api/src/tools/task-board/run-column-automation.ts (0 warnings/errors). No new test: this reuses unassignSuperAgent(), an existing storage primitive already exercised by handTaskToHuman()'s integration coverage — this sandbox has no Postgres to add a new DB-backed test, and the fix is a one-line swap onto an already-tested method. Full CI validates the integration suite.


Summary by cubic

Fixes a race where un-delegating a column-rule claim could overwrite a human's concurrent reassignment, reusing the conditional unassignSuperAgent() primitive instead of a plain update.

  • On a lost fence, re-reads the card before returning so the response doesn't render the human's reassignment as reverted.
  • The conditional un-delegate now also clears assignedBy, fixing stale delegation metadata on unassigned cards (also on the handTaskToHuman path).
  • Adds a real-Postgres integration test covering both the winning clear and the lost-fence case.

Written for commit 325903a. Summary will update on new commits.

Review in cubic

…a column-rule claim

runColumnAutomation() claims an unassigned card for the Super Agent with a
conditional UPDATE (assignee_id IS NULL), then calls
reactToSuperAgentDelegation(), which can take a while (dispatch, quota
check) or throw a TaskQuotaError. On that throw it un-delegated with a
plain update({ assigneeId: null, assignedBy: null }) — unconditional, so
if a human reassigned the card in that window (e.g. via
TASK_BOARD_ITEM_UPDATE) while the claim was in flight, this write silently
wiped their reassignment back to null.

storage.taskBoard already has the right primitive for exactly this:
unassignSuperAgent(), a conditional UPDATE (WHERE assignee_id =
SUPER_AGENT_ASSIGNEE_ID) used by handTaskToHuman() for the identical
race. Swapped the plain update() for it here too, so un-delegation only
takes effect if the card is still assigned to the Super Agent.

Verified: bunx tsc --noEmit and oxlint on the touched file are clean.
unassignSuperAgent() itself is exercised by handTaskToHuman()'s existing
integration coverage; this PR reuses that same tested primitive rather
than adding new DB-dependent test infra this sandbox can't run.

Reviewer check: rg unassignSuperAgent apps/api/src/tools/task-board — both call sites now share the same fence.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/api/src/tools/task-board/run-column-automation.ts">

<violation number="1" location="apps/api/src/tools/task-board/run-column-automation.ts:77">
P2: When the quota rejection unassigns a card, the new helper clears only `assigneeId` and leaves `assignedBy` from the failed delegation. Preserve the conditional fence but also clear `assigned_by` in the successful `unassignSuperAgent()` update so unassigned cards do not retain stale delegation metadata.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/api/src/tools/task-board/run-column-automation.ts Outdated
);
return await ctx.storage.taskBoard.update(
// Same conditional fence as `handTaskToHuman` — never stomp a concurrent reassignment.
const undelegated = await ctx.storage.taskBoard.unassignSuperAgent(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the quota rejection unassigns a card, the new helper clears only assigneeId and leaves assignedBy from the failed delegation. Preserve the conditional fence but also clear assigned_by in the successful unassignSuperAgent() update so unassigned cards do not retain stale delegation metadata.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/tools/task-board/run-column-automation.ts, line 77:

<comment>When the quota rejection unassigns a card, the new helper clears only `assigneeId` and leaves `assignedBy` from the failed delegation. Preserve the conditional fence but also clear `assigned_by` in the successful `unassignSuperAgent()` update so unassigned cards do not retain stale delegation metadata.</comment>

<file context>
@@ -73,12 +73,13 @@ export async function runColumnAutomation(
     );
-    return await ctx.storage.taskBoard.update(
+    // Same conditional fence as `handTaskToHuman` — never stomp a concurrent reassignment.
+    const undelegated = await ctx.storage.taskBoard.unassignSuperAgent(
       item.id,
       orgId,
</file context>

…assignedBy

Addresses cubic review on #6828.

P1: `unassignSuperAgent()` returns null when a human won the reassignment
race, and the `?? delegated` fallback then handed back the stale Super Agent
snapshot — Jira/clients would render the human's reassignment as reverted.
Re-read the row instead.

P2: `unassignSuperAgent()` cleared `assignee_id` but left `assigned_by` from
the failed delegation, so an unassigned card kept stale delegation metadata.
Clear it inside the same conditional UPDATE, which also fixes the identical
leak on `handTaskToHuman`'s path. `enqueue-task-run`/`nudge-thread`/
`review-sweeper` all read it as `assignedBy ?? createdBy`, so the fallback
already covers the null.

Test: real-Postgres case in task-board-column-claim.integration.test.ts
covering both the winning clear and the lost fence leaving a human's card
untouched.
@pedrofrxncx
pedrofrxncx enabled auto-merge (squash) September 1, 2026 19:22
@pedrofrxncx
pedrofrxncx merged commit 81111bc into main Sep 1, 2026
32 of 33 checks passed
@pedrofrxncx
pedrofrxncx deleted the fix/column-automation-undelegate-race-w1 branch September 1, 2026 19:24
decocms Bot pushed a commit that referenced this pull request Sep 1, 2026
PR: #6828 fix(task-board): don't stomp a human reassignment when un-delegating a column-rule claim
Bump type: patch

- decocms (apps/api/package.json): 4.315.4 -> 4.315.5
- @decocms/native (apps/native/package.json): 4.315.4 -> 4.315.5

Deploy-Scope: server
pedrofrxncx added a commit that referenced this pull request Sep 1, 2026
…ion (#6835)

* fix(task-board): don't stomp a human claim in the PR-open board reaction

applyBoardDecision reads openCards (and target.assigneeId) before calling
the LLM to decide create-vs-update, which can take seconds. It then wrote
claimSuperAgent based on that stale assigneeId with a plain storage.update()
— no WHERE fence — so a human claiming the card while the LLM call was in
flight got silently overwritten back to the Super Agent.

storage.taskBoard.claimUnassignedForSuperAgent() is the exact primitive
#6828 just used to fix the identical race in runColumnAutomation (a
conditional UPDATE requiring assignee_id IS NULL and the card's original
status). Reused it here: the claim now happens through that fence, and the
follow-up storage.update() only touches status, never assignee fields.

Added an integration test simulating the race (stale openCards snapshot,
then a human claim lands before applyBoardDecision runs) asserting the
human's assignment survives.

* fix(task-board): fence the PR-open advance on the card's live status

The claim added in the previous commit could lose its race and return
null while the status write that followed went ahead unconditionally,
replaying an advance decided against a snapshot taken before the LLM
call — dragging a card someone had since shipped back to In Progress.

Fold both writes into one conditional UPDATE fenced on that snapshot's
status, claiming for the Super Agent only when the row is still
unowned. Losing the race now links the PR onto the card as it stands
instead of moving it.

---------

Co-authored-by: Pedro França <pedrofrxncx@deco.cx>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant