Skip to content

fix(annotator): completing a job flips the workspace to read-only in place - #447

Merged
JArmandoAnaya merged 1 commit into
mainfrom
fix/finish-readonly
Aug 8, 2026
Merged

fix(annotator): completing a job flips the workspace to read-only in place#447
JArmandoAnaya merged 1 commit into
mainfrom
fix/finish-readonly

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #439

The diagnosis, corrected before the fix

The issue was filed as a stale-declaration bug: the mutation completes, the workspace keeps the
allowed_actions it already held, and the mode never re-derives. That reproduction was run first
and did not hold. A TestClient walk over the real kernel — approve, start, annotate every
asset, POST /jobs/{job_id}/complete — answers:

After the job completes
batch.state in_annotation
job.state / job.allowed_actions completed / []
every asset's allowed_actions ["annotate", "skip", "submit_for_review"]
a subsequent POST /jobs/{job_id}/annotations 201 Created

Three facts follow, and together they move the fix a layer down:

  1. Completing a job does not complete its batch. JobService.complete says so in its own
    docstring; BatchService derives batch completion from the jobs when asked, and the workspace
    never asks. So the batch gate — the only dimension asset_actions had past progress — cannot
    see a finished job at all.
  2. The declaration did not move, so the workspace was showing a current answer rather than a
    stale one. useJobTransition's onSuccess already invalidated jobKeys.job(jobId),
    ["projects"] and ["batches"], and the last of those prefix-covers the workspace's own asset
    query key. The refetch happened; the refetched answer was identical. Adding invalidation would
    have been a no-op.
  3. The kernel still accepted the write. AnnotationService gated on require_open_batch plus
    WRITABLE_PROGRESS, and JobService.mark on require_open_batch alone — job state was nowhere
    in either path. Labels written after Finish were stored, and an asset in a finished job could be
    un-skipped back to unannotated, unsettling a job whose own record already said every asset was
    dealt with.

So Finished described nothing, and the layer holding the wrong answer was the wire. Ruled on
before any code was written (2026-08-08): a completed job is closed for writes, and the gate
belongs in the kernel, because the alternative — reading jobState in the page — is the frontend
deciding legality, which ui-capabilities bans and which would have left the CLI, MCP and REST
still writing into finished work.

What changed

The kernel, and one set read by both sides. OPEN_JOB_STATES ({pending, in_progress}) joins
WRITABLE_PROGRESS and SETTLED_PROGRESS in domain/task.py, stated outright rather than derived
as "the states with a move left" — PROMOTABLE_PROGRESS's argument, since the two agree today by
coincidence.

  • asset_actions(progress, *, batch_state, job_state) — three dimensions, none of them optional
    and none defaulted, so a caller cannot drop one the way the browser's old mirror dropped the
    batch dimension.
  • JobService.require_open_job raises JobFinished (409 JOB_FINISHED). Called by the three
    annotation writes and by mark, before mark's documented no-op check — writing into work
    that is over is a bug whether or not the value would have changed. Reads are untouched:
    for_asset passes membership alone, because a viewer over finished work has to be able to show
    it.
  • Threaded through both wire projections (server/models.py and visionset/wire/) and both
    listings that build them, so REST and MCP answer identically. No schema change — the field is
    allowed_actions and it simply comes back empty, so openapi.json and the generated client are
    byte-identical.

The frontend does not compute anything new. The mode flips because readOnly re-derives from a
declaration that finally moved. What the page gains is copy and one absence:

  • A third banner cause, ranked below a closed batch and above a settled frame: "This job is
    finished, so its frames can no longer be edited."
    It names no route onward, deliberately —
    JOB_TRANSITIONS has no way back and the batch is still open, so there is no correction to
    offer. The three causes are held as one value (workflowBecause) because three places have to
    agree on them: the banner renders for either workflow cause, the skipped notice yields to either,
    and the sentence is whichever spoke. Without that, a skipped frame in a finished job would have
    reproduced Completed batch opens editable: edit functions remain active in the read-only workspace #423 one level down.
  • The frame's own verbs leave once the job is closed: Skip / Un-skip and the flow verb
    (Save and next / Next) are absent rather than disabled on every frame of a completed batch or
    a finished job — the tool strip's rule applied to the bar, since all three only ever move this
    frame
    and there is no move behind any of them there. The browse | resolve divider goes with
    them.
  • Completion is said out loudtoast.success, the add-a-class chain's idiom — because
    everything else the press does is a subtraction, and a screen with less on it is not an
    explanation.

Two deviations from the task, both named, both found by a test

The task's §B3 says the flow verbs are absent "in read-only". Taken literally — gating on the
frame's readOnly — it breaks two shipped rules, and the browser suite said so rather than review:

  1. Finish job is kept. complete is the job's declaration, not the frame's. A job whose
    last frame is accepted — read-only, while other frames are still outstanding — would have had
    no way to be finished from the workspace at all. The cycle walk is the proof rather than the
    argument: it accepts its last frame and then presses Finish, so removing the control breaks
    the real-kernel suite. Where it is withheld it already explains itself (Finish job on the last frame: disabled state must explain itself #427); once pressed it
    reads Finished.

  2. The gate is the job, not the frame. A per-frame gate cost six e2e tests on the first run,
    and two of them were load-bearing. Un-skip is the one way back out of a skipped frame, and a
    skipped frame is read-only — gating on the mode strands it under a notice promising a control
    that is not there, which is Completed batch opens editable: edit functions remain active in the read-only workspace #423 reproduced one level down. And Annotation top bar: consolidate image navigation into a centered cluster #416 measured the navigation
    cluster to a constant width so that walking a job does not move the arrows under a cursor; a
    slot that emptied and refilled frame by frame through a mixed job breaks that.

    frameVerbs is therefore workflowBecause === null — a closed batch or a finished job, both of
    which withhold every move on every frame alike, so the cluster is uniformly narrower and nothing
    jitters. That is also exactly the state §B3 is about.

The behaviour was written down as correct

tests/mcp/test_job_tools.py::test_a_write_into_a_completed_job_starts_nothing asserted the write
lands, and said why in its own docstring: "Writing here is legal — the gate is the batch, and its
batch is still in_annotation."
That sentence is the bug, stated as a rule. The claim the test
actually exists to protect is #109's — that add_annotations auto-starting a pending job must
never drag a completed one backwards — and that claim survives intact; only the "and the write
still lands" half is gone. It now asserts the refusal and the unchanged job state together, because
a refusal that had nevertheless started the job would be the same backwards move by another road.

tests/server/test_errors.py's exact-correspondence table also went red on its own, which is the
table doing its job: an unmapped kernel error answers 500 the day a route reaches it.

Already true at HEAD, asserted rather than implemented

Of the three surfaces the task asked to verify, two needed no work and are now pinned on the far
side of the transition instead of only on a completed batch: the tool strip has been absent
rather than disabled since #306, and the classes panel absent with the objects region at full
height
is #431's, re-measured post-flip. Navigation was already ungated and is asserted
working — , the counter and the gallery — with no save-first guard engaging.

Found, not fixed

  • BatchAssetOut publishes job_id but not job_state, so a client that wanted to render why a
    frame is frozen would have to fetch the job. Nothing needs it today — the workspace already holds
    the job — and adding a field to a published model is a wire change that should be asked for.
  • The gallery and batch screens read the same allowed_actions and therefore go quiet on a
    finished job's frames too, which is correct; whether they should say why is a separate design
    question and not smuggled in here.
  • Inside a working job, Skip on an accepted or review_pending frame is still a disabled
    control with no reason attached to it — the title spread it carried was closedBecause, which
    is null in exactly the case the control now renders in, so the spread was dead and is gone. The
    banner above says why the frame is a viewer, which is the one-explanation-surface rule; whether
    the control should carry it too is Finish job on the last frame: disabled state must explain itself #427's question applied to a second control.

Test plan

  • tests/kernel/test_capabilities.py — the asset matrix gains its third dimension. (batch, job, progress) scenarios now include in_progress throughout and the row where the bug lived: a
    completed job inside an in_annotation batch. Plus three sentence-tests, one of which
    (test_the_asset_gate_reads_the_same_set_the_services_refuse_by) makes the stronger claim — that
    what an asset declares is computed from the very set the service raises against, so a third job
    state cannot be admitted by one and refused by the other.
  • tests/kernel/test_job_service.py — a finished job refuses a progress move inside an open batch;
    the gate fires before the no-op; a second job of the same batch carries on, which is the
    difference between this gate and the batch's.
  • tests/kernel/test_annotation_service.py — all three writes refused, nothing moved; reads still
    work.
  • tests/server/test_annotations.py — 409 JOB_FINISHED over HTTP with the batch deliberately left
    open, the declarations asserted empty in the same test (declaration and refusal agreeing is the
    contract), the progress route refused by the same code, and an open-job control.
  • frontend/app/e2e/annotate.spec.ts — the deterministic reproduction, in chromium: drive to the
    last frame with everything resolved, press Finish, and assert the viewer with no reload. A
    sentinel written on window before the press is read back after it, so a reload fails the test
    rather than passing it. Then: banner and its sentence, no palette / classes region / Skip /
    Save and next, Finished, the objects region measured at full panel height, Read-only workspace: no class panel, no edit affordances, selection syncs to the panel #426's handle and
    selection-sync rules on the far side, the flip on a middle frame after navigating, and the
    gallery still opening.
  • frontend/app/cycle/cycle.spec.ts — the same claim against a real server and a real kernel, at
    the point the walk already presses Finish, with the batch still in_annotation.
  • frontend/ui-core/src/annotator/topBar.test.tsx — the filled-slot sweep becomes "at most one",
    which is the rule change stated where it was pinned.

Mutation verification, each applied by its exact diff with the anchor asserted before and after,
reverted by git apply -R, tree byte-identical to this commit at the end:

Mutation Named test that went red
the job_state gate removed from asset_actions e2e "finishing the job turns the workspace into a viewer in place, on every frame"; test_a_finished_job_offers_its_assets_nothing_even_in_an_open_batch; the asset matrix
require_open_job removed from the three annotation writes test_nothing_is_written_after_the_job_finishes_even_with_the_batch_open; test_no_annotation_is_written_into_a_finished_job
require_open_job removed from JobService.mark test_a_finished_job_refuses_a_progress_move_inside_an_open_batch; test_the_job_gate_fires_before_the_no_op
frameVerbs forced true (the verbs render on a closed job) e2e "finishing the job turns the workspace into a viewer in place, on every frame"; "a completed batch opens as a viewer, and says so"
the banner's finishedBecause cause removed the same e2e, on its sentence assertion; the cycle step

Gate stages, run in the worktree (staged, because a full pytest here runs past the harness's
ceiling; exit codes verbatim):

Stage Exit
uv run pytest -q -rf tests/kernel 1 — two ffmpeg-environment failures only, see below
uv run pytest -q -rf --ignore=tests/kernel 0
uv run ruff check . / uv run ruff format --check . 0 / 0
uv run mypy src/visionset 0
uv run lint-imports 0
pnpm -r build / pnpm test / pnpm -r lint (all three annotator boundary gates) 0 / 0 / 0
openapi drift / generated client drift / mcp reference drift / version sync 0 / 0 / 0 / 0
browser e2e (CI=1 playwright test, 231 tests) 0
browser cycle (CI=1 playwright test -c playwright.cycle.config.ts, real server + real kernel) 0

The two kernel failures are pre-existing on main and tracked as #444 — merged under the
baseline-proof exception (refactor-protocol, added by #446), step python tests, cf. #444.

Branch run, uv run pytest -q -rf tests/kernel (exit 1):

=========================== short test summary info ============================
FAILED tests/kernel/test_ingest_service.py::test_a_truncated_clip_keeps_what_decoded_and_reports_the_break
FAILED tests/kernel/test_video_processor.py::test_a_truncated_clip_yields_what_decoded_and_then_refuses

Baseline run, the same two node ids on unmodified main at the merge-base 1a59521, working
tree clean (git status --short empty), same machine:

src/visionset/kernel/adapters/ffmpeg_video_processor.py:518: UnsupportedMedia
=========================== short test summary info ============================
FAILED tests/kernel/test_ingest_service.py::test_a_truncated_clip_keeps_what_decoded_and_reports_the_break
FAILED tests/kernel/test_video_processor.py::test_a_truncated_clip_yields_what_decoded_and_then_refuses

Identical, and the cause is #444's rather than anything here: this diff touches capability
declarations, the job and annotation service gates, two wire projections and the annotation
workspace, while those two tests exercise ffmpeg frame extraction from a truncated clip —
ffmpeg_video_processor.py, which this branch does not touch at all. The python CI job is the
arbiter and is green on this PR.

…place

A completed job went on declaring `annotate` on every one of its frames, and
went on accepting the labels that declaration promised. Completing a job does
not complete its batch — `BatchService` derives that separately — so the batch
gate had nothing to say, and the annotation workspace stayed a live editor over
work it had just been told was finished.

The gate is the kernel's, not the page's. `OPEN_JOB_STATES` is the set both
sides read: `asset_actions` takes the job's state as a third dimension, and
`JobService.require_open_job` refuses the three annotation writes and `mark`
with `JobFinished` (409 `JOB_FINISHED`), so the declaration and the refusal
cannot disagree. The workspace then flips through the invalidation the Finish
mutation already performed — in place, on every frame, with nothing in the
browser computing legality.

Beside it, the frame's own verbs leave the bar once the **job** is closed:
`Skip` / `Un-skip` and the flow verb are absent rather than disabled on every
frame of a completed batch or a finished job. The gate is the job and not the
frame, because a settled frame inside a working job still keeps its `Un-skip`
and still owes the cluster its measured width. `Finish job` stays either way —
`complete` is the job's declaration, not the frame's.
@JArmandoAnaya
JArmandoAnaya merged commit 856d2f8 into main Aug 8, 2026
13 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the fix/finish-readonly branch August 8, 2026 13:34
JArmandoAnaya added a commit that referenced this pull request Aug 8, 2026
…439)

The skill encoded the pre-#447 world: annotation writes gated on asset
progress alone, with no job dimension. Left as it was, the next design
session consulting it re-derives the hole that #439 closed.

Decision 2 now states both dimensions and names OPEN_JOB_STATES as the one
set the declaration (asset_actions) and the refusal (require_open_job) both
read, so they cannot disagree; it also states why the batch gate never
covered this — a job completing does not complete its batch. Decision 9
records the two rulings #447 shipped and a later reader would tidy away:
frame-verb gating is job-level, and Finish job stays reachable on a settled
last frame.

Prose sweep for the same false sentence: docs/annotations.md, docs/jobs.md,
docs/ui.md, docs/mcp.md, docs/mcp-walkthrough.md, mcp/_autostart.py,
AnnotationPage.tsx and GalleryScreen.tsx all described one or two gates
where there are now three. ui-capabilities gains the read-only transition,
which is what a mid-session Finish now performs.
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.

Finishing a job leaves the workspace in stale edit mode — must flip to read-only in place

1 participant