ENG-760 - [build] Board and History read one stored fact — the PR's resolution, not druks's opinion of it - #132
ENG-760 - [build] Board and History read one stored fact — the PR's resolution, not druks's opinion of it#132druks-operator[bot] wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Contract verification — ENG-760, implementation revision 1
The substance of this change is right. All seven acceptance criteria are satisfied in the code, and the whole backend suite (982 tests) plus the frontend vitest suite (69 tests) pass locally. One thing blocks it: the new WorkItemsPage.test.tsx does not typecheck, so npm --prefix frontend run build fails — locally and in the PR's checks job (run 30277301296).
Blocking
Frontend typecheck fails on the newly added test file.
src/extensions/ship/WorkItemsPage.test.tsx(66,7): error TS2722: Cannot invoke an object which is possibly 'undefined'.
src/extensions/ship/WorkItemsPage.test.tsx(82,11): error TS2488: Type 'NodeListOf<Element>' must have a '[Symbol.iterator]()' method that returns an iterator.
Both are introduced by this diff and both are in the test that covers AC6, so the AC6 evidence itself is what breaks the build. Details and suggested shapes are in the inline comments. After fixing, npm --prefix frontend run build, npm --prefix frontend run lint, and npm --prefix frontend test should all be green.
Verified
- AC1 —
a4c9e2f7b1d6chains offf2c8b81a9d4e(which followsd3a5c71f8e40), single head. Adds nullablepr_merged/pr_resolved_at, backfillsshipped → (true, updated_at)andcancelled → (false, updated_at), dropswork_items_status_idxthenstatus. Downgrade restores the inverse mapping.DateTime(timezone=True)matches the_UtcDateTimeannotation map. - AC2 —
HandoffStatus,set_status,build_end_settles_the_item,any_workflow_start_returns_item_to_board, and theBuild.dispatch()status reset are all gone; no stragglers underbackend/druksorfrontend/src.pr_close_settles_the_itemis the sole writer, andtest_redelivered_webhook_does_not_rewrite_resolutionproves redelivery does not overwrite a stored resolution. - AC3 —
Run.open_subject_idsis replaced byRun.subject_states(), which keeps the ranked driving-run query and drops theOPEN_STATESSQL predicate.Ship.list_subjectsjoins that map against oneWorkItem.list_recent(limit=500)and excludespr_resolved_at is not Nonebefore looking at run state, sofinishedsurvives — covered by the parametrizedtest_an_unresolved_build_holds_the_board. - AC4 —
resolutionserializes asmerged/closed/null;DashboardItem.resolutionreplacesstatus; routes unchanged;list_handofffilters and orders onpr_resolved_at, andupdated_atnow carries that timestamp. - AC5 —
test_failed_build_later_merged_moves_from_board_to_historyseeds a failed build, firespr.closed(merged=True), and assertspr_merged is True, absence from/api/ship/work_item, andresolution == "merged"in History with the resolution timestamp on the wire. - AC6 —
WorkItemsPagefilters resolved rows first, bucketsparked/failed/finishedas needs-you andscheduled/runningas in-flight, and computestotal = needsYou.length + inFlight.length. The component test uses a mixed snapshot including arunningrow withresolution: 'merged'. - AC7 —
HistoryPage,StatusTag, andstyles.cssusemerged/closedthroughout; the pull-request webhook tests still assert run cancellation, ticket sync, and branch cleanup while asserting stored resolution instead of milestones.
Follow-up recommendations
Not blocking, and none of these map to an acceptance criterion — file separately if you agree.
- Reopen-then-merge is unreachable.
pr_close_settles_the_itemreturns early wheneverpr_merged is not None, per the plan's dedup rule. A PR closed unmerged, then reopened and merged, staysclosedin History forever. That is the specified behaviour today; worth a ticket if reopen turns out to happen in practice. - Facade imports.
subscribers.pyimportsdb_sessionfromdruks.databaseandBasefromdruks.models, andextension.pyimportsRunfromdruks.durable.models. Extension code elsewhere goes throughdruks.dbanddruks.durable, whichAGENTS.mdnames as the author surface. assertfor wire invariants.DashboardItem.from_work_itemuses bareassertforpr_merged/pr_resolved_at. Underpython -Othose vanish; a raisedValueError(or letting Pydantic reject theNone) would fail loudly either way.- Unbounded state map.
Run.subject_states()returns every subject of a type, andStoredSubject.list_opennow applies its limit in Python over that full map rather than in SQL. Fine at current scale; worth revisiting if a subject type grows large.
Push back on any of this if you read it differently — particularly the follow-ups, which are judgement calls rather than contract violations.
| const { container } = render(<WorkItemsPage />) | ||
|
|
||
| act(() => { | ||
| sse.handlers?.snapshot({ |
There was a problem hiding this comment.
tsc -b rejects this call: sse.handlers is typed Record<string, (data: unknown) => void> | null, and under the project's index-access strictness handlers?.snapshot is ((data: unknown) => void) | undefined, so invoking it is TS2722: Cannot invoke an object which is possibly 'undefined'. This fails npm --prefix frontend run build locally and on the PR's checks job.
Two reasonable shapes: narrow explicitly before the call (const handlers = sse.handlers; if (!handlers?.snapshot) throw new Error('snapshot handler not registered')), which also gives a clear failure if the component stops registering snapshot; or type the hoisted mock as a concrete interface with an optional snapshot and assert it non-null. The first is a bit more verbose but keeps the test honest about its precondition — your call.
The test that should cover this is the one already here: WorkItemsPage.test.tsx must pass under tsc -b, not just vitest. Please re-run npm --prefix frontend run build after the fix.
| expect(screen.queryByText('running-6')).toBeNull() | ||
| expect(container.querySelector('.dash-h1-count')?.textContent).toBe('(5)') | ||
| expect( | ||
| [...container.querySelectorAll('.wi-group-count')].map((node) => node.textContent), |
There was a problem hiding this comment.
Second typecheck failure from the same file: TS2488: Type 'NodeListOf<Element>' must have a '[Symbol.iterator]()' method that returns an iterator. The project's lib does not include dom.iterable, so spreading a NodeListOf is not allowed.
Array.from(container.querySelectorAll('.wi-group-count')) is the dominant fix here — it needs no config change and reads the same. Widening lib in tsconfig would also work but changes compiler config for one test assertion, which I'd avoid.
Same verification: npm --prefix frontend run build must go green; the AC6 bucketing assertions in this test are otherwise exactly what was asked for.
Dismissed after implementation revision update.
There was a problem hiding this comment.
Contract verification — ENG-760, implementation revision 2
The round-1 blocker is fixed. All seven acceptance criteria hold and the full verification profile is green.
Prior blocker — resolved
Round 1 blocked on: "Frontend typecheck fails on the newly added test file" — TS2722 on sse.handlers?.snapshot(...) and TS2488 on spreading NodeListOf. Revision 2 narrows the handler explicitly (throwing snapshot handler was not registered if the component stops registering it, which is the stronger of the two fixes I suggested) and swaps the spread for Array.from(...). npm --prefix frontend run build now passes locally and the On Pull Request Frontend check on d9bfb17 is green.
Verified
- AC1 —
a4c9e2f7b1d6(revisesf2c8b81a9d4e, itself afterd3a5c71f8e40; single head) adds nullablepr_merged/pr_resolved_at, backfillsshipped → (true, updated_at)andcancelled → (false, updated_at), then dropswork_items_status_idxandstatus.downgrade()restores the inverse mapping. - AC2 —
HandoffStatus,WorkItem.set_status,build_end_settles_the_item,any_workflow_start_returns_item_to_board, and theBuild.dispatch()status reset are all gone; the remainingset_statushits are the unrelated ticketing-provider API.pr_close_settles_the_itemis the only writer of both columns, andtest_redelivered_webhook_does_not_rewrite_resolutionproves redelivery leaves a stored resolution untouched. - AC3 —
Run.open_subject_idsis replaced byRun.subject_states(), which keeps the ranked driving-run query and drops theOPEN_STATESSQL predicate.Ship.list_subjectsjoins that map against oneWorkItem.list_recent(limit=500)and excludespr_resolved_at is not Nonebefore consulting run state, sofinishedsurvives. Covered bytest_an_unresolved_build_holds_the_boardandtest_the_newest_run_state_speaks_for_the_item. - AC4 —
resolutionserializes asmerged/closed/null;DashboardItem.resolutionreplacesstatus; both routes unchanged;list_handofffilters and orders onpr_resolved_at, which also backsupdated_aton the wire. Tests assert the exact camelCase values and thatstatusis absent. - AC5 —
test_failed_build_later_merged_moves_from_board_to_historyseeds a failed build, firespr.closed(merged=True), then assertspr_merged is True, absence from/api/ship/work_item, andresolution: "merged"in History with the matching resolution timestamp. - AC6 —
WorkItemsPagefilters resolved rows before reading run state, bucketsparked/failed/finishedas needs-you andscheduled/runningas in-flight, and computestotal = needsYou.length + inFlight.length.WorkItemsPage.test.tsxuses a mixed snapshot including arunningrow withresolution: 'merged', and now typechecks. - AC7 —
HistoryPage,StatusTag, andstyles.cssusemerged/closedthroughout, with the stale.outcome-shipped/.outcome-cancelledselectors removed. The adapted pull-request webhook tests still assert run cancellation, ticket synchronization, and branch cleanup whileship()/close_external()no longer write resolution.
Verification profile results
uv run ruff check backend— pass.uv run pytest backend/— 982 passed (local postgres + redis via docker).uv pip install -e backend/tests/druks-field_notes— pass.- proof-extension tests — 5 passed.
npm --prefix frontend run lint— pass (run in anode:22container; no node toolchain in this sandbox).npm --prefix frontend run build— pass.npm --prefix frontend test— 69 passed.- GitHub checks on
d9bfb17:On Pull Request Frontendpass,On Pull Request Backendpass.
Follow-up recommendations
Carried over from round 1 — none maps to an acceptance criterion, so none blocks. File separately if you agree.
- Reopen-then-merge is unreachable.
pr_close_settles_the_itemreturns early wheneverpr_merged is not None, exactly as the plan specified. A PR closed unmerged, then reopened and merged, staysclosedin History permanently. Worth a ticket if reopen happens in practice. - Facade imports.
subscribers.pyimportsdb_sessionfromdruks.databaseandBasefromdruks.models;extension.pyimportsRunfromdruks.durable.models. Extension code elsewhere goes throughdruks.dbanddruks.durable, whichAGENTS.mdnames as the author surface. assertfor wire invariants.DashboardItem.from_work_itemuses bareassertforpr_merged/pr_resolved_at; those vanish underpython -O. A raisedValueError, or letting Pydantic reject theNone, fails loudly either way.- Unbounded state map.
Run.subject_states()returns every subject of a type, andStoredSubject.list_opennow applies its limit in Python over that full map rather than in SQL. Fine at current scale, worth revisiting as subject counts grow.
Push back on any of these if you read them differently — they are judgement calls, not contract violations.
|
Code review: Minimal, well-targeted fix — both changes correctly address noUncheckedIndexedAccess/DOM.Iterable typecheck failures in the test file, with no scope creep or test-shape concerns. |
czpython
left a comment
There was a problem hiding this comment.
Reviewed the full branch (25 files, end to end) against main. The core idea is right — GitHub owns the outcome, run state owns liveness — but it ships three behavior regressions and a handful of house-rule violations.
Blocking
- A redispatched item can never come back to the board. pr_resolved_at is written once and never cleared: the dispatch reset was deleted (workflows.py), any_workflow_start_returns_item_to_board was deleted, and pr.opened doesn't touch it. So: PR closed → operator re-opens the ticket → build re-dispatches → new run running → the item is filtered out at extension.py:143 forever, while History shows it as closed. The deleted test_dispatch_pulls_cancelled_item_back_onto_the_board existed for exactly this strand, and the replacement test now pins the opposite. Same shape on GitHub's side: if item.pr_merged is not None: return (subscribers.py:63) means a close→reopen→merge stores closed permanently.
The resolution belongs to a PR, not to the item forever. Either Build.dispatch clears both columns (a new attempt starting isn't druks stamping an opinion — the old PR's outcome no longer describes the current work), or store the resolved pr_number alongside and only suppress when it matches the item's current one.
-
The shipped/cancelled milestone events are gone, silently. set_status was the only producer of Ship.record_event(type="shipped"|"cancelled"). The feed still renders extension milestones by their own word — events/feed.py:14, extensions/base.py:455, frontend/src/lib/feed.ts. After this PR a subject's timeline ends at the run's last lifecycle event; the merge never appears in it. Nothing in the plan or the ACs mentions dropping the milestone. Keep the record_event in pr_close_settles_the_item (it's one line, and it's the outward-facing half of "the PR resolved"), or say explicitly that the timeline loses it.
-
cancelled / orphaned rows now reach the wire and render nowhere. subject_states drops the OPEN_STATES predicate, so list_subjects returns any item whose newest run is cancelled or orphaned and that has no PR to resolve it. WorkItemsPage buckets only parked|failed|finished and scheduled|running, so those rows are fetched, dropped from both lanes, excluded from total, and flip the empty state from "nothing active" to "no matches". Previously OPEN_STATES excluded them at the source. Filter them in list_subjects or give them a lane.
Should fix before merge
4. Three asserts in DashboardItem.from_work_item (schemas.py:159-162). Banned outright, and stripped under python -O. The root cause is the tri-state pr_merged: bool | None plus a derivation helper. Store the resolution itself — pr_resolution: Mapped[str | None] holding "merged"/"closed". The stored fact then is the wire value (which is this PR's own thesis), _pr_resolution() disappears, DashboardItem reads resolution=item.pr_resolution with no asserts, WorkItemSummary reads it directly, and the migration maps shipped→merged / cancelled→closed just as directly. Shorter diff, one noun, no tri-state bool.
-
Platform plumbing moved into extension code. Ship.list_subjects now calls Run.subject_states directly — checklist #7 names exactly this, and Run is meant to leave author reach. It also leaves StoredSubject.list_open with zero production callers (models.py:106); review uses Subject.list_open, ship abandoned the door instead of adapting it. Keep WorkItem.list_open() and let the platform door take the extra predicate.
-
The read is now unbounded and in Python. subject_states materializes every subject-of-type's newest run into a dict on every board load and SSE snapshot; StoredSubject.list_open then builds an IN (...) from it, and Subject.list_open slices [:limit] after the fact. That grows with total work items forever, where the old query filtered and limited in SQL. Push the filter and limit back down.
-
pr_resolved_at = Base.utc_now() is druks's clock, not GitHub's. The title says "not druks's opinion of it", but History is ordered by webhook receipt time — a redelivery or a backfill sorts wrong. pull_request["closed_at"] / merged_at is right there in on_pull_request; normalize it into the payload next to merged.
Nits
item.pr_resolved_at is None (extension.py:143) → not item.pr_resolved_at; no falsy datetime to distinguish. (pr_merged is not None is a legitimate carve-out only while it stays tri-state — #4 removes it.)
subscribers.py:5-6 imports druks.database / druks.models; extension code goes through the druks.db facade, which ship/models.py already does. Same for from druks.durable.models import Run → from druks.durable import Run.
WorkItemsPage: finished landing in needs-you deserves the why (review-only work with no PR yet); and [...unresolved] copies an array filter already made fresh.
test_failed_build_later_merged_moves_from_board_to_history opens a TestClient inside an async test that has already awaited DBOS work — the poisoning failure mode. Worth confirming it's green in CI rather than assuming.
|
@czpython — thanks for the thorough read. Your three blocking issues are real and the implementation will be revised. Here's what changes and why each is a contract change rather than just a fix: Blocking 1 — re-dispatch clears resolution (design reversal) The plan's "Ruled out" section explicitly said "Clear PR resolution during dispatch — rejected because the ticket makes Blocking 2 — restore milestone record_event
Blocking 3 — filter cancelled/orphaned at the source Dropping Should fix before merge:
Nits (also applied): The implementer will pick this up as revision 3. |
Superseded by implementation revision 3.
There was a problem hiding this comment.
Contract verification — ENG-760, implementation revision 3
All fourteen acceptance criteria hold and the full verification profile is green locally, matching the two green checks runs on 49b39ed. Ticket ENG-760 and its single operator comment were read first; the comment adds no constraint the plan omits.
Prior blockers — all resolved
The owner's CHANGES_REQUESTED review listed three blocking items and four must-fix quality items. Each is addressed in 49b39ed:
- Re-dispatch clears resolution —
workflows.py:130-137extends the existing genuinely-new-run branch to passpr_merged=None, pr_resolved_at=Nonethroughitem.update(), with the two new_KEEP-sentinel parameters added inmodels.py:365-382.test_dispatch_pulls_cancelled_item_back_onto_the_boardseeds a merged item, dispatches a new run, and asserts both fields areNoneand the item is back inShip.list_subjects(). The dedup path keeps a stored resolution — asserted intest_duplicate_dispatch_keeps_the_live_attempt_routing. (AC8) - Milestone events restored —
subscribers.py:69-73callsShip.record_event(type="shipped"|"cancelled", subject=item)after the fields are flushed and beforeship()/close_external().frontend/src/lib/feed.ts:44renders an extension milestone type as its own word, so"shipped"/"cancelled"need no renderer change. Both webhook tests now assert theEventrow. (AC9) - Cancelled/orphaned rows excluded —
extension.py:138-147filtersstates[str(item.id)] not in (RunState.CANCELLED, RunState.ORPHANED)in the backend composition.test_a_cancelled_or_orphaned_build_with_no_resolution_leaves_the_boardcovers both states, derivingORPHANEDby deleting theworkflow_statusrow and backdatingRun.created_atpast the grace window. (AC10) assert→raise ValueError—schemas.py:159-163; no bare assert remains infrom_work_item. (AC11)- Facade imports —
subscribers.pynow importsdb_sessionfromdruks.dband no longer needsBase;extension.pyimportsRun, RunStatefromdruks.durable.StoredSubject.list_openis removed fromdruks/models.pyas dead code;Subject.list_openindatastructures.pystays and the Review extension's tests pass. (AC12) - Limit pushed into SQL —
Run.subject_states(..., limit=500)applies.limit(limit)on the ranked,created_at DESC-ordered query; both call sites thread their limit. (AC13) - GitHub's own timestamp —
on_pull_request_closedforwardsmerged_at/closed_atasresolved_at; the subscriber parses it with the ISO idiom. Both webhook tests assert the exact parsed constant rather than "not None". (AC14)
AC1–AC7 are unchanged from revision 2 and re-verified against this head: migration a4c9e2f7b1d6 with its inverse downgrade, HandoffStatus/set_status absent, pr.closed the only writer of a non-None resolution, resolution serialized as merged/closed/null, the failed-then-merged regression test, and the frontend bucketing plus History glyphs.
Verification profile
uv run ruff check backend— passuv run pytest backend/— 984 passeduv pip install -e backend/tests/druks-field_notes— pass- proof-extension tests — 5 passed
npm --prefix frontend run lint— pass (run in anode:22container; no node toolchain in the sandbox)npm --prefix frontend run build— passnpm --prefix frontend test— 69 passed- GitHub
checkson49b39ed— both green
Follow-up recommendations
None of these blocks; file separately if you agree.
Subject.list_open's limit now bounds subjects, not open subjects.datastructures.py:59applies the SQLLIMITbefore the PythonOPEN_STATESfilter, solist_open(limit=50)returns the open subset of the newest 50 subjects — fewer than 50 open subjects when recent ones are closed. The plan directed exactly this, and the Review extension is the only caller, so it ships. If Review's active list ever needs a guaranteed page size, the filter has to move into the ranked SQL rather than the limit. A test in the Review extension's active-list suite asserting page size against a mix of open and closed newest-runs would pin whichever behaviour you want.- Stale docstring reference.
datastructures.py:54still points atStoredSubject.list_open, which this diff deletes. One-line docstring fix. - Owner nit not applied.
WorkItemsPage.tsx:130keeps the redundant[...unresolved]copy;unresolvedis freshly built byfilteron the line above. resolved_atassumes the field is present.on_pull_request_closedreadspull_request["merged_at"]/["closed_at"]directly. GitHub always populates one onaction == "closed", so this is correct today; a malformed or replayed payload raisesKeyErrorinside the webhook rather than being rejected at the boundary. If you want the boundary to be defensive, a.get()with an explicit 400 plus a test intest_webhooks_pull_request.pyfor a payload missing both fields would cover it.- Reopen-then-merge is still unreachable (carried from rounds 1 and 2).
pr_close_settles_the_itemreturns early wheneverpr_merged is not None, so a PR closed unmerged, then reopened and merged, staysclosedin History. Note that item 1 above partly softens this: a re-dispatch now clears the resolution, so the item is recoverable through a new attempt.
Push back on any of this — the follow-ups in particular are judgement calls, not contract violations.
|
Code review: Correctly re-implements the PR-resolution-as-stored-fact design ( |
Linear ticket: ENG-760
Plan
Decision
Implement this as one coherent migration/read-side/wire/UI change. The stored source of truth becomes the PR outcome reported by GitHub, while run state remains the source of liveness. Done is checked first; only unresolved work is eligible for the active Board.
Revision 3 note: the repo owner's review overturned one prior ruling. Re-dispatching a work item onto a genuinely new run now clears any stored
pr_merged/pr_resolved_at— the old PR's outcome no longer describes the new attempt.pr.closedremains the only writer of a non-None resolution; dispatch only ever clears the fields back toNone. See "Ruled out" below for the superseded line.The ticket is decision-complete. Its single operator comment confirms the design as understood and adds no new constraint.
Implementation
Replace the handoff status schema. (Unchanged from revision 2 — already landed and verified.) A new Alembic revision after
d3a5c71f8e40replaceswork_items.statuswith nullablepr_merged: bool | Noneandpr_resolved_at: datetime | None, backfillingshipped→(true, updated_at)andcancelled→(false, updated_at), then dropswork_items_status_idxandstatus. Downgrade restores the inverse mapping.pr.closedwrites the resolution;Build.dispatchclears it; the milestone event returns.In
backend/druks/contrib/ship/subscribers.py,pr_close_settles_the_itemkeeps its dedup guard (if not item or item.pr_merged is not None: return) unchanged, but now:pr_resolved_atfrom GitHub's own timestamp instead ofBase.utc_now()(see point 7 below), andset_statusused to emit, viaShip.record_event(type="shipped", subject=item)whenpayload["merged"]is true, orShip.record_event(type="cancelled", subject=item)when false — call it right after the two fields are set and flushed, beforeship()/close_external(). These are new literal event-type strings; nothing in the codebase currently emits"shipped"/"cancelled"(HandoffStatusand its call sites are fully gone), so there is no existing string to match against — use these two exactly, matching the enum valuesset_statusused to pass torecord_event.from druks.database import db_sessionandfrom druks.models import Basewithfrom druks.db import Base, db_session(the documented author door; both names are already exported there).In
backend/druks/contrib/ship/workflows.py,Build.dispatch(around the existingif item.build_run_id != run_id:branch that already clearsbranch/pr_numberon a genuinely new run) also clears the resolution:WorkItem.update()uses an explicit_KEEP-sentinel kwarg pattern (backend/druks/contrib/ship/models.py:357) — addpr_merged: bool | None = _KEEPandpr_resolved_at: datetime | None = _KEEPparameters and bodies to that method so both new fields can be cleared through the same call asbranch/pr_number, rather than mixing a direct-attribute-assignment style intodispatch. The dedup path (item.build_run_id == run_id, a live-run redispatch) must NOT go through this branch, so an already-stored resolution survives a duplicate dispatch — this is existing behavior, unchanged.Generalize the durable read query, and exclude cancelled/orphaned unresolved items from the Board.
Run.subject_states()keeps its ranked, unfiltered{subject_id: RunState}query, but add alimit: int = 500parameter and apply.limit(limit)to the final statement (already ordered bydriving.c.created_at.desc()), so the newest-N subjects are bounded in SQL rather than materializing every subject a type has ever had a run for.Ship.list_subjects()passes its existing500through instead of relying only onWorkItem.list_recent(limit=500)'s bound. Inbackend/druks/durable/datastructures.py,Subject.list_openpasses its ownlimitthrough tosubject_states(subject_type, limit=limit)instead of slicing the full dict with[:limit]in Python — that slice becomes an unreachable safety cap once the SQL side is bounded, not the primary limit.backend/druks/models.py'sStoredSubject.list_openhas zero production callers anywhere inbackend/druksorbackend/tests(Ship.list_subjectsnever adopted it, and the only caller of anylist_openvariant isdruks/contrib/review/extension.py:34, which calls the identity-onlySubject.list_openindatastructures.py, a different method). RemoveStoredSubject.list_openfromdruks/models.py— it is dead code, and itsOPEN_STATES-only filtering can't serve Ship's needs anyway (Ship must keepfinished-but-unresolved items, whichOPEN_STATESexcludes). LeaveSubject.list_open(datastructures.py) untouched other than the limit threading above; Review's usage is unaffected.In
backend/druks/contrib/ship/extension.py, fix the facade violation —from druks.durable.models import Runbecomesfrom druks.durable import Run(also importRunStatefrom there). Then close the real gap the repo owner flagged:list_subjects()currently excludes an item only whenpr_resolved_at is not None, so a work item whose newest run iscancelledororphaned— real, reachableRunStatevalues with no resolution ever coming (no GitHub event fires for an internally-cancelled/orphaned run) — stays on the Board forever, and the frontend has no bucket for either state (confirmed:WorkItemsPage.tsx'sneedsYou/inFlightfilters only matchparked|failed|finishedandrunning|scheduledrespectively; a cancelled/orphaned+unresolved row renders in neither group while still inflating the raw count). Exclude both in the backend composition:Wire contract and DashboardItem hardening.
WorkItemSummary.resolutionand the/api/ship/work_item//api/ship/work-items/historyshapes are unchanged from revision 2. Inbackend/druks/contrib/ship/schemas.py,DashboardItem.from_work_itemreplaces its three bareasserts withraise ValueError(...)— asserts vanish underpython -Oand this invariant (everyDashboardItemis built from an already-resolvedWorkItem) must fail loudly in production, not silently:(The reviewer also floated replacing
pr_merged: bool | Nonewith a singlepr_resolution: str | Nonecolumn as a further simplification. That is optional, not required by any acceptance criterion here — it would re-touch the already-verified migration and ripple through more files than this revision's feedback calls for. Leave it as a follow-up unless you're already touching every call site anyway.)GitHub's own timestamp, not druks's clock, backs
pr_resolved_at. The normalizedpr.closedsignal currently carries only{"branch": ..., "merged": ...}(backend/druks/core/webhooks/github.py,on_pull_request_closed) — it does not forward GitHub'smerged_at/closed_atat all, so the fix touches the webhook normalizer, not just the subscriber. Add the resolved timestamp to the published payload there, wheremergedis already known:In
pr_close_settles_the_item, parse it instead of stampingBase.utc_now():matching the existing ISO-parse idiom used elsewhere (
backend/druks/harnesses/codex.py:167). GitHub always populatesmerged_atorclosed_aton aclosedaction, so no null-handling is needed here. Test fixtures that build a raw GitHub payload (tests/ship/test_webhooks_pull_request.py's_fire_closedhelper) needmerged_at/closed_atadded to theirpull_requestdict.Render facts in the frontend. Unchanged from revision 2 —
WorkItemsPage.tsxfilters resolved summaries first, bucketsparked/failed/unresolvedfinishedas needs-you andscheduled/runningas in-flight,total = needsYou.length + inFlight.length.HistoryPage.tsx/StatusTag.tsxrendermerged/closed. (Optional nit, not required for any AC: a one-line comment on why unresolvedfinishedlands in needs-you, since a cancelled/orphaned+unresolved row can no longer reach the wire, this bucket only ever holds genuine review-only work now.)Update tests.
backend/tests/ship/test_build_dispatch.py:test_dispatch_does_not_clear_a_stored_pr_resolutionasserts the exact behavior this revision reverses (pr_merged is False/pr_resolved_at is not Nonesurvive a dispatch to a brand-new run) — replace it withtest_dispatch_pulls_cancelled_item_back_onto_the_board: seed a work item, close its PR (pr_merged=True,pr_resolved_atset), dispatch a new build (stubBuild.startto return a fresh run id), assertpr_merged is Noneandpr_resolved_at is None, and assert the item is present inShip.list_subjects(). Add a companion assertion totest_duplicate_dispatch_keeps_the_live_attempt_routing(the dedup path) confirming a stored resolution survives when dispatch hands back the same live run.backend/tests/ship/test_webhooks_pull_request.py: extend the redelivery test coverage and_fire_closed's payload withmerged_at/closed_at; assertpr_resolved_atequals the parsed GitHub timestamp, not the webhook-receipt time; add an assertion thatpr_close_settles_the_itemwrites anEventrow (type="shipped"or"cancelled") for the item.backend/tests/ship/test_build_board_membership.py: addtest_a_cancelled_or_orphaned_build_with_no_resolution_leaves_the_board, parametrized overstate in ("cancelled", "orphaned")."cancelled"seeds directly viaseed_build_run(druks_db, work_item_id=item.id, state="cancelled")."orphaned"is not a stateseed_dbos_statussupports (backend/druks/testing.py) — seed a run normally, delete itsworkflow_statusrow, and backdateRun.created_atpast_MISSING_STATUS_GRACE(5 minutes,backend/druks/durable/dbos_state.py) sostate_expressionderivesORPHANED. Assertstr(item.id) not in _board_ids(druks_db)in both cases.AGENTS.md's liveness/outcome line only if you find it no longer holds — the current wording ("An externally owned PR outcome is stored from the provider event, never inferred from run lifecycle") already survives this revision unedited: dispatch clears a stale fact, it never infers a new one.Preserved decisions and scope
cancelledororphaned, with no PR resolution, is excluded from the active Board entirely — neither needs-you nor in-flight, since neither bucket can act on it and nothing will ever resolve it except a fresh dispatch or apr.closed.status,shipped, orcancelledis added to the new Ship API contract.Ruled out
work_items.statusand patch more lifecycle settlement paths — rejected because terminal run events cannot observe a later manual merge, which is the production failure this ticket fixes.resolutionandSubjectStatus.state) and Ship's frontend owns presentation.status/shipped/cancelledand newresolution/merged/closedfields — rejected because the repository requires one canonical wire spelling andHandoffStatusmust be removed.Clear PR resolution during dispatch, workflow start, or— overturned in revision 3. The repo owner's review determined that a genuinely new dispatch must clear a stale resolution, since the old PR's outcome no longer describes the new attempt.pr.openedpr.closedremains the sole writer of a non-None resolution;Build.dispatchonly ever clears the fields back toNone, and the existing dedup guard inpr_close_settles_the_itemis unaffected — a subsequentpr.closedafter a clearing dispatch simply writes fresh facts. Clearing during workflow start orpr.openedis still out of scope; onlyBuild.dispatch's genuinely-new-run branch clears.Acceptance Criteria
d3a5c71f8e40adds nullablework_items.pr_mergedandwork_items.pr_resolved_at, backfillsstatus='shipped'to(true, updated_at)andstatus='cancelled'to(false, updated_at), then dropswork_items_status_idxandwork_items.status; its downgrade restores the inverse mapping.WorkItemmapping.HandoffStatus,WorkItem.set_status/settle, the run-terminal settlement subscriber, and dispatch/start status clearing (from revision 1) are absent.pr.closedis the only production code that writes a non-Nonepr_merged/pr_resolved_at(establishes a resolution);Build.dispatchis the only other writer, and it only ever clears both fields toNonewhen pointing an item at a genuinely new run (see AC8). Webhook redelivery does not rewrite an already-stored resolution.pr_merged/pr_resolved_atand confirm each site is eitherpr_close_settles_the_item(non-None) orBuild.dispatch's new-run branch (None); read the webhook redelivery test.RunStateas adict[str, RunState], bounded by an explicitlimitapplied in SQL rather than in Python;Run.open_subject_idsremains removed. Ship's active list combines that map with oneWorkItem.list_recent()result, excludes resolved items before considering run state, and preserves finished-but-unresolved items for the needs-you bucket.LIMIT, the Ship list composition, and tests covering newest-run selection plus finished unresolved work.WorkItemSummaryserializesresolutionas exactly"merged","closed", ornull; History items exposeresolutioninstead of the former handoffstatus. The Board remains at/api/ship/work_item, History remains at/api/ship/work-items/history, and History is ordered bypr_resolved_at.pr.closedwithmerged=true, and proves the item haspr_merged=True, is absent from the active Board response, and is present in History withresolution: "merged".WorkItemsPagefirst removes summaries whoseresolutionis non-null, putsparked,failed, and unresolvedfinishedrows in needs-you, putsscheduledandrunningrows in in-flight, and computes the displayed active count asneedsYou.length + inFlight.length. Frontend tests cover these branches.WorkItemsPageand its component test using a mixed snapshot, including a resolved row with an otherwise active run state.merged/closedresolution values. Existingship()andclose_external()run cancellation, ticket synchronization, and branch-cleanup behavior remains covered while neither method writes resolution.HistoryPage,StatusTag, styles, and the adapted pull-request webhook tests.Build.dispatchclearspr_mergedandpr_resolved_atback toNonewhenever it points a work item at a genuinely new run (the same branch that already clearsbranch/pr_number), and leaves them untouched when a dispatch dedups to the same live run. A focused test seeds a resolved item, dispatches a new build, and asserts both fields areNoneand the item is present in the active Board response; a second test confirms a dedup dispatch preserves a stored resolution.Build.dispatchandWorkItem.update's new parameters; readtest_dispatch_pulls_cancelled_item_back_onto_the_boardand the dedup companion assertion; confirm the deleted/renamedtest_dispatch_does_not_clear_a_stored_pr_resolutionno longer asserts the reversed behavior.pr_close_settles_the_itememits a timeline milestone event (type="shipped"when merged,type="cancelled"when closed unmerged) viaShip.record_event(subject=item), restoring the milestoneWorkItem.set_statusused to produce before its removal.record_eventcalls; read a test asserting anEventrow with the matchingtypeandsubject_idexists after apr.closeddelivery.Ship.list_subjects()excludes any item whose newest run state iscancelledororphanedand whosepr_resolved_at is None, in addition to excluding resolved items — these rows have no actionable bucket and must not reach the wire. The exclusion happens in the backend list composition, not only in frontend bucketing.Ship.list_subjects()'s filter predicate; readtest_a_cancelled_or_orphaned_build_with_no_resolution_leaves_the_boardcovering both states.DashboardItem.from_work_itemraisesValueErrorinstead of using bareassertwhen a work item lacks a stored resolution, so the invariant fails loudly underpython -O.schemas.pyfor theraise ValueErrorreplacements; confirm no bareassertremains infrom_work_item.subscribers.pyimportsBase/db_sessionviadruks.db, andextension.pyimportsRun/RunStateviadruks.durable, matching the documented author-surface doors named inAGENTS.md.StoredSubject.list_openindruks/models.pyis removed as dead code (zero production callers);Subject.list_openindatastructures.py, used by the Review extension, is unaffected other than threading itslimitintoRun.subject_states.StoredSubject.list_openno longer exists indruks/models.pyand the Review extension's active-list test still passes.Run.subject_states()takes alimitparameter and applies it as a SQLLIMITon the ranked, newest-first query, rather than materializing every subject of a type before Python filters/slices.Ship.list_subjects()andSubject.list_open()both pass their existing limit through instead of slicing an unbounded Python list.subject_states's SQL for theLIMITclause and its two call sites for the threadedlimitargument.pr_resolved_atis sourced from GitHub's ownmerged_at/closed_attimestamp (added to the normalizedpr.closedpayload by the webhook handler) rather than druks's clock at webhook receipt, so History ordering is stable under redelivery and operator backfill.on_pull_request_closed's payload construction andpr_close_settles_the_item's parsing; read a test assertingpr_resolved_atequals the payload's timestamp, not the time the test ran.