Skip to content

feat: Home resumes by recency, and its one control follows what is waiting - #560

Merged
JArmandoAnaya merged 4 commits into
mainfrom
feat/home-recency
Aug 12, 2026
Merged

feat: Home resumes by recency, and its one control follows what is waiting#560
JArmandoAnaya merged 4 commits into
mainfrom
feat/home-recency

Conversation

@JArmandoAnaya

@JArmandoAnaya JArmandoAnaya commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Two flags left open when Home shipped (#552, merged as #556), settled together because they are
one mechanism: the review CTA is ranked by the same recency the column restores.

The column

annotation_job_asset gains a nullable touched_at, and it is the only timestamp in the schema
that dates a person's work rather than a record's creation.

It cost one line at the write, because there was exactly one write to change.
UnitOfWork.set_asset_progress is the narrow compare-and-set introduced when concurrent progress
writes were being lost (#302); every progress transition goes through it, and _job_write_children
writes a progress value only on insert, so no other path can move one. The stamp rides in the
same guarded UPDATE, which makes it as atomic as the transition it records and unable to drift
from it. A write that loses its race stamps nothing, because losing a race is not work.

No backfill, and migration 8 says why. The moments the column holds were never recorded
anywhere — not in another row, not in a derived field, not on disk — so a workspace's history of
who-worked-when begins here. Every existing row is NULL, which reads as nobody has touched this
frame since the column existed
.

That is what the ranking's second population is for. A batch with a stamp outranks every batch
without one and the later touch wins between two that have; batches whose rows are all NULL are
ranked among themselves by the rule that shipped before — furthest through first, ties to the
later-created batch. A workspace opened straight after upgrading therefore behaves exactly as it
did, and converges to real recency as soon as anybody works in it.

One deliberate limit on where the stamp reaches. _refresh_progress now writes even when the
derived progress does not move, so drawing a second box on an already-annotated frame touches its
batch — that is most of what annotating actually is, and a stamp written only on transitions would
miss it. JobService.mark keeps its no-op short-circuit: a client re-sending a value the asset
already holds is a retry rather than somebody working the frame, and that early return is a
documented part of what mark means.

The CTA priority

Home shows exactly one filled button in every state. It now resolves in the order the workspace
actually needs:

kind control goes to
annotate Continue annotating the editor, at the unlabeled frame
review Review annotations the editor, at the frame awaiting review
open Open batch the batch gallery
New project the header's own button, filled

Review opens the annotator, and there is no second screen to build. docs/ui.md already took
this position: "There is one screen, not two. Which controls appear is the frame's state, so the
annotator and the reviewer are the same page wearing what it is looking at."
A review_pending
frame opens read-only with Accept and Return to annotator on it, gated on its own declared
actions — so the review target has the same shape as the annotate target, a job and an asset, and
reaches it through the same deep link. The gallery's In review filter segment is component state
rather than a URL parameter and is left alone.

Open batch survives, as a fourth outcome rather than a third state. The decision comment names
three; the shipped card already flips to Open batch when a batch is settled throughout, and an
open batch nobody has completed is a better answer to "what do I do next?" than starting another
project. Recorded on #559 before implementing.

The kernel resolves which applies. ResumeTarget.kind is new, and it is the field to read
first: it says whether next_asset_id is a frame to label, one to review, or absent. That is a
judgment about what somebody should do next rather than a fact the rest of the response restates,
so a browser working it out again would keep a second copy of a rule that can drift. It is
deliberately unlike the first-run state, which is still not a field because totals.projects
already answers it — the distinction is written into the enum and into docs/ui.md, since the two
would otherwise read as contradictory.

review_pending is new beside it, and is populated in every kind rather than only in review: a
batch can hold frames for review and unlabeled frames at once, and the count is more interesting in
that case, not less.

Verification

Every new rule was mutation-verified after committing the implementation: one unconditional step
per case, anchor asserted present exactly once before and replaced after, revert by recorded diff,
clean tree checked between cases. Sixteen mutations, sixteen reddened a named test, sixteen
reverts landed.

mutation reddened
stamp never written four, incl. test_the_batch_you_worked_last_wins
stamp read always null the same four
stamp only on a real move test_an_edit_that_moves_no_progress_still_counts_as_working_the_batch
batch recency takes the oldest job test_a_split_batch_is_as_recent_as_its_newest_job
recency inverted three, incl. ..._wins_across_projects_too
recency ignored two
untouched preferred test_a_batch_somebody_has_worked_beats_one_nobody_has
untouched tier removed the same one
fallback rank inverted test_the_batch_you_are_furthest_through_wins_where_nobody_has_been
fallback tie direction (>=>) test_a_tie_goes_to_the_batch_created_later
kind priority inverted three, incl. test_review_outranks_a_batch_with_neither
kind priority ignored the same three
review ranked before annotate test_labeling_in_one_batch_outranks_review_in_a_more_recent_one
review never resolved two
review landing is the labeling one two
review count zeroed two

One came back green and it was a real hole. Taking the oldest of a batch's jobs instead of
the newest changed nothing, because every fixture in the file made single-job batches and a batch
with one job cannot tell max from min. A partition is what reaches it, so
test_a_split_batch_is_as_recent_as_its_newest_job cuts a batch into one job per asset and works
two of them either side of working a different batch. The mutation reddens it now.

Writing that test also caught my own first version of it: the split batch became fully annotated,
so kind decided the ranking before recency was ever consulted and it failed against correct code.
It carries a third asset now, and a comment saying why.

The gate, staged

Run in stages because the harness kills at ~10 minutes. Exit codes verbatim:

pytest tests/kernel tests/server tests/architecture              0
pytest tests/cli tests/formats tests/inference tests/jobs
       tests/mcp tests/packaging tests/scripts tests/examples
       tests/test_versioning.py                                  0
ruff check .                                                     0   All checks passed!
ruff format --check .                                            0   380 files already formatted
mypy src/visionset                                               0   no issues found in 159 source files
lint-imports                                                     0   Contracts: 4 kept, 0 broken
pnpm -r build                                                    0
pnpm -r lint                                                     0
pnpm test:scripts                                                0
pnpm test  (annotator)                                           0   1005 passed
pnpm test  (ui-core)                                             1   see #555 below
check.sh generated                                               0   PASSED
browser cycle, real server (chromium)                            0   1 passed (57.3s)
annotator + app e2e (chromium)                                   1   see below

openapi.json re-exported after the fact leaves no diff, so the committed spec matches the app.

ui-core vitest, red on the merge-base too — #555

Reproduced here, by me, in this environment, on unmodified main at the merge-base d6777d6:

baseline (d6777d6)   Test Files  4 failed | 46 passed (50)
                          Tests  8 failed | 958 passed (966)

this branch          Test Files  3 failed | 47 passed (50)
                          Tests  8 failed | 962 passed (970)

The same count, in the same files — inference.test.tsx, schemaDraft.test.tsx,
screens.test.tsx, addClassProvenance.test.tsx — and every failure is
Error: Test timed out in 5000ms. home.test.tsx passes on every run, 21 of 21, and it is the
only ui-core file this branch touches. CI's frontend job is green. Tracked as #555.

The local e2e suite did not produce a usable verdict, and CI is the arbiter

Declared rather than silent. annotator + app e2e (chromium) failed locally on this machine
and the failures are machine saturation, not this diff. Two runs, and the failing set is different
each time:

run 1   5 failed | 250 passed (5.1m)
        annotate.spec.ts:1483  selecting on the canvas scrolls the object's row into view
        annotate.spec.ts:2463  a frame goes out for review, comes back, and is accepted the second time
        annotate.spec.ts:2556  the job counter never goes backwards when a frame is reviewed
        annotate.spec.ts:3168  a polygon class steps its detail from the keyboard, with no request
        perf.spec.ts:126       a drag costs the committed layer nothing per move

run 2   abandoned mid-run; failing set already differed
        annotate.spec.ts:1483  (again)
        annotate.spec.ts:1747  a copied annotation can be pasted onto the next frame

Every failure is Test timeout of 20000ms exceeded, and the second run's set includes a copy/paste
scenario this diff cannot reach. The load average on this laptop went from 45 to 394 across the
runs, from a second unrelated session; nothing this task spawned, and no orphaned process of mine
(checked by PPID == 1). Run 2 was stopped for that reason rather than finished, and its processes
were confirmed dead.

The two review scenarios were checked rather than assumed, because they are the ones this
change could plausibly have broken. Run alone at two workers:

Running 4 tests using 2 workers
····
  4 passed (20.5s)

This is the sibling of #511 and #550 in this suite, and of #555 one suite over. CI on clean runners
is the arbiter: all fourteen required checks are green, including annotator e2e (chromium) and
browser cycle (chromium) — the real-server run the protocol prizes most.

Driven in a browser, on a real server

The suite above being unusable is exactly when this matters, so all four states were walked by hand
against visionset server over a seeded workspace with two projects: one batch part-way through
labeling, one fully labeled with a frame sent back for review.

  1. annotate wins over a more recently worked review batch. The part-way batch was worked
    first and still won, which is the tier doing its job.
  2. Settling its last frame flipped Home to review on the other project — card reading
    Batch 3 · 1 waiting on review with Review annotations.
  3. The control landed at /jobs/{job}?asset={frame} with the in review chip, the read-only
    banner ("This frame is waiting on a review — return it to the annotator to change its labels"),
    Accept offered, and Skip and Delete disabled. This is the whole argument for not building a
    second screen, confirmed rather than asserted.
  4. Accepting it left kind: open and an empty attention list — and Highway pilot then outranked
    the other batch on recency, because accepting stamped it. The new column working live.

Found, not fixed

  • domain/task.py:107 and :123 still say "M1 has no review surface" and "there is no review
    surface yet"
    , and docs/jobs.md:48,215 repeats it, while docs/ui.md documents the annotator's
    three review moves as shipped. Stale prose rather than stale behaviour; this change relies on the
    shipped moves and does not alter them.
  • _DECLARED_TAILS in tests/kernel/test_migrations.py has no entry for inference_connection,
    whose model_family arrived by ALTER in migration 7. Adding this change's own entry made the
    gap visible.

Flags for Armando

  • A batch still has no timestamps of its own. touched_at dates work, not the batch: nothing
    records when a batch was created or when it changed state, so the tie-break within the untouched
    population is still insertion order, and the oldest batch nobody has started remains
    underivable. The project header's Annotate control hit the same wall from the other side
    (Project header Annotate CTA silently picks the first active batch when several are in annotation #544, shipped in fix(ui): the project header asks which batch when several are open for annotation #545). batch.created_at / state_changed_at is a domain-model decision with
    wider consequences than a progress stamp and wants its own issue; it is recorded as out of scope
    on Restore true-recency resume via annotation_job_asset.touched_at #558 rather than taken here.
  • The stamp does not cover a repeated mark to a state the asset already holds, per the
    decision above. If a surface ever wants "somebody looked at this batch" rather than "somebody
    changed something in it", that is the case to revisit.
  • review_pending is reachable only through the annotator's own controls. There is no review
    queue to send anybody to and Home does not invent one: the CTA lands on a frame, not on a
    filtered list. If reviewing several frames in a row becomes the common path, making the gallery's
    In review segment addressable (?segment=review) is the small change that would support it.

Closes #558
Closes #559

… on it

`annotation_job_asset` gains a nullable `touched_at`, written inside
`UnitOfWork.set_asset_progress`'s guarded UPDATE so the stamp is as atomic as
the transition it records. Migration 8 adds it without a backfill: the moments
it holds were never recorded anywhere, so every existing row starts NULL and
means "nobody has worked this frame since the column existed".

`SummaryService` ranks open batches on it. A batch somebody has worked outranks
one nobody has, the later touch wins between two that have been, and batches
whose rows are all NULL keep the progress ranking that shipped before — which is
every batch in a workspace created before migration 8.

`_refresh_progress` now writes even when the derived progress does not move, so
an edit to an already-annotated frame stamps. `JobService.mark` keeps its no-op
short-circuit: a client re-sending a value the asset already holds is a retry
rather than somebody working the frame.

The resume target also gains a `kind` — annotate, review or open — resolved here
because the order between the three is a decision rather than a fact the rest of
the projection restates.
The resume card renders the kind the wire declares rather than working it out:
**Continue annotating**, **Review annotations** or **Open batch**, the first two
opening the editor and the third the gallery. A review-pending frame opens
read-only with the review actions on it, so there is no second screen to send
anybody to — and the count line follows the label, since how much of the batch
is labeled is not the number anybody came for once the labeling is done.
@JArmandoAnaya
JArmandoAnaya merged commit 031e0a1 into main Aug 12, 2026
15 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/home-recency branch August 12, 2026 18:47
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.

Home primary CTA priority: annotate, then review, then create Restore true-recency resume via annotation_job_asset.touched_at

1 participant