Skip to content

The active board follows each item's driving run#80

Merged
czpython merged 1 commit into
mainfrom
agent/ENG-740
Jul 25, 2026
Merged

The active board follows each item's driving run#80
czpython merged 1 commit into
mainfrom
agent/ENG-740

Conversation

@druks-operator

@druks-operator druks-operator Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Linear ticket: ENG-740

The bug

ACTIVE (2) above a single rendered row, and cancelled items that never left the board.

Membership came from work_items.status, a column only the shipped/closed paths wrote — list_subjects() returned WorkItem.list_recent() if not item.status. The lanes came from somewhere else: the run-derived SubjectStatus.state. Two sources of truth that had to agree, maintained separately.

A build that crashed or was cancelled ended its run but wrote no status, so the item stayed NULL — counted as active by the header while its derived state was cancelled and matched neither lane. Because membership was defined by the absence of a value, the failure mode was always lingering.

The change

The board derives from the same run state the lanes bucket by, and the header counts the lanes it renders.

status keeps only its History meaning. A run that ends with nothing merged settles the item cancelled, so an abandoned attempt still lands somewhere instead of vanishing; ship() and close_external() keep overwriting, because GitHub announced those outcomes.

Run.cancel now announces run.cancelled. The workflow body re-raises DBOSWorkflowCancelledError without emitting, so nothing had ever observed an operator cancel.

Simplifications found on the way

The newest run speaks for its subject. The old rule was "prefer an active run, else the newest". Queue dedup gives a subject at most one active run per kind, and the next starts only once the last is terminal — so within a kind, active is newest. The preference only decided anything when two kinds overlapped on one subject, which stopped being possible when scope folded into build (#81).

Removing it exposed a latent weakness it had been hiding: created_at holds whole seconds, so two runs starting in the same second tie. The uuid7 id orders by real creation time and settles them. It also fixed a live inconsistency — list_for_subject ordered by updated_at while the board query used created_at, and updated_at moves, so a touched older run could out-rank a newer one.

A subject lists its own open rows. Subject.list_open() composes Run.open_subject_ids() as a subquery — one statement, one round trip — so an extension asks its own subject instead of the platform read side for a set of ids to filter against. list_subjects is one line. The run query lives on Run beside list_for_subject and get_latest_for_subject; reads.py is for queries that compose rows into response shapes, and this composes none.

Run.get_latest_for_subject() replaces runs[0] at every call site.

Verification

ruff check / ruff format clean, imports clean, frontend tsc / eslint / vitest green. Backend CI green.

test_build_board_membership.py covers: live and failed runs hold the board; finished and cancelled ones leave it while status is still NULL (the original strand); a re-dispatched item returns; an ended build settles cancelled; a shipped item keeps its outcome; and the bulk board query and per-subject status name the same driving run.

Not in this PR

work_items.status still encodes two things — whether it ended (now derivable) and how. Collapsing it to is_shipped is ENG-760; it's a migration plus a wire change plus the History page's filter.

@druks-reviewer druks-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two CI regressions introduced by this revision — both actionable in-repo.

Blocking

  1. Backend test — Run.cancel mock signature is now stale.
    test_subject_lifecycle.py::test_workflow_cancel_takes_its_own_kind_and_passes_over_idle_subjects monkeypatches Run.cancel with async def cancel(self, *, failure=None). BuildWorkflow.cancel now forwards failure_code= to whatever Run.cancel it finds, so the mock raises TypeError: got an unexpected keyword argument 'failure_code'. Constraint: the mock must accept the same kwargs Run.cancel now accepts — either add failure_code=None explicitly (mirrors the real signature and documents the contract) or absorb it with **_ (looser, but resilient to future kwargs). Extend the same test to assert the forwarded failure_code when relevant so future signature drift is caught.

  2. Frontend build — spread over NodeListOf<Element> is rejected by strict TS config.
    frontend/src/extensions/build/WorkItemsPage.test.tsx:64 does [...document.querySelectorAll('.wi-group-count')]. tsc -b fails with TS2488: Type 'NodeListOf<Element>' must have a '[Symbol.iterator]()' method that returns an iterator, breaking npm --prefix frontend run build. Constraint: iterate without relying on the iterator protocol on NodeListOf. Array.from(document.querySelectorAll('.wi-group-count')) is the least invasive fix and keeps behaviour identical; if you prefer to keep the spread everywhere, adding dom.iterable to tsconfig's lib also works but widens the type surface for the whole app — the local Array.from is the more conservative choice unless there's a project-wide preference. The new test in the same file covers the count assertion; no additional test needed once the build passes.

Push back on either if you see a cleaner approach — the constraint is only "backend tests pass" and "tsc -b && vite build succeeds," not the exact one-liner.

@druks-reviewer druks-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Round 2 verification — approving.

Both prior-round blockers are addressed exactly as flagged:

  • backend/tests/test_subject_lifecycle.py:84 — the Run.cancel monkeypatch now accepts failure_code=None, matching the extended signature.
  • frontend/src/extensions/build/WorkItemsPage.test.tsx:64 — swapped spread over NodeListOf for Array.from(...), unblocking tsc -b.

Frontend CI is green. Backend CI was still in progress at review time; the diff since round 1 is limited to the two lines above (both test-only, both correctly resolving the prior TypeError / TS2488 blockers), so I'm not blocking on the pending check.

Follow-up recommendations (post-merge, non-blocking)

  • Consider a targeted unit test for list_subject_ids_on_board's "active scope + terminated build" driving-run ordering so future ROW_NUMBER changes don't silently regress.
  • Product may want scope-terminal signals (Scope.kind + run.cancelled/run.failed) surfaced somewhere; today they leave the item invisible to both board and History by design.

Push back if any of these read differently on your end.

@druks-reviewer

Copy link
Copy Markdown

Code review: Board membership, ACTIVE count, and handoff writes correctly unified onto run-derived state; two medium follow-up items filed as a sub-issue on ENG-740.

@druks-operator
druks-operator Bot marked this pull request as ready for review July 25, 2026 20:10
@druks-operator
druks-operator Bot requested a review from czpython as a code owner July 25, 2026 20:10
@czpython czpython changed the title ENG-740 - [build] Active board membership derives from the hand-written work_items.status, not the runs — terminal runs strand items and the count disagrees with the lanes The active board follows each item's driving run Jul 25, 2026
Membership read work_items.status, a column only the shipped/closed paths
wrote, so a build that crashed or was cancelled left the item on the board
forever — counted by the header but matching neither lane. The board now
derives from the same run state the lanes bucket by, and the header counts
the lanes it renders.

The newest run speaks for its subject: dedup gives a subject one active run
per kind and the next starts only once the last is terminal, so preferring
an active run over a newer one decided nothing once scope folded into build.
created_at holds whole seconds, so the uuid7 id settles same-second ties.

Subject.list_open() composes Run.open_subject_ids(), so an extension asks its
own subject rather than the platform read side. Run.cancel announces
run.cancelled — the body re-raises without emitting, so nothing had observed
an operator cancel before.
@czpython
czpython merged commit ce2faaa into main Jul 25, 2026
2 checks passed
@czpython
czpython deleted the agent/ENG-740 branch July 25, 2026 22:45
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