You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Run review history work in an abortable worker thread - #170
ReviewService.load read Git history (synchronous Git children and an object-storage walk) and linked it on the HTTP server's event loop, for up to 30 s. During that time cancellation, shutdown, request timeouts and every other request were stuck. This PR moves history reading and linking behind a worker boundary with an abortable protocol, and keeps the deadline as a worker-side budget.
Bounded workers: at most 2 history workers run at once, using FIFO slots that are held until each worker thread has ended. A queued job that is aborted, or whose budget runs out while it waits, never starts.
Worker per load (runner/history-job.ts, history-work.ts, history-worker.ts). On abort, or when the budget passes, the parent sends a cancel. The worker kills its running Git process, waits for it to exit, and replies. A worker that has not replied within 1 s (it is in synchronous linking or in the storage walk) is terminated. The job settles only after the thread has ended, and an abort rejects with the signal's own reason.
Abortable Git (git/history.ts). readHistory is async and takes a signal. It waits for each child's close, so it settles only after Git has exited, and it settles on the callback alone when Git never started. This was needed because worker.terminate() cannot interrupt execFileSync (measured: it waits for the child).
Budget. The budget crosses threads as relative milliseconds, counted from the worker's start message. A 5 s start grace applies until that message arrives. A shared timeOrigin clock was rejected because it drifts after system sleep.
ReviewService.load/act are async, and a synchronous apply(view, input, actionId) runs inside Store.userAction. Store reads and writes stay on the server thread. If the Store moved while the worker ran, the load reads again, at most three reads in all within the deadline. It reports the deadline, not staleness, when time runs out.
Callers. Every load takes the request's or operation's abort signal. No worker await follows a final remote read:
Merge admission inspects the PR once more after its final review load, then checks trust and the review's generations synchronously before the attempt.
Pre-merge preparation inspects the PR after its last review load.
Merge replays use a coordinator shutdown signal, and close() waits for them.
Pre-merge rechecks task generations after each worker await, before beginRebase and recordHistory.
Server behaviour.
A load failure before a review command is not recorded under its action ID, so the same ID can be resent.
Any failure after a command (or question retry) was applied answers with outcomeUnknown.
A request aborted after the shutdown drain keeps the existing 409.
A question saved during shutdown still gets its retryable "Server stopped" answer.
Browser specs. The specs run the server in the Playwright process and relied on the old blocking loads to sequence steps. They now wait for the page's review load or refresh (test/browser/review-page.ts). Playwright timeouts were raised (test 60 s, expect 15 s), because server Git time now counts.
Docs:README.md and docs/implementation/read-only-review.md ("History work off the server thread").
Test Coverage
New tests are in test/history-job.test.ts and test/history-spawn.test.ts, plus new cases in test/pre-merge.test.ts, test/merge.test.ts and test/merge-task-pr.test.ts. A gated git fixture (test/fixtures/gated-git.ts) holds a chosen Git call until the test releases it. The tests cover:
an aborted read kills Git and settles after it has exited;
a load runs while the server thread keeps working;
the worker enforces the deadline;
re-read on a Store change, and stale after three reads;
overlapping loads that both see a moved HEAD;
the deadline reported rather than staleness;
worker termination, proven by a heartbeat that stops;
the start grace and the never-start path;
a spawn failure (EMFILE);
the server answering other requests during a load, and shutdown ending the load and its Git process;
outcomeUnknown after an applied command, a retry and a failed reload;
an unrecorded load failure, and a replay loading less;
"Server stopped" answers when shutdown stops load 3 or load 4;
merge-replay abort at shutdown, and a trust revocation during the final merge load;
pre-merge: trust revoked and task changed during the final load, during the old-history read, and during both #refresh paths.
Each new regression test was run against the change with its fix removed, and it failed.
Pre-Landing Review
Seven independent review rounds were run by separate review agents, read-only, each over the full diff against origin/main22e76a8. The diff was uncommitted during review; the reviewed content is commit 2f9672e.
Round 1: cross-thread clock skew; merge replays not abortable; a markStopped reload abortable at shutdown; comment overclaims; missing tests. All fixed.
Round 2: loads no longer atomic (read-only GETs failing as stale); missing failing-before tests for the merge and pre-merge reorders; a question missing its answer when the post-apply load was aborted; the backstop counting from worker creation. All fixed.
Round 3: a possible hang when Git fails to spawn (EMFILE/ENFILE); doc wording; retry-question not marking applied; refreshReview passing without a refresh. All fixed.
Round 4: a task change during the rebase old-history read could be adopted, and the refreshReview doc comment overclaimed. Both fixed.
Round 5:#refresh recorded a head without the task guard; outcomeUnknown limited to aborts; "Stale" reported on deadline expiry. All fixed.
Round 6: no runtime defects. Three test and doc gaps, fixed.
merge.ts: the final review load came after the last GitHub read.
pre-merge.ts: a review load ran after the final remote.inspect.
Both were valid and are fixed in 771a683, with failing-before tests. The threads are answered and resolved.
Independent rounds after Copilot, on base 22e76a8 with the content of 771a683:
Round 8: verified both fixes. It found two test gaps: the retargeted pre-merge tests fired too early, and the merge generation check had no test. Both are fixed, and each test now fails only through the guard it names.
Round 9: no new findings. Its one observation was declined: in merge-queue mode the final load now runs after the queue watermark. That widens a window origin/main already had (an async inspect after the watermark), and the final status read still validates the PR's head, base, queue mode and identity.
A rejected terminate() settled a job before its thread exited (in the summary).
Both were valid and are fixed in 3de30ec, with failing-before tests. The thread is answered and resolved.
Independent rounds after Copilot round 2, on base 22e76a8 with the content of 3de30ec:
Round 10: no new findings in the code. During this round the full Vitest run exposed one test whose mocked clock now crossed the new queue wait; the test was fixed to move the clock once the worker held its budget.
Round 11: no product findings. It noted that this test could leak its stubs if it timed out; its cleanup moved to afterEach.
Round 12: no new findings.
Copilot round 3, on 3de30ec: 0 open findings. It noted that the exact final head lacked a full-suite run while Docker validation was blocked. Both are now covered by the test plan below.
Declined: the round 9 observation above.
Review lessons
Each review finding is classified below. The new rules are in AGENTS.md ("Async jobs and polling"), commits ca97365, 7b8b698 and c4667ad.
Finding (round)
Classification
Cross-thread clock skew from timeOrigin (independent round 1)
New rule: never compare clock readings across threads; pass a relative budget.
Loads no longer atomic (round 2); a task change during the rebase old-history read (round 4); #refresh recorded a head without the task guard (round 5); the final review load after the last GitHub read in merge and pre-merge (Copilot round 1)
New rule: when synchronous work moves behind an await, "list every await it adds and apply the local-generation rule under 'Guarded external actions' to each one"; "a local await must not follow the final remote validation".
Unbounded worker concurrency (Copilot round 2)
New rule: work that leaves a serializing path needs its own concurrency bound.
Browser specs relied on the in-process server blocking Playwright (validation)
New rule: tests that host the server wait for observable state.
Merge replays not abortable or awaited at close (round 1); a rejected terminate() settled a job before its thread exited (Copilot round 2)
Covered: "Do not release a concurrency slot when cancellation is requested…" and "Begin shutdown… then cancel and await jobs".
Comment and doc overclaims (rounds 1, 3, 4, 6)
Covered: "Treat every behavioural claim… as something to verify".
Missing or weak tests (rounds 1, 6, 8); missing failing-before tests for the merge and pre-merge reorders (round 2); pre-merge tests firing early (round 8); refreshReview passing without a refresh (round 3)
Covered: "give it a test that fails if the claim is false" (Review readiness).
"Stale" reported when the deadline had passed (round 5)
Covered: "Preserve the original timeout, cancellation, and shutdown reason".
A failure after an applied command was reported as a refusal (rounds 3, 5)
Covered: "Once an irreversible external command succeeds, do not convert later refresh or rendering failures into action failure".
Spawn failure (EMFILE) never emitting close (round 3)
One-off: a Node child_process detail with no general rule beyond the existing subprocess-settlement race.
Backstop counted from worker creation (round 2)
One-off: specific to this protocol's start message.
markStopped reloading, abortably, at shutdown (round 1); a question saved during shutdown missing its answer (round 2)
One-off: specific to Questions.markStopped.
A test's mocked clock crossing the new queue wait (round 10 validation); test cleanup leaking on timeout (round 11); the gated-git numbering race (validation)
One-off: test-fixture fixes in this PR.
Exact final head lacked a full-suite run and Docker validation (Copilot round 3)
One-off: validation evidence, now recorded in the test plan.
Merge-queue watermark window (round 9)
Declined, as recorded above.
Documentation
Updated:
README.md: the async readHistory example and the limits paragraph.
docs/implementation/read-only-review.md: a new section on lifecycle, deadline, cancellation, staleness, review commands, and merge/preparation guards.
The /ship document-release subagent audit was not run.
Not run in this /ship
origin/main had not moved. The repo has no VERSION, CHANGELOG or TODOS file. The /ship review army, adversarial review, coverage audit and document-release audit were not repeated: the seven independent rounds and the runs below cover this exact content.
Docker project: not run locally, because the Docker daemon is unresponsive. CI's real-docker job passed on 3de30ec.
Follow-up (pre-existing, out of scope): the review page loses text typed, or an Import click, before its first review load finishes.
ReviewService.load read Git history (synchronous Git children and an
object-storage walk) and linked it on the HTTP server's event loop, for
up to 30 s. During that time cancellation, shutdown, request timeouts and
every other request were stuck.
History reading and linking now run in one worker thread per load
(runner/history-job.ts). readHistory spawns Git asynchronously and waits
for each process to exit, so a cancel kills the running Git process; a
worker that cannot read the cancel within 1 s is terminated. The load's
deadline crosses threads as a relative budget counted from the worker's
start. Store reads and writes stay on the server thread; a load whose
inputs changed while the worker ran reads again, at most three reads in
all.
load and act are async, and a synchronous apply runs inside
Store.userAction. Every load takes the request's or operation's abort
signal. Merge admission, pre-merge preparation and merge replays recheck
their trust and task generations after each new wait, in the same turn
as the write they guard. A failure after a review command was applied
now says outcomeUnknown, a load failure before it is not recorded under
its action ID, and a question saved during shutdown still gets its
retryable answer.
Browser specs ran the server in the Playwright process and relied on the
old blocking loads to sequence steps. They now wait for the page's
review load or refresh, and the Playwright timeouts allow for the
server's real Git work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#170 review)
The worker-backed review load had become an await after the final GitHub
read: merge admission could begin an attempt for a pull request that
moved while the load ran, and pre-merge preparation could record
readiness for a stale remote pair.
Merge admission now inspects the pull request once more after its final
review load, compares it with the previous pass, and only then checks
local trust and the review's generations synchronously before the
attempt. Preparation drops the review load that followed its final
remote inspection; readiness uses the view loaded just before it, which
the synchronous generation check after the inspection keeps current.
Tests cover a pull request moving and the review changing during the
final merge load, the load/inspect order in preparation, and trust or
task changes during preparation's final load, each asserting the check
that refuses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Do not settle jobs before rejected worker termination has exited
runner/history-job.ts:62
terminate() rejecting does not prove the worker thread has exited, but this rejection branch calls deliver() and settles the job anyway. During cancellation/shutdown that can release ownership while the worker (and potentially its Git child) is still alive, contradicting the method's lifecycle guarantee. Let the exit handler be the only path that delivers the decided outcome; a rejected termination request should not settle the job.
…ts (#170 review)
Review loads no longer serialize on the server thread, so several tabs or
quick refreshes could start any number of worker threads and Git
processes. At most two history workers now run at once. A later job
waits for a slot in arrival order; the wait counts against its budget,
and a job aborted or out of time while it waits never starts. Each slot
is held until its worker thread has ended.
A rejected worker.terminate() no longer settles the job: it proves
nothing about the thread, so the job waits for the thread's exit event.
The gated Git test fixture numbers calls under a lock, so calls that
start together get distinct numbers. The review deadline test moves its
mocked clock only once the worker holds its budget, and its clock and
environment stubs are restored in afterEach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ead (#170)
Four rules for async work, from the findings on this pull request:
recheck every same-turn guard after a new await and make the final
remote validation the last await before an irreversible action; bound
work that leaves a serializing path; never compare clocks across
threads; and in tests that host the server, wait for observable state
instead of relying on the server blocking the test's event loop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's tree equals c4667ad, this branch's former base, so the merge keeps this
branch's tree unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves runner/merge.ts #replay (keeps #170's async load, shutdown abort
and tracked replays, plus #48's aborted-click answer and the click's own
signal on its fresh read) and web/server.ts (#170's async loadReview with
#48's statusAfterMerge). Adapts #48's new tests to the async review
service; one test now waits for the click's gh call before shutdown, as
production does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ctions
Since #170 a click can be in a review load when its deadline fires, and
the load's history worker gets 1 s before it is terminated, so a click
can settle about 1 s or a little more after its deadline, not 0.75 s.
The merge-queue poll tests read the recorded snapshot from the Store
instead of running a review load inside every mocked inspection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since #170 the review load awaits a worker; reading the queue snapshot
before it could pair an older queue state with a newer attempt status.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lan import (#172)
* Run review history work in an abortable worker thread
ReviewService.load read Git history (synchronous Git children and an
object-storage walk) and linked it on the HTTP server's event loop, for
up to 30 s. During that time cancellation, shutdown, request timeouts and
every other request were stuck.
History reading and linking now run in one worker thread per load
(runner/history-job.ts). readHistory spawns Git asynchronously and waits
for each process to exit, so a cancel kills the running Git process; a
worker that cannot read the cancel within 1 s is terminated. The load's
deadline crosses threads as a relative budget counted from the worker's
start. Store reads and writes stay on the server thread; a load whose
inputs changed while the worker ran reads again, at most three reads in
all.
load and act are async, and a synchronous apply runs inside
Store.userAction. Every load takes the request's or operation's abort
signal. Merge admission, pre-merge preparation and merge replays recheck
their trust and task generations after each new wait, in the same turn
as the write they guard. A failure after a review command was applied
now says outcomeUnknown, a load failure before it is not recorded under
its action ID, and a question saved during shutdown still gets its
retryable answer.
Browser specs ran the server in the Playwright process and relied on the
old blocking loads to sequence steps. They now wait for the page's
review load or refresh, and the Playwright timeouts allow for the
server's real Git work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Make a remote read the last await before merge and preparation writes (#170 review)
The worker-backed review load had become an await after the final GitHub
read: merge admission could begin an attempt for a pull request that
moved while the load ran, and pre-merge preparation could record
readiness for a stale remote pair.
Merge admission now inspects the pull request once more after its final
review load, compares it with the previous pass, and only then checks
local trust and the review's generations synchronously before the
attempt. Preparation drops the review load that followed its final
remote inspection; readiness uses the view loaded just before it, which
the synchronous generation check after the inspection keeps current.
Tests cover a pull request moving and the review changing during the
final merge load, the load/inspect order in preparation, and trust or
task changes during preparation's final load, each asserting the check
that refuses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Bound concurrent history workers and settle only after the thread exits (#170 review)
Review loads no longer serialize on the server thread, so several tabs or
quick refreshes could start any number of worker threads and Git
processes. At most two history workers now run at once. A later job
waits for a slot in arrival order; the wait counts against its budget,
and a job aborted or out of time while it waits never starts. Each slot
is held until its worker thread has ended.
A rejected worker.terminate() no longer settles the job: it proves
nothing about the thread, so the job waits for the thread's exit event.
The gated Git test fixture numbers calls under a lock, so calls that
start together get distinct numbers. The review deadline test moves its
mocked clock only once the worker holds its budget, and its clock and
environment stubs are restored in afterEach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Record the review lessons from moving review loads off the server thread (#170)
Four rules for async work, from the findings on this pull request:
recheck every same-turn guard after a new await and make the final
remote validation the last await before an irreversible action; bound
work that leaves a serializing path; never compare clocks across
threads; and in tests that host the server, wait for observable state
instead of relying on the server blocking the test's event loop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Trim the new async rules to what existing rules do not already say (#170 review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Say plainly which process the in-process test rule is about (#170 review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep input made before the first review load, and explain a refused plan import
Text typed into the composer before the first /api/review answer had no item
to belong to, so applying the answer erased it (switching Ask/Request change
erased it too). It is now kept under the unselected key and handed to the first
item render() selects.
"Import next revision" did nothing visible while no plan was loaded. It is now
aria-disabled with no plan, and pressing it says why: still loading (written
under the load's status ownership, so the load's result replaces it), or not
loaded after a failure. The Plans screen shows "Loading plan…" during the first
load and Refresh plan. The busy-write refusal is checked first, so an import
pressed during a Retry Apply no longer hides the Apply's result.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test that text typed before a failed first load reaches the first item after Refresh (#172 review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Explain a refused plan import even when no file is chosen (#172 review)
The file input's `required` let the browser's own check stop the form before
importPlan ran, so with no plan loaded and no file chosen the page asked for a
file instead of saying why it could not import. The form is now novalidate;
importPlan already reports a missing file. The no-plan refusal test also checks
its ✕ status colour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test an import with a plan loaded and no file; say which refusals keep ownership (#172 review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Say exactly which import refusals keep status ownership (#172 review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test text typed after the last mode switch; state why the file refusal claims status (#172 review)
The draft tests switched modes before the load answered, which saved the text
early, so they passed even without the save made when the response applies.
They now type more after the last switch. The importPlan comment now says the
file refusal always claims the status line, because feedback on the file just
chosen is newer than any read already running (as "does not let an older failed
Review refresh replace newer Plans validation feedback" already requires).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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
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.
Summary
ReviewService.loadread Git history (synchronous Git children and an object-storage walk) and linked it on the HTTP server's event loop, for up to 30 s. During that time cancellation, shutdown, request timeouts and every other request were stuck. This PR moves history reading and linking behind a worker boundary with an abortable protocol, and keeps the deadline as a worker-side budget.runner/history-job.ts,history-work.ts,history-worker.ts). On abort, or when the budget passes, the parent sends a cancel. The worker kills its running Git process, waits for it to exit, and replies. A worker that has not replied within 1 s (it is in synchronous linking or in the storage walk) is terminated. The job settles only after the thread has ended, and an abort rejects with the signal's own reason.git/history.ts).readHistoryis async and takes asignal. It waits for each child'sclose, so it settles only after Git has exited, and it settles on the callback alone when Git never started. This was needed becauseworker.terminate()cannot interruptexecFileSync(measured: it waits for the child).timeOriginclock was rejected because it drifts after system sleep.ReviewService.load/actare async, and a synchronousapply(view, input, actionId)runs insideStore.userAction. Store reads and writes stay on the server thread. If the Store moved while the worker ran, the load reads again, at most three reads in all within the deadline. It reports the deadline, not staleness, when time runs out.close()waits for them.beginRebaseandrecordHistory.outcomeUnknown.test/browser/review-page.ts). Playwright timeouts were raised (test 60 s, expect 15 s), because server Git time now counts.README.mdanddocs/implementation/read-only-review.md("History work off the server thread").Test Coverage
New tests are in
test/history-job.test.tsandtest/history-spawn.test.ts, plus new cases intest/pre-merge.test.ts,test/merge.test.tsandtest/merge-task-pr.test.ts. A gatedgitfixture (test/fixtures/gated-git.ts) holds a chosen Git call until the test releases it. The tests cover:outcomeUnknownafter an applied command, a retry and a failed reload;#refreshpaths.Each new regression test was run against the change with its fix removed, and it failed.
Pre-Landing Review
Seven independent review rounds were run by separate review agents, read-only, each over the full diff against
origin/main22e76a8. The diff was uncommitted during review; the reviewed content is commit 2f9672e.markStoppedreload abortable at shutdown; comment overclaims; missing tests. All fixed.retry-questionnot markingapplied;refreshReviewpassing without a refresh. All fixed.refreshReviewdoc comment overclaimed. Both fixed.#refreshrecorded a head without the task guard;outcomeUnknownlimited to aborts; "Stale" reported on deadline expiry. All fixed.Copilot round 1, on 2f9672e:
remote.inspect.Both were valid and are fixed in 771a683, with failing-before tests. The threads are answered and resolved.
Independent rounds after Copilot, on base 22e76a8 with the content of 771a683:
origin/mainalready had (an async inspect after the watermark), and the final status read still validates the PR's head, base, queue mode and identity.Copilot round 2, on 771a683:
terminate()settled a job before its thread exited (in the summary).Both were valid and are fixed in 3de30ec, with failing-before tests. The thread is answered and resolved.
Independent rounds after Copilot round 2, on base 22e76a8 with the content of 3de30ec:
afterEach.Copilot round 3, on 3de30ec: 0 open findings. It noted that the exact final head lacked a full-suite run while Docker validation was blocked. Both are now covered by the test plan below.
Declined: the round 9 observation above.
Review lessons
Each review finding is classified below. The new rules are in
AGENTS.md("Async jobs and polling"), commits ca97365, 7b8b698 and c4667ad.timeOrigin(independent round 1)#refreshrecorded a head without the task guard (round 5); the final review load after the last GitHub read in merge and pre-merge (Copilot round 1)terminate()settled a job before its thread exited (Copilot round 2)refreshReviewpassing without a refresh (round 3)close(round 3)child_processdetail with no general rule beyond the existing subprocess-settlement race.markStoppedreloading, abortably, at shutdown (round 1); a question saved during shutdown missing its answer (round 2)Questions.markStopped.Documentation
Updated:
README.md: the asyncreadHistoryexample and the limits paragraph.docs/implementation/read-only-review.md: a new section on lifecycle, deadline, cancellation, staleness, review commands, and merge/preparation guards.The /ship document-release subagent audit was not run.
Not run in this /ship
origin/mainhad not moved. The repo has no VERSION, CHANGELOG or TODOS file. The /ship review army, adversarial review, coverage audit and document-release audit were not repeated: the seven independent rounds and the runs below cover this exact content.Docker project: not run locally, because the Docker daemon is unresponsive. CI's
real-dockerjob passed on 3de30ec.Follow-up (pre-existing, out of scope): the review page loses text typed, or an Import click, before its first review load finishes.
Test plan
npm run typecheck: no errors (on 3de30ec).npx vitest run --project parallelon exactly 3de30ec (clean worktree): 2042/2042 passed.npx playwright test: 137/137 passed, with the worker cap.testandreal-dockerboth passed.real-dockercovers the Docker gate; locally, the Docker daemon is unresponsive.🤖 Generated with Claude Code