feat(task-board): run a column's rule when a card lands there, however it moved - #6800
Conversation
…r it moved
The rule lives on the column, but only the Jira pull ever read it. Dragging a
card into that column in Studio did nothing — which is the gesture the feature
was described by ("in column X, run the agent with prompt Y when a card
arrives") and the one it did not cover.
`runColumnAutomation` is now the single act, and both the pull and
`TASK_BOARD_ITEM_UPDATE` go through it, so a column cannot mean one thing to
the sync and another to the board. Fires only on an actual lane change, and
never on a card someone already owns.
This also fixes a bug the sharing made obvious. The rule was looked up by the
card's status and then claimed on the board's QUEUE lane, so a rule on any
other column found itself, tried to claim somewhere the card was not, matched
nothing and did nothing. The claim now fences on the column the rule is on —
and because both the lookup and the fence read `item.status` in one function,
they can no longer diverge. That is structural rather than tested: an
integration test covers the fence's contract, but nothing stops a future
caller passing the wrong lane except there no longer being a lane to pass.
Settings → Tasks gets "When a card lands in a column": one switch per column
with an optional instruction, beside the system prompt from #6794. Shown only
for a board whose columns are the org's own — on Studio's board the lifecycle
is already decided, and a switch per canonical lane would invite a team to turn
off the thing that makes the board work.
Verified in a browser against real Postgres: the switch and prompt persist, and
moving a card into the configured column by hand delegates it to the Super
Agent. The run itself does not start locally (`TierUnavailableError`, no model
provider configured here), which surfaced something pre-existing worth naming:
`reactToSuperAgentDelegation` re-throws only `TaskQuotaError`, so the
un-delegate on failure covers quota alone. Every other dispatch failure leaves
the card assigned to an agent that will never run — the state its comment says
it prevents. Left alone deliberately; widening what is fatal in dispatch is its
own change.
…ther Two sections listed the same columns of the same board — one asking what a column means, the other whether the agent runs there. That reads as two settings when it is two halves of one, and the split is what made the meanings look like triggers. Now one "Board columns" section, one row per column: its meaning, and the switch. The Jira card no longer restates half of it; a board using Studio's lanes still gets the status mapping there, which is genuinely Jira's business. Also fixes a label I made wrong earlier today. "Queued for the agent" described the `todo` role back when the auto-delegate fenced on it. Since the fence moved to the column the rule is on, that role has exactly one remaining use — where a card returns when the agent gives up — so the label now says that. A role that sounds like a trigger is the specific confusion this change exists to remove: every meaning is a PLACE the lifecycle puts a card, and only the switch starts anything. Verified in a browser: one section, five rows for the mirrored board's columns, the select and the switch each persisting from the same row, and no duplicate under the Jira integration.
|
Merged the two sections after review feedback — they listed the same columns of Now one Board columns section, one row per column:
The Jira card no longer restates half of it. A board using Studio's lanes still Also fixes a label this branch made wrong. "Queued for the agent" described the |
…ngs do
Two things, both about a column being described in Studio's words rather than
the board's.
`ADVANCEABLE` was a hardcoded `{triage, todo, in_progress}` — the lanes a card
may still be pushed into the review phase from. On a mirrored board a card's
column is `Fazendo` or `Code Review`, so the set matched nothing and no card
ever advanced when its PR opened. Same bug as the rest of this arc, missed
because it is a `Set.has` rather than an `===`.
`canAdvance` asks the board instead: is the card's column at or before the
target's, in the board's own order. Every board has ordered columns — a
tracker's are ordered by the tracker — so "has it got past this yet" is
answerable anywhere. A test asserts the canonical board yields exactly the
three lanes the set held, which is what makes the swap a refactor for anyone
not mirroring. A column the board does not have is not advanceable at either
end: a card nobody can place is not one to move.
The settings section now says what happens instead of naming the schema. The
role select was a list of meanings, which read as triggers — "Queued for the
agent" sounds like it queues something. It is now "Move cards here when …"
followed by the EVENT Studio detects, which is the thing actually being chosen:
the agent starts, the agent is done and needs a person, the work shipped, the
agent gave up.
The automation is an add-and-delete rather than a switch, because that is what
the storage does — a row exists or it does not, and deleting is the off switch.
Adding it opens the instruction box under the column with a line saying the
card's title and description already come with it, so the box is the
instruction rather than the whole message.
|
Two more, from review. A PR-open never advanced a card on a mirrored board
The settings now say what happensThe select was a list of meanings, which read as triggers. "Queued for the It is now the event we detect, which is the thing actually being chosen:
The automation became add-and-delete rather than a switch, matching what the Verified in a browser: adding creates the row, deleting removes it, and the |
"The agent starts working on it" describes a state of the world; the person choosing it wants to know what we watch for. Each option is now the event itself: A run starts on it, or a pull request opens The agent's run finishes Its pull requests merged and it sat untouched for five days A run fails with no retries left Nothing — I move cards here myself The five-day one is the archive sweep's actual threshold, and the first names both of its triggers on purpose: a card reaches the in-progress column from a PR opening, a re-run, and the conflict-resolution fence, so "a pull request is opened" alone would have been concrete and wrong. The keys move with the strings — `whenAgentStarts` no longer describes what it holds.
There was a problem hiding this comment.
10 issues found and verified against the latest diff
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/web/src/views/settings/board-columns.tsx">
<violation number="1" location="apps/web/src/views/settings/board-columns.tsx:88">
P2: When the Tasks settings page renders, `useTaskBoardItems()` subscribes to live board and Decopilot events and processes unrelated task updates. Use the column-only query path so this settings view does not open board subscriptions or run board cache/activity handlers.</violation>
<violation number="2" location="apps/web/src/views/settings/board-columns.tsx:92">
P2: When loading the board fails, this branch tells the user that no columns exist and provides no retry. Handle the query error separately from the legitimate empty-columns case.</violation>
<violation number="3" location="apps/web/src/views/settings/board-columns.tsx:205">
P2: When a user edits an instruction and clicks the trash button, the blur save races the delete and can recreate the automation after deletion. Suppress or cancel the blur save while removing the automation, or serialize these writes.</violation>
</file>
<file name="apps/web/src/hooks/use-column-automations.ts">
<violation number="1" location="apps/web/src/hooks/use-column-automations.ts:31">
P2: When the automation list fails on its initial load, this fallback makes the settings screen treat every configured rule as disabled. Clicking Add can then overwrite an existing rule with the default instruction, so expose the query error and keep the controls unavailable until the list loads successfully.</violation>
</file>
<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:61">
P2: When the quota rejects this run, the timeline says the Super Agent owns the card although the catch immediately removes that ownership. Record the assignment only after delegation succeeds, or append a matching rollback activity when the claim is released.</violation>
<violation number="2" location="apps/api/src/tools/task-board/run-column-automation.ts:61">
P1: If the activity insert fails, this function exits after claiming the card and never calls `reactToSuperAgentDelegation`, leaving an assigned card with no run. Make this timeline write best-effort so dispatch still proceeds.</violation>
<violation number="3" location="apps/api/src/tools/task-board/run-column-automation.ts:79">
P1: If someone reassigns the card while delegation is in flight and the delegation then fails, this unconditional update clears that newer assignment and `assignedBy`. Roll back with a conditional claim-release operation that only clears the Super Agent assignment still owned by this automation.</violation>
<violation number="4" location="apps/api/src/tools/task-board/run-column-automation.ts:83">
P2: When a person moves a card into an automated column, other open boards receive the pre-automation item and never learn about the new assignee until refresh. Emit a board update after the conditional claim (and after rollback when applicable), or move the user-path broadcast after automation completes.</violation>
</file>
<file name="apps/api/src/jira/sync.ts">
<violation number="1" location="apps/api/src/jira/sync.ts:334">
P2: When Jira moves an issue between statuses grouped by the same column, this call re-runs the column rule even though the card never landed in a new column. Gate the Jira path on `before?.status !== status` while retaining the create-path behavior.</violation>
</file>
<file name="apps/api/src/tools/task-board/update.ts">
<violation number="1" location="apps/api/src/tools/task-board/update.ts:523">
P2: When the automation lookup, claim, or activity write fails after the status update, this await reports an error after the move is committed and can leave the card unprocessed. Make the trigger retryable or best-effort with claim cleanup, rather than making a one-time status transition the only attempt.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| return await ctx.storage.taskBoard.update( | ||
| item.id, | ||
| orgId, | ||
| { assigneeId: null, assignedBy: null }, |
There was a problem hiding this comment.
P1: If someone reassigns the card while delegation is in flight and the delegation then fails, this unconditional update clears that newer assignment and assignedBy. Roll back with a conditional claim-release operation that only clears the Super Agent assignment still owned by this automation.
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 79:
<comment>If someone reassigns the card while delegation is in flight and the delegation then fails, this unconditional update clears that newer assignment and `assignedBy`. Roll back with a conditional claim-release operation that only clears the Super Agent assignment still owned by this automation.</comment>
<file context>
@@ -0,0 +1,84 @@
+ return await ctx.storage.taskBoard.update(
+ item.id,
+ orgId,
+ { assigneeId: null, assignedBy: null },
+ by.actor,
+ );
</file context>
| ); | ||
| if (!delegated) return item; | ||
|
|
||
| await ctx.storage.taskBoard.recordActivity({ |
There was a problem hiding this comment.
P1: If the activity insert fails, this function exits after claiming the card and never calls reactToSuperAgentDelegation, leaving an assigned card with no run. Make this timeline write best-effort so dispatch still proceeds.
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 61:
<comment>If the activity insert fails, this function exits after claiming the card and never calls `reactToSuperAgentDelegation`, leaving an assigned card with no run. Make this timeline write best-effort so dispatch still proceeds.</comment>
<file context>
@@ -0,0 +1,84 @@
+ );
+ if (!delegated) return item;
+
+ await ctx.storage.taskBoard.recordActivity({
+ taskBoardItemId: item.id,
+ action: "assignee_changed",
</file context>
| const { automations, isPending } = useColumnAutomations(); | ||
|
|
||
| if (isLoading || isPending) return <Skeleton className="h-40 w-full" />; | ||
| if (columns.length === 0) { |
There was a problem hiding this comment.
P2: When loading the board fails, this branch tells the user that no columns exist and provides no retry. Handle the query error separately from the legitimate empty-columns case.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/views/settings/board-columns.tsx, line 90:
<comment>When loading the board fails, this branch tells the user that no columns exist and provides no retry. Handle the query error separately from the legitimate empty-columns case.</comment>
<file context>
@@ -0,0 +1,224 @@
+ const { automations, isPending } = useColumnAutomations();
+
+ if (isLoading || isPending) return <Skeleton className="h-40 w-full" />;
+ if (columns.length === 0) {
+ return (
+ <p className="text-xs text-muted-foreground">
</file context>
|
|
||
| function BoardColumnRows() { | ||
| const t = useT(); | ||
| const { columns, isLoading } = useTaskBoardItems(); |
There was a problem hiding this comment.
P2: When the Tasks settings page renders, useTaskBoardItems() subscribes to live board and Decopilot events and processes unrelated task updates. Use the column-only query path so this settings view does not open board subscriptions or run board cache/activity handlers.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/views/settings/board-columns.tsx, line 86:
<comment>When the Tasks settings page renders, `useTaskBoardItems()` subscribes to live board and Decopilot events and processes unrelated task updates. Use the column-only query path so this settings view does not open board subscriptions or run board cache/activity handlers.</comment>
<file context>
@@ -0,0 +1,224 @@
+
+function BoardColumnRows() {
+ const t = useT();
+ const { columns, isLoading } = useTaskBoardItems();
+ const { automations, isPending } = useColumnAutomations();
+
</file context>
| placeholder={t("settings.boardColumns.promptPlaceholder")} | ||
| onChange={(e) => setDraft(e.target.value)} | ||
| onBlur={() => { | ||
| if (draft !== (prompt ?? "")) saveAutomation(draft); |
There was a problem hiding this comment.
P2: When a user edits an instruction and clicks the trash button, the blur save races the delete and can recreate the automation after deletion. Suppress or cancel the blur save while removing the automation, or serialize these writes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/views/settings/board-columns.tsx, line 203:
<comment>When a user edits an instruction and clicks the trash button, the blur save races the delete and can recreate the automation after deletion. Suppress or cancel the blur save while removing the automation, or serialize these writes.</comment>
<file context>
@@ -0,0 +1,224 @@
+ placeholder={t("settings.boardColumns.promptPlaceholder")}
+ onChange={(e) => setDraft(e.target.value)}
+ onBlur={() => {
+ if (draft !== (prompt ?? "")) saveAutomation(draft);
+ }}
+ data-column-automation-prompt={columnKey}
</file context>
| return automations; | ||
| }, | ||
| }); | ||
| return { automations: query.data ?? [], isPending: query.isPending }; |
There was a problem hiding this comment.
P2: When the automation list fails on its initial load, this fallback makes the settings screen treat every configured rule as disabled. Clicking Add can then overwrite an existing rule with the default instruction, so expose the query error and keep the controls unavailable until the list loads successfully.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/hooks/use-column-automations.ts, line 31:
<comment>When the automation list fails on its initial load, this fallback makes the settings screen treat every configured rule as disabled. Clicking Add can then overwrite an existing rule with the default instruction, so expose the query error and keep the controls unavailable until the list loads successfully.</comment>
<file context>
@@ -0,0 +1,62 @@
+ return automations;
+ },
+ });
+ return { automations: query.data ?? [], isPending: query.isPending };
+}
+
</file context>
| by.actor, | ||
| ); | ||
| } | ||
| return delegated; |
There was a problem hiding this comment.
P2: When a person moves a card into an automated column, other open boards receive the pre-automation item and never learn about the new assignee until refresh. Emit a board update after the conditional claim (and after rollback when applicable), or move the user-path broadcast after automation completes.
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 83:
<comment>When a person moves a card into an automated column, other open boards receive the pre-automation item and never learn about the new assignee until refresh. Emit a board update after the conditional claim (and after rollback when applicable), or move the user-path broadcast after automation completes.</comment>
<file context>
@@ -0,0 +1,84 @@
+ by.actor,
+ );
+ }
+ return delegated;
+}
</file context>
| ); | ||
| if (!delegated) return item; | ||
|
|
||
| await ctx.storage.taskBoard.recordActivity({ |
There was a problem hiding this comment.
P2: When the quota rejects this run, the timeline says the Super Agent owns the card although the catch immediately removes that ownership. Record the assignment only after delegation succeeds, or append a matching rollback activity when the claim is released.
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 61:
<comment>When the quota rejects this run, the timeline says the Super Agent owns the card although the catch immediately removes that ownership. Record the assignment only after delegation succeeds, or append a matching rollback activity when the claim is released.</comment>
<file context>
@@ -0,0 +1,84 @@
+ );
+ if (!delegated) return item;
+
+ await ctx.storage.taskBoard.recordActivity({
+ taskBoardItemId: item.id,
+ action: "assignee_changed",
</file context>
| action: "assignee_changed", | ||
| actorId: null, | ||
| data: { from: null, to: SUPER_AGENT_ASSIGNEE_ID }, | ||
| return runColumnAutomation(ctx, item, { |
There was a problem hiding this comment.
P2: When Jira moves an issue between statuses grouped by the same column, this call re-runs the column rule even though the card never landed in a new column. Gate the Jira path on before?.status !== status while retaining the create-path behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/jira/sync.ts, line 334:
<comment>When Jira moves an issue between statuses grouped by the same column, this call re-runs the column rule even though the card never landed in a new column. Gate the Jira path on `before?.status !== status` while retaining the create-path behavior.</comment>
<file context>
@@ -327,63 +321,20 @@ function mapPriority(issue: JiraIssue): TaskBoardItemPriority {
- action: "assignee_changed",
- actorId: null,
- data: { from: null, to: SUPER_AGENT_ASSIGNEE_ID },
+ return runColumnAutomation(ctx, item, {
+ assignedBy: integration.createdBy,
+ actor: JIRA_SYNC_ACTOR,
</file context>
| previous !== null && | ||
| previous.status !== item.status | ||
| ) { | ||
| item = await runColumnAutomation(ctx, item, { |
There was a problem hiding this comment.
P2: When the automation lookup, claim, or activity write fails after the status update, this await reports an error after the move is committed and can leave the card unprocessed. Make the trigger retryable or best-effort with claim cleanup, rather than making a one-time status transition the only attempt.
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/update.ts, line 523:
<comment>When the automation lookup, claim, or activity write fails after the status update, this await reports an error after the move is committed and can leave the card unprocessed. Make the trigger retryable or best-effort with claim cleanup, rather than making a one-time status transition the only attempt.</comment>
<file context>
@@ -508,6 +509,21 @@ export const TASK_BOARD_ITEM_UPDATE = defineTool({
+ previous !== null &&
+ previous.status !== item.status
+ ) {
+ item = await runColumnAutomation(ctx, item, {
+ assignedBy: getUserId(ctx)!,
+ actor: getUserId(ctx)!,
</file context>
PR: #6800 feat(task-board): run a column's rule when a card lands there, however it moved Bump type: minor - decocms (apps/api/package.json): 4.312.1 -> 4.313.0 - @decocms/native (apps/native/package.json): 4.312.1 -> 4.313.0 Deploy-Scope: both
Follow-up to #6794, whose Settings → Tasks page this builds on. Written against
its branch; retargeted to
mainafter it merged mid-flight.The gap
The rule lives on the column (
task_board_column_automations), but only theJira pull ever read it —
automationForhad exactly one caller. Dragging a cardinto that column in Studio did nothing, which is the gesture the feature is
described by and the one it did not cover.
runColumnAutomationis now the single act. Both the pull andTASK_BOARD_ITEM_UPDATEgo through it, so a column cannot mean one thing tothe sync and another to the board. It fires only on an actual lane change, and
never on a card someone already owns.
A bug the sharing made obvious
The rule was looked up by the card's status and then claimed on the board's
queue lane:
A rule on anything but the queue lane found itself, tried to claim where the
card was not, matched nothing, and did nothing. Silently — a lost claim and an
impossible one both come back null.
The claim now fences on the column the rule is on. Worth being precise about
what protects that: the lookup and the fence both read
item.statusinside onefunction, so they cannot diverge. That is structural, not tested. The new
integration test covers the fence's contract — wins on its own column, refuses
one that moved on, exactly one of two concurrent triggers wins, refuses an owned
card — but it would not have caught the original bug, which lived in the caller.
The UI
Settings → Tasks gets "When a card lands in a column": one switch per column
with an optional instruction, beside #6794's system prompt. Neighbours on
purpose, and different things — that prompt is standing context every run
carries; this one is the opening instruction of the run a column fires.
Shown only for a board whose columns are the org's own. On Studio's board the
lifecycle is already decided, and a switch per canonical lane would invite a
team to turn off the thing that makes the board work. Open to being wrong about
that; it is a one-line gate either way.
Off is a deleted row rather than an empty string, so "no rule" has one
representation and
automationForkeeps answering null for it.Verification
Browser, against real Postgres:
saves on blur
assigneeId: "super-agent", and the timeline showsstatus_changedthenassignee_changed1056 tests against real Postgres, 0 failures.
check,lint,fmt,knipclean.
Something pre-existing this surfaced
The run does not start on my machine —
TierUnavailableError, no model providerconfigured locally. Chasing that showed
reactToSuperAgentDelegationre-throwsonly
TaskQuotaErrorand swallows everything else, so the un-delegate-on-failurepath covers quota alone. Any other dispatch failure leaves the card assigned to
an agent that will never run, which is the state its own comment says it
prevents.
Left alone deliberately: widening what counts as fatal in dispatch is its own
change, not one to make inside a feature PR.
Summary by cubic
Column automation rules now run when a card lands in a configured column, whether the Jira sync moved it or someone dragged it in Studio. The rule fires only on an actual lane change and never on an already-owned card; previously only the sync path read the rule, so a drag did nothing.
Bug Fixes
item.statusinsiderunColumnAutomation, so they cannot diverge.canAdvancenow asks the board's own column order.New Features
todorole is now labeled "Where the agent gives up" — since the fence moved to the column's rule, that is all the role means.Deliberately left as-is:
reactToSuperAgentDelegationonly re-throwsTaskQuotaError, so any other dispatch failure leaves the card assigned to an agent that will never run. Widening what counts as fatal in dispatch is its own change.Written for commit 1517f32. Summary will update on new commits.