fix(task-board): every lane decision goes through the board, not a literal - #6790
Merged
Conversation
…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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 froma 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:
claimConflictResolutionstatus = 'in_review'claimUnassignedForSuperAgentstatus = 'todo'openReviewCycleIfInProgressstatus = 'in_progress'The shape
BoardHandlergrowslanes(), answering all five at once. Together rather thanone 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.
StudioBoardHandlerreturns exactly the literals that were spelled out at thosecall 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.
boardForDbbuilds the same handler from a database handle, for the projectorwiring 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
intakeis the leftmost column, not a role. A card has to be bornsomewhere, 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_columnsrows the handler reads. It is a superset;inReviewPhasein the loop body, which now takes the board's review column, isthe authority.
todoandin_progressbecome assignable column roles, end to end. Withoutthem the board cannot be configured for any of this.
A bug this found in #6778, already merged
tracker_statusesis jsonb, andpgserialises a JS array as a Postgres ARRAYliteral. A populated one is rejected outright; an empty one is accepted as
{}— an empty OBJECT. The silent half is the dangerous one, and it onlysurfaced 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
handTaskToHumannow parks best-effort. Parking needs to know the board, and acard must still reach a person when that read fails — I had widened the failure
surface without noticing.
reactToFailedTaskRunandadvanceTasksToReviewOnThreadFinishtake lanesrather 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,knipclean.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:
advanceToReviewIfInProgresshas no caller outside itsown definition and an integration test.
knipdoes not flag class methods, soit has been dead for a while.
Summary by cubic
Routes task-board lane decisions through
BoardHandlerinstead 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.todoandin_progressare now assignable roles in Jira settings.boardCandistinguishes missing-role no-ops from lost races without repeating warnings.Bug Fixes
undefinedand stranding the card.tracker_statusesnow round-trips JSONB arrays correctly, including empty arrays.handTaskToHumanstill hands a card to a person when parking cannot read the board.Written for commit e12cf64. Summary will update on new commits.