Skip to content

fix(task-board): every lane decision goes through the board, not a literal - #6790

Merged
viktormarinho merged 5 commits into
mainfrom
fix/board-handler-automations
Aug 31, 2026
Merged

fix(task-board): every lane decision goes through the board, not a literal#6790
viktormarinho merged 5 commits into
mainfrom
fix/board-handler-automations

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Finishes the mirrored-board arc. #6771 named the lanes, #6774 made sprint
two-way, #6778 made the status push work. This is the part that decides where
a card goes
— and it was still spelling out Studio's own column names.

The problem

Roughly fifteen places asked or answered "which lane is this card in?" by
comparing against in_progress / in_review / todo. On a board mirrored from
a tracker the columns are called whatever that tracker calls them. Every one of
those comparisons missed — silently, because a predicate that matches nothing
looks exactly like a predicate with nothing to do.

The writers were the visible half: an agent picking up a card moved it to
in_progress, a column that does not exist there, so the card left the board.

The readers were worse:

fence gates on consequence on a mirrored board
claimConflictResolution status = 'in_review' automatic conflict resolution never claims; the caller reads it as losing a race
claimUnassignedForSuperAgent status = 'todo' the Jira auto-delegate never fires
openReviewCycleIfInProgress status = 'in_progress' the review cycle never opens

The shape

BoardHandler grows lanes(), answering all five at once. Together rather than
one method each for two reasons: it is one read instead of five, and the storage
layer needs them as values — its fences are SQL predicates, so "which column
means in-progress" has to be answered before the query is built, not asked from
inside it.

StudioBoardHandler returns exactly the literals that were spelled out at those
call sites. That is what makes this a no-op for every org that never opted into
mirroring, and there is a test asserting it, because it is the whole safety
argument.

boardForDb builds the same handler from a database handle, for the projector
wiring and the sweeper. Without it those callers would have to be told which
columns mean what, and one that forgot would put Studio's vocabulary back on
someone else's board.

Three judgement calls

intake is the leftmost column, not a role. A card has to be born
somewhere, and making that configurable would turn "create a card" into a setup
step. Every other lane is nullable, and null means the same thing throughout:
this board has no column that means this, so nothing happens.

The cross-org sweeper query keeps a lane predicate. It cannot take a
resolved lane — each row may belong to a different board — so its pre-filter
reads the same task_board_columns rows the handler reads. It is a superset;
inReviewPhase in the loop body, which now takes the board's review column, is
the authority.

todo and in_progress become assignable column roles, end to end. Without
them the board cannot be configured for any of this.

A bug this found in #6778, already merged

tracker_statuses is jsonb, and pg serialises a JS array as a Postgres ARRAY
literal. A populated one is rejected outright; an empty one is accepted as
{}
— an empty OBJECT. The silent half is the dangerous one, and it only
surfaced when the new tests ran against real Postgres. Fixed, with a test that
asserts the shape read back rather than the value written.

Two robustness fixes the tests forced out

handTaskToHuman now parks best-effort. Parking needs to know the board, and a
card must still reach a person when that read fails — I had widened the failure
surface without noticing.

reactToFailedTaskRun and advanceTasksToReviewOnThreadFinish take lanes
rather than a database handle. Passing a handle made a pure-ish reaction
impossible to test without a database, which a unit test caught immediately.

Verification

Started a standalone Postgres and ran the storage and task-board suites against
it: 854 tests, 0 failures. That is what surfaced the jsonb bug.

check, lint, fmt, knip clean.

Not converted

The archive and merged-tag sweeps. Both are cross-org and would need the same
role join as the review sweeper's pre-filter, and neither is on the
execute-a-task path this arc exists to unblock.

Also noted while here: advanceToReviewIfInProgress has no caller outside its
own definition and an integration test. knip does not flag class methods, so
it has been dead for a while.


Summary by cubic

Routes task-board lane decisions through BoardHandler instead of hardcoded Studio column names, so mirrored tracker boards now move and gate cards using their configured columns. Boards missing required roles skip affected automation, or reject explicit conflict resolution, with an actionable warning logged once per org and role.

Refactors

  • lanes() resolves intake, queue, progress, review, and archive in one read while canonical boards keep their existing values.
  • Mirrored boards use the leftmost column for intake; todo and in_progress are now assignable roles in Jira settings.
  • Database-backed callers and the review sweeper resolve lanes per organization; archive and merged-tag sweeps still use literals.
  • boardCan distinguishes missing-role no-ops from lost races without repeating warnings.

Bug Fixes

  • Conflict failures restore the board’s actual review lane instead of writing undefined and stranding the card.
  • tracker_statuses now round-trips JSONB arrays correctly, including empty arrays.
  • handTaskToHuman still hands a card to a person when parking cannot read the board.
  • Verified with 854 storage and task-board tests against Postgres, with 0 failures.

Written for commit e12cf64. Summary will update on new commits.

Review in cubic

…teral

On a board mirrored from a tracker the columns are called whatever the tracker
calls them. Roughly fifteen places still asked or answered "which lane is this
card in?" by comparing against Studio's own names, and on such a board every
one of them missed — silently, because a predicate that matches nothing looks
exactly like a predicate with nothing to do.

The writers were the visible half: an agent picking up a card moved it to
`in_progress`, a column that does not exist there, so the card left the board.
The readers were the worse half. `claimConflictResolution` fences on
`status = 'in_review'`, so automatic conflict resolution never claimed anything
and the caller read that as losing a race. `claimUnassignedForSuperAgent` fences
on `todo`, so the Jira auto-delegate never fired.

`BoardHandler` grows `lanes()`, which answers all five at once — one read, and
the storage layer needs them as VALUES anyway, because its fences are SQL
predicates built before the query runs. `StudioBoardHandler` returns exactly the
literals that were spelled out at those call sites, which is what makes this a
no-op for every org that never opted into mirroring; there is a test asserting
that, because it is the whole safety argument.

`boardForDb` builds the same handler from a database handle, for the projector
wiring and the sweeper. Without it those callers would have to be TOLD which
columns mean what, and one that forgot would put Studio's vocabulary back on
someone else's board.

`intake` is the leftmost column rather than a role. A card has to be born
somewhere, and making that configurable would turn "create a card" into a setup
step. Every other lane is nullable, and null means the same thing everywhere:
this board has no column that means this, so nothing happens.

The cross-org sweeper query cannot take a resolved lane — each row may belong
to a different board — so its pre-filter reads the same `task_board_columns`
rows the handler reads. It is a superset; `inReviewPhase` in the loop body,
which now takes the board's review column, remains the authority.

Also fixes a bug shipped in #6778: `tracker_statuses` is jsonb, and `pg`
serialises a JS array as a Postgres ARRAY literal. A populated one was rejected
outright, an empty one was silently stored as `{}` — an empty OBJECT. Found by
running the new tests against real Postgres.

Two robustness fixes the tests forced out. `handTaskToHuman` now parks
best-effort: a card must reach a person even when the board read fails.
`reactToFailedTaskRun` and `advanceTasksToReviewOnThreadFinish` take lanes
rather than a database handle, so they stay testable without one.

`todo` and `in_progress` join `in_review` and `archived` as assignable column
roles, end to end — without them the board cannot be configured for any of this.

Not converted: the archive and merged-tag sweeps, which are cross-org and would
need the same role join. Neither is on the execute-a-task path.
Asking the board anything read as
`(await (await boardFor(ctx, org)).lanes()).review` — two awaits and three
paren levels to get one string. The pattern predates this branch but I had
multiplied it to 23 sites.

`boardLanes`, `boardColumnsOf`, `boardAutomationFor` and `boardLanesForDb` name
what each caller actually wants, so the nesting lives in one place. `boardForDb`
is module-private now that its only callers go through the wrapper.

Two things worth recording. The mechanical sweep rewrote the wrappers' own
bodies into calls to themselves — three functions that returned by calling
themselves, which typechecks and hangs. `bun run check` caught the first as an
unused-symbol warning; the other two only fell out of reading every `return
await` in the file afterwards, which is what a regex over its own output earns.
… a no-op

`{ status: lanes.review ?? undefined }` was written to silence a type error,
and `undefined` means "leave the status alone" to `update`. So the conflict
fence's failure path stopped reverting: the card kept the In Progress the fence
gave it, and the guard above only fires on the review lane — the exact
stranding the code it replaced existed to prevent.

The invariant was already there and I had just hidden it from the compiler.
`claimConflictResolution` returns null unless both lanes exist, so inside
`if (claimed)` the review lane is provably a string. Hoisting that check above
the claim narrows the type, and the revert writes a string.

Where the board genuinely cannot express the round trip, the two callers now
say so rather than proceeding: the automatic reaction declines, and the
user-invoked tool throws naming the roles to set.

`pr-open-board-reaction` had the same shape with a second meaning stacked on it
— `undefined` there already meant "don't touch the status", so reusing it for
"no lane to advance to" also skipped the Super Agent claim and the review cycle
without saying why. The lane check folds into `advancing`, which is the decision
it belongs to.

The remaining nulls are deliberate and typed: a board with no column for a
meaning does not get that behaviour, and the caller reads it as not having
happened. That is a stated answer, not a swallowed error.
A fence that declines for want of a column returns null, and a fence that lost
a race returns null. Nothing downstream can tell them apart, so an unconfigured
board did nothing and said nothing — the same silence this branch exists to
remove, one layer up.

`boardCan` is the line that tells the difference. It narrows the lane to a
string for the caller that proceeds, and for the caller that does not it names
the meaning and what will not happen, in words someone can act on.

Warned once per org and meaning rather than per call. These sit on sweep and
sync paths that fire every few seconds, so an unconfigured board would bury the
log it is trying to write. The key set is capped instead of TTL'd: it is bounded
by orgs times meanings, and a full reset just re-warns once.

Five callers: automatic conflict resolution (both lanes), parking a reviewed
card, the Jira auto-delegate, and advancing a card when its PR opens. The last
one keeps the LANE rather than a boolean — `boardCan` narrows inside the
expression and a boolean would not carry that to the write.
@viktormarinho
viktormarinho merged commit f64d360 into main Aug 31, 2026
34 checks passed
@viktormarinho
viktormarinho deleted the fix/board-handler-automations branch August 31, 2026 19:26
decocms Bot pushed a commit that referenced this pull request Aug 31, 2026
PR: #6790 fix(task-board): every lane decision goes through the board, not a literal
Bump type: patch

- decocms (apps/api/package.json): 4.309.4 -> 4.309.5
- @decocms/native (apps/native/package.json): 4.309.4 -> 4.309.5
- @decocms/shared (packages/shared/package.json): 0.75.0 -> 0.75.1

Deploy-Scope: both
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