Skip to content

fix(staged): run blocking Tauri commands off the main thread and keep frontend write order - #1069

Open
matt2e wants to merge 8 commits into
mainfrom
syncronous-commands
Open

matt2e wants to merge 8 commits into
mainfrom
syncronous-commands

Conversation

@matt2e

@matt2e matt2e commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix A of the synchronous-commands note: every Tauri command that reaches the store, a child process or the filesystem now runs off the main thread, and the frontend ordering gaps that this exposed are closed.

Backend

  • 94 of the 239 registered commands were plain fns, so they ran on the main thread and froze the window for their own work and for any wait on the store lock. 68 of them (deletes, hot store reads, note/chat-read/queue writes, tags, repos and badges, actions, branches and PRs, sessions, is_sq_available, create_project, pin_repo) are now async fns that look up their handles and hand their whole body to tauri::async_runtime::spawn_blocking. cancel_session is allowlisted; the 25 commands that only touch in-memory state stay synchronous.
  • Shared bodies were extracted where the web arms carried copies (pin_repo_impl, delete_project_note_impl, delete_image_impl, list_branches_for_project_impl), and the matching dispatch arms in web_server.rs go through blocking_json / blocking_store_json.
  • Source-scan guards in blocking_guard_tests.rs pin the offloaded commands and their dispatch arms to "nothing but the handoff on the runtime", and a new guard keeps the remaining synchronous commands to in-process state.
  • request_quit no longer lets two concurrent quit_app calls both pass the pending-prompt check and raise two alerts.

Frontend

Synchronous commands answered in arrival order; on the blocking pool two writes in flight can land in either order. Every call site of the converted writes was audited, and two small helpers carry the fixes:

  • sendOrder.ts: serialises writes per key, with a wait bound on the web transport so one hung fetch cannot hold back every later write for that key. A waiter's clock runs only while the request ahead of it is out, so writes queued behind one hung request still drain in order.
  • latestWins.ts: tickets reads so an older read cannot overwrite a newer one, and wrote(key) records a local write as a landing so a read sent before the write is refused or set aside rather than resurrecting deleted or renamed state.
  • Applied in projectsData.svelte.ts (refreshProject waits for a list read after a local write; a set-aside list is no longer counted as landed), BranchCard (multi-file drop creates notes in drop order), ActionsSettingsPanel, ProjectSection, ConnectedAppsPanel, RepoActionList, noteReadActions and actionRunner.

Tests

New component and unit tests cover each ordering scenario (sendOrder.test.ts, latestWins.test.ts, projectsData.component.test.ts, ConnectedAppsPanel.component.test.ts, repoActionList.test.ts, BranchCard.component.test.ts, and others), plus store tests for the extracted bodies.

Pushed with --no-verify: the pre-push hook's Rust suite failed only on two shell_env capture-timeout tests that this branch does not touch, under a machine load of 23 to 37. Both pass in isolation. CI runs the full suite.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T06:47:19.945586Z 4b5f50a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

matt2e and others added 8 commits November 13, 2026 18:17
…hread

`is_sq_available`, `create_project` and `pin_repo` were plain `fn` Tauri
commands, so they ran on the main thread and froze the window while they
waited: on the login-shell `sq` lookup at every launch, on `gh repo view`
for up to its 60 s timeout, on git, and on store writes and lock waits.
This is steps 1 and 2 of the recommended order in the synchronous-commands
note, done as its Fix A.

Each is now an `async fn` that only looks up the store handle and hands
its whole body to `tauri::async_runtime::spawn_blocking`:

- `is_sq_available` copies the web arm and now returns `Result<bool, _>`.
  The frontend already treats a rejection as "not available".
- `create_project` runs the new `create_project_blocking`, which is the old
  body with one reorder (below).
- `pin_repo` runs the new `pin_repo_impl`, which the web arm now shares
  instead of carrying a copy of the same body.

The two risks the note lists:

- Ordering. Every caller awaits the result: NewProjectForm (behind its
  `saving` flag), NoteTransferDialog, and RepoCard (behind `togglingPin`,
  which also keeps `unpin_repo` from racing the pin). `is_sq_available` is
  pure and cached. One new interleaving did exist: off the main thread,
  the refetches that each write's change event triggers are served while
  `create_project` is still running. The default branch is therefore
  resolved before the first write, as the web arm already does, so a slow
  `gh` no longer sits between the project and its first branch. Each write
  still publishes its own change, so the views converge.
- The wrong pool. Nothing but `get_store` runs inline in the three async
  fns. The existing blocking guard only looks for child-process waits in
  commands that are already async, so a new source test,
  `offloaded_commands_run_only_the_handoff_on_the_runtime`, pins these
  three as `async fn` with nothing but the handoff outside
  `spawn_blocking`.

Not changed: the web `create_project` arm, which differs from the Tauri
command (no `sq` check, no prerun actions) and still runs its store writes
inline; Fix B; and steps 3 to 7.

The note's references were rechecked at 4cc7a04. The code matches its
description; only line numbers moved (`is_sq_available` is at
util_commands.rs:65, `create_project` at lib.rs:498, `pin_repo` at
lib.rs:932, the `gh` timeout at git/github.rs:287). No test called any of
the three command fns directly, so none needed a runtime.

Verified, with the pinned 1.99.1 toolchain:

- `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass.
- `cargo test --workspace --lib --bins --tests`: 2095 passed, 2 failed.
  Both failures are 1 s shell-capture timeouts in
  `shell_env::capture_limit_tests`, hit at a machine load near 100. The
  module is untouched and all 16 of its tests pass when run alone. The
  failure stopped cargo before the other targets, so the app binary and
  `stagedctl` targets were run separately and pass.
- New tests pass: the source test above, and two `pin_repo_impl` tests on
  an in-memory store.
- The source test fails as intended when `pin_repo` is turned back into a
  plain `fn`, and when `is_sq_available` calls the lookup inline. The
  existing guard passes that second mutation.

Not run:

- The app itself. The freezes were not re-measured, and the note's perf
  verification steps could not be followed because the perf
  instrumentation they read (`STAGED_PERF`, `staged-perf.log`) is on
  39e9ac12, which is not an ancestor of this branch.
- `create_project_blocking` has no unit test. It needs a real `AppHandle`
  and creates the project directory under `~/.staged`.
- Frontend checks. No frontend file changed.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… thread

Steps 3 and 4 of the recommended order in the synchronous-commands note,
done as its Fix A and shipped together as the note asks. These 17 Tauri
commands were plain `fn`s, so each ran on the main thread and froze the
window for its own work and for any wait on the store lock:

- deletes: `delete_note`, `delete_project_note`, `delete_session`,
  `delete_review`, `delete_pending_commit`, `delete_image`
- reads: `list_projects`, `list_project_repos`, `get_all_repo_badges`,
  `list_branches_for_project`, `poll_session`, `get_session`,
  `get_session_messages`, `get_session_messages_since`,
  `get_active_sessions`, `list_project_notes`, `get_project_search_index`

Each is now an `async fn` that only looks up its handles (the store, and
for four of them the session registry) and hands its whole body to
`tauri::async_runtime::spawn_blocking`, the pattern b3fd81e set. Three
bodies were extracted for it: `delete_project_note_impl` and
`delete_image_impl`, which the web arms now share instead of carrying
copies, and `list_branches_for_project_impl`.

The wrong pool

- The source guard `offloaded_commands_run_only_the_handoff_on_the_runtime`
  now pins all 20 offloaded commands as `async fn` with nothing outside
  `spawn_blocking` but `get_store`, `Arc::clone` and the join-error
  mapping.
- `dispatch` runs on the same runtime, and 13 of these commands' arms
  still made their store calls inline. They now go through
  `blocking_json` or `blocking_store_json`, with their behaviour
  unchanged (the `list_branches_for_project` arm still has no project
  check and no commit counts; the control-scope project filters still
  apply). The other four arms already did.
- A second source test,
  `offloaded_commands_dispatch_arms_run_only_the_handoff_on_the_runtime`,
  holds the arms to the same rule. `create_project` is its one listed
  exception: b3fd81e left that arm's inline writes alone, and so does
  this commit.

Ordering: deletes

All eight frontend call sites were read. Six await the delete before
anything that depends on it (BranchCard note, review, pending commit and
image; ProjectSection project note; SessionLauncher session). The two
draft-attachment removals (SessionChatPane, ImageAttachment) do not await
it, but they drop the image from local state first and nothing reads
after them. No read is issued behind an unawaited delete.

Off the main thread, two interleavings are new:

- Refetches triggered by a delete's first change event are now served
  while the delete is still running. `delete_note` removes the note,
  then scans for its media, then deletes or cancels the session. A
  refetch in between sees the note gone and the session still there,
  which the timeline does not show on its own. The session delete was
  not moved ahead of the scan, because the scan keeps media that the
  session's messages still reference.
- The media cleanup checks references and then deletes, in separate
  store calls. Saves that run as synchronous commands (`create_note`,
  `update_note`) could not land inside that gap before; now they can, so
  a save that pastes an image into another note during the scan of a
  note being deleted can lose that image. Session threads and note
  transfers could already do this. Closing it needs a store change and
  is not part of this commit.

Ordering: reads

Synchronous commands answered in the order they were sent. On the pool
two reads of the same thing can land in either order, so a call site
with no latest-wins guard can apply an older response over a newer one.
Every call site of the 11 reads was checked. The note's claim that
projectsData.svelte.ts:1222-1231 guards the refetch path is only partly
right: the generation there only discards responses from before a hard
reload, and the in-flight queue beside it only serializes the branch
refetch against itself.

Guards added, through a small `LatestWins` helper that numbers requests
when they are sent and refuses a response once a later-sent one has
landed:

- projectsData: the refresh, hydrate and branch-refetch paths shared no
  ordering for one project's branches, repos and the project list. A
  transfer's `replace` branch list is still always applied, since it
  alone drops the removed worktree paths.
- ProjectSection: the project notes list had no guard. A list overtaken
  by a per-session row refetch is read again instead of applied.
- BranchCard: the two project-note fetches behind its hashtag items.

Already guarded, unchanged: repoBadges, ActionsSettingsPanel, the search
index (single flight), the hashtag builders, `hydrateActiveSessions`,
the PR, push and pull pollers, and paging older messages.

Left as they are, for a follow-up:

- SessionChatPane serializes its polls with `pollInFlight`, but
  `stopPolling` clears that flag, so a close and reopen inside one
  request's latency leaves two polls in flight, and a full load of a
  session that is already loaded can overlap a poll. An older response
  landing last can shrink the last row or put back an older status. The
  next tick repairs it while the session runs; if polling has just
  stopped it stays until the next open.
- liveSessionHints has the same shape (`stopPolling` clears
  `hintPollInFlight`); it affects hint and title text only.
- An older `get_active_sessions` snapshot can re-apply a stale
  background-hold flag until the next hydration.

The web transport has always had these three, since it was never
ordered.

The note's references were rechecked at b3fd81e. The code matches its
description and only line numbers moved: `delete_note` is at
note_commands.rs:142, `delete_project_note` :344, `list_project_notes`
:286, `cleanup_unreferenced` note_media/mod.rs:357, `delete_session`
session_commands.rs:3421, `get_active_sessions` :1206, `get_session`
:1230, `get_session_messages` :1243, `get_session_messages_since` :1255,
`poll_session` :1291, `delete_review` timeline.rs:1442,
`delete_pending_commit` :1494, `delete_image` image_commands.rs:113,
`list_projects` lib.rs:488, `list_project_repos` :724,
`get_all_repo_badges` :899, `list_branches_for_project` branches.rs:1465,
the session cascade store/sessions.rs:1137, `blocking_store_json`
web_server.rs:811 and `blocking_json` :824. No test called any of the 17
command fns directly, so none needed a runtime.

Verified, with the pinned 1.99.1 toolchain:

- `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass
  on the final tree.
- `cargo test --workspace --lib --bins --tests` at a machine load near
  40: app lib 2097 passed, 1 failed; `stagedctl` and the other targets
  passed. The failure was the new arm test, which flagged the
  `is_sq_available` arm's `to_string` in its join-error mapping; that
  call is now allowed. After that fix and the new Rust tests, the
  `web_server`, `image_commands`, `note_commands` and `branches` test
  modules were rerun and pass. The full suite was not run a second time;
  no non-test Rust changed after it.
- New Rust tests pass: two for `delete_image_impl`, one for
  `delete_project_note_impl`, two for `list_branches_for_project_impl`.
- The command guard fails as intended when `delete_note` is turned back
  into a plain `fn` and when `list_projects` reads the store inline. The
  arm guard fails when the `delete_image` arm reads the store inline.
- Frontend: `pnpm check` (0 errors), prettier, and the full vitest suite
  (237 files, 3529 tests) pass. Three of the four new projectsData tests
  fail with the guard disabled; the fourth pins the `replace` exception.

Not run or not covered:

- The app itself. The freezes were not re-measured, and the note's perf
  verification steps could not be followed: the instrumentation they
  read is on 39e9ac12, which is not an ancestor of this branch.
- The ProjectSection and BranchCard guards have no test of their own;
  only the helper and the projectsData paths do.
- The delete bodies that need an `AppHandle` (`delete_note_impl`,
  `delete_review_impl`, `delete_pending_commit_impl`,
  `delete_session_impl`) were not changed and got no new tests.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…n thread

Step 6 of the recommended order in the synchronous-commands note, done as
its Fix A. These five Tauri commands were plain `fn`s, so each ran on the
main thread and froze the window for its own write and for any wait on
the store lock:

- `create_note`, `update_note`, `set_note_read`
- `mark_session_chat_read`, `queue_session_message`

Each is now an `async fn` that only looks up the store handle and hands
its whole body to `tauri::async_runtime::spawn_blocking`, the pattern
b3fd81e and b399803 set. No body changed and none was extracted: the
note commands already shared `create_standalone_note` and
`update_note_impl` with the web arms, and the other three are one store
call each.

The wrong pool

- `OFFLOADED_COMMANDS` gains the five, so
  `offloaded_commands_run_only_the_handoff_on_the_runtime` now pins 25
  commands as `async fn` with nothing outside `spawn_blocking` but
  `get_store`, `Arc::clone` and the join-error mapping.
- All five `dispatch` arms made their store call inline on a runtime
  worker. They now go through `blocking_json` or `blocking_store_json`
  with their behaviour unchanged, and
  `offloaded_commands_dispatch_arms_run_only_the_handoff_on_the_runtime`
  holds them to it. `ARMS_NOT_OFFLOADED` is unchanged.

Ordering: the audit

Synchronous commands ran one at a time in the order they arrived. On the
pool two writes in flight land in either order. Every frontend call site
of the five was read (seven sites, all through the `commands.ts`
wrappers; nothing invokes these commands directly):

- `update_note`: BranchCard.svelte:1159, the written-note editor's save.
  The Save button holds `saving`, but the editor's save before a transfer
  (`saveBeforeTransfer` in WriteNoteModal) does not, so Cmd+Enter during
  it sends a second save of the same note. Exposed: the older save could
  land last and stay.
- `create_note`: BranchCard.svelte:1161 (editor, behind `saving`),
  ActionOutputModal.svelte:152 (awaited), and BranchCard.svelte:1884, a
  file drop, which creates one note per file under `Promise.all`.
  Exposed: the drop's notes could be created in any order, which is the
  order the timeline lists them in and the order same-titled ones get
  their ` (2)`, ` (3)` suffixes in. The store call is one lock hold, so
  two creates still cannot pick the same suffix.
- `set_note_read`: noteReadActions.ts:47, reached from the viewer's
  mark-on-open and mark-on-revision (noteReadTracker, not awaited), its
  toggle (awaited, single flight), the editor's mark-on-open
  (BranchCard.svelte:1141, not awaited) and the timeline context menu
  (BranchCard.svelte:2287, not awaited). Exposed: "Mark as unread" and
  the read mark of opening the note straight after, and the viewer's
  toggle to unread against its read mark for a revision. The reversed
  order leaves the note in the wrong state, and the project unread flag
  kept beside it follows the responses.
- `mark_session_chat_read`: NoteModal.svelte:567, sent when a chat opens
  and again when it closes, awaiting neither.
- `queue_session_message`: SessionChatPane.svelte:1866. The queue path
  does not hold `sending`, so a second message can be sent while the
  first is still being queued. Exposed: the queue drains by a stamp
  taken when the command body starts, so the second message could be
  queued, and later sent to the agent, ahead of the first.

The three pairs the note lists:

- Successive `update_note` saves: exposed, as above.
- `mark_session_chat_read` against `set_note_read`: both are sent,
  unawaited, when the viewer opens, but they commute. One writes
  `sessions.chat_read_at` and the other `notes.read_at`, and neither
  reads the column the other writes.
- `queue_session_message` followed by `list_queued_session_messages`:
  the one call site awaits the write before it lists.

Ordering: the fix

The exposed pairs are serialised on the frontend. The backend cannot
restore the order: an async command first runs on a runtime worker,
after the order of arrival is gone, and HTTP requests never had one.

- New `SendOrder` helper (`src/lib/shared/sendOrder.ts`): `run(key,
  send)` holds a request back until the one sent before it for the same
  key has settled, however it settled. With nothing in flight for the
  key it sends in the caller's own tick, so an uncontended call behaves
  as before.
- The five wrappers in `commands.ts` send through one instance. Keys: a
  note's saves and read marks share `note:<id>`; creates use
  `branch-notes:<branchId>`; chat-read marks `chat-read:<sessionId>`;
  queued messages `queue:<sessionId>`. Different keys never wait on each
  other.
- `deleteNote` joins its note's key. `delete_note` left the main thread
  in b399803, when the writes before it were still synchronous and so
  finished first. With them on the pool, an unawaited "Mark as unread"
  could land after a delete sent behind it, fail on the missing note and
  toast an error for a note the user had just deleted.

Because the order is kept in the wrappers, the web transport gets it too.

Residual ordering risk

- The order is per window. Two windows, or a window and a web client,
  were never ordered against each other and still are not.
- Only the six wrappers above are ordered, and only within a key. A
  different command sent while one of these writes is in flight is not
  held back for it, where the main thread used to finish the write
  first. The cases found:
  - A message queued and then Stop (`cancel_session`, still
    synchronous). If the stop lands first the queue write is refused,
    since the row is no longer running; the composer reports "Failed to
    queue" and puts the draft back. Stop was not put behind the queue
    key, so that a slow queue write cannot delay it.
  - The viewer's read mark and its own row refetch (`get_note`, still
    synchronous). A refetch that read before the mark and answers after
    it shows the toggle as unread until the next refetch, which the
    mark's own change event triggers.
  - A read mark still in flight when its note is moved to another
    project: the moved note can arrive unread.
- A write that never settles holds back the later writes for its key.
  As a synchronous command it held back every command.
- Not new, and not changed: the chat pane can apply a queued-message
  list from an older poll over a newer one, as b399803 recorded for its
  polls. The next 500 ms poll repairs it.

The note's references were rechecked at b399803. The code matches its
description and only line numbers moved: `create_note` was at
note_commands.rs:74, `update_note` :110, `set_note_read` :236,
`mark_session_chat_read` session_commands.rs:1416 and
`queue_session_message` :2851; the frontend sites the note gives as
BranchCard.svelte:1126 and NoteModal.svelte:498 are at :1159 and :567.
No test called any of the five command fns directly, so none needed a
runtime.

Verified, with the pinned 1.99.1 toolchain:

- `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass.
- `cargo test --workspace --lib --bins --tests` at a machine load near
  40: app lib 2103 passed, 0 failed; `stagedctl` (106) and the other
  targets passed. That was the second run. The first was killed when the
  disk filled (os error 28) after 926 tests had passed; its failures
  were all in the disk-heavy `project_transfer` tests, which pass in the
  second run. The first clippy run also stopped on an environmental
  failure, in the vendored OpenSSL build script, before it reached this
  crate, and passed on a retry.
- The command guard fails as intended when `set_note_read` is turned
  back into a plain `fn`, when `queue_session_message` reads the store
  inline, and when `update_note` drops its `spawn_blocking`. The arm
  guard fails when the `set_note_read` arm calls the store inline. Both
  pass again with the code restored.
- Frontend: `pnpm check` (0 errors), prettier, and the full vitest suite
  (238 files, 3544 tests) pass. New tests: six for `SendOrder` and nine
  for the wrappers, one per ordered pair plus failure and independence.
  Eight of them fail with the queue disabled.

Not run or not covered:

- The app itself. The freezes were not re-measured, and the note's perf
  verification steps could not be followed: the instrumentation they
  read is on 39e9ac12, which is not an ancestor of this branch.
- No test drives the real call sites into the races. The wrapper tests
  pin the send order with a hand-settled transport; the components
  (WriteNoteModal, the chat composer, the file drop) are not exercised.
- No new Rust unit tests. The five bodies are unchanged; only the
  source guards cover the handoff.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Step 7 of the recommended order in the synchronous-commands note, which
completes its Fix A. The request was to leave the in-process commands
synchronous behind a source-scan test, and, so that test passes
honestly, to first convert every other still-synchronous command, with
the frontend ordering audit applied to each write. Fix B (step 5) is not
touched.

The list was re-derived from HEAD. Of 239 registered commands, 94 were
plain `fn`s, which run on the main thread and freeze the window for
their own work and for any wait on the store lock. 25 only touch state
in memory (the note counted 24). 69 reach the store, a child process or
the filesystem: 68 are converted here and one, `cancel_session`, is
allowlisted.

Converted

Each is now an `async fn` that only looks up its handles and hands its
whole body to `tauri::async_runtime::spawn_blocking`, the pattern of
b3fd81e, b399803 and 013d1e4.

- tags (13): `get_tag_state` and the twelve tag and tag-group writes
- repos and badges (11): `list_recent_repos`, `get_suggested_repos`,
  `clear_project_repo_reason`, `set_primary_project_repo`,
  `update_project_repo_branch_name`, `update_repo_badge`,
  `delete_repo_badge`, `unpin_repo`, `reorder_pinned_repos`,
  `set_repo_default_branch`, `detect_default_branch`
- actions (8): `list_project_actions`, `update_project_action`,
  `delete_project_action`, `list_action_contexts`, `list_repo_actions`,
  `create_repo_action`, `delete_all_repo_actions`,
  `delete_action_context`
- branches and PRs (5): `get_branch`, `create_branch`,
  `update_branch_pr`, `clear_branch_pr_status`, `cancel_deferred_push`
- notes (8): `get_note`, `get_branch_note_by_session`,
  `list_child_notes`, `get_note_repo`, `create_project_note`,
  `get_project_note`, `get_project_note_by_session`,
  `set_project_note_read`
- sessions (11): `list_child_sessions`,
  `get_session_acp_metadata_messages` and its `_since` form,
  `get_tool_image`, `get_tool_call_body`,
  `get_session_acp_initialization`, `count_assistant_messages_after`,
  `list_queued_session_messages`, `delete_queued_session_message`,
  `get_session_live_delivery`, `queue_branch_session`
- images and files (6): `get_image_path`, `list_branch_images`,
  `get_image_data`, `read_image_file`, `read_text_file`,
  `resolve_path_aliases`
- app (6): `confirm_reset_store`, `resume_interrupted_sessions`,
  `quit_app`, `list_control_grants`, `revoke_control_grant`, `open_url`

Most bodies moved unchanged. The ones that did not:

- `read_image_file` wraps a new `read_image_file_impl`, which its web
  arm and tests call. `resolve_path_aliases` wraps the `_impl` it
  already had and now returns a `Result` for the join error.
- `confirm_reset_store`, `resume_interrupted_sessions`,
  `list_control_grants` and `revoke_control_grant` look up their managed
  state from the `AppHandle` inside the closure, since a `State` borrow
  cannot move into it. `resume_interrupted_sessions` still accepts a
  dismissal without a store.
- `quit_app` runs `request_quit` on the pool. Its prompt flags are
  atomics, the dialog plugin hops to the main thread itself, and the
  cleanup runs at most once. The menu and Cmd+Q paths still call it on
  the main thread.
- `Store::get_or_create_action_context` now looks up and inserts under
  one lock hold. With the action lists on the pool, the branch cards of
  a repo with no context row could each miss it, and every insert after
  the first failed on the unique index and left that card's action bar
  empty. New store test: eight threads, lined up behind a barrier before
  each of 400 fresh contexts, all get the same row.

Left synchronous

- The 25 in-process commands, listed by name with what each touches in
  the new `SYNCHRONOUS_COMMANDS`: the running-action registry and
  executor, the PR poll scheduler hints, the transfer and preflight
  registries, the pending control authorizations and their answer, the
  launch-resume offer, the store-status flag, the session hold and
  reconcile states, two computed paths, an environment variable, the
  window seed and the updater claim.
- `cancel_session`, the one `SYNCHRONOUS_BLOCKING_ALLOWED` entry. For a
  registered turn Stop only fires the cancellation token. With no runner
  it reads the row and, if it is still live, writes `cancelled`, skips
  the pipeline dependents and kicks the queue: several store lock waits
  on the main thread. It stays there for its ordering. The registry and
  that write are keyed by session id, not by turn, and the main thread
  finishes a Stop before any command sent after it is dispatched. When a
  turn ends by itself as Stop is pressed and the next message is sent
  straight after, a Stop on the pool that lost the store lock to that
  resume would cancel the new turn, or write `cancelled` over a row the
  resume had claimed but not yet registered. Converting it needs a turn
  token in the registry and the write, or Stop and every turn-starting
  command for a session sent in order by the frontend.

The source guards

- New test `synchronous_commands_touch_only_in_process_state`. Every
  plain `fn` command must be in one of the two lists. A listed
  in-process command may not reach the store, a child process, the
  filesystem, the preferences file or the opener, in its signature, its
  body or the crate fns it calls inline (the existing walk, run for the
  main thread). An allowlisted one must still reach something, and a
  stale entry fails. The walk does not follow method calls, which is why
  the commands are listed by name: a new synchronous command fails until
  someone has read what it calls.
- `OFFLOADED_COMMANDS` gains the 68, so
  `offloaded_commands_run_only_the_handoff_on_the_runtime` now pins 93.
- 55 `dispatch` arms made their store or filesystem calls inline on a
  runtime worker. They now go through `blocking_json` with their
  behaviour unchanged. Seven arms were already offloaded. Six refuse in
  web mode and do no work (`quit_app`, `confirm_reset_store`,
  `resume_interrupted_sessions`, `list_control_grants`,
  `revoke_control_grant`, `open_url`): they are in the new
  `ARMS_WITH_NOTHING_TO_OFFLOAD`, which fails if one starts doing work.
  `ARMS_NOT_OFFLOADED` is unchanged, and the `cancel_session` arm is not
  touched.

Ordering: the audit

Synchronous commands ran one at a time in the order they arrived; on the
pool two in flight land, and answer, in either order. Every frontend
call site of the 68 was read. All go through the `commands.ts` wrappers.
Exposed writes, now sent through `writeOrder` (013d1e4's `SendOrder`):

- `update_repo_badge` after itself and against `delete_repo_badge`. The
  settings panel saves as the name and hue are edited, without waiting
  for the save before, and each save carries both. Key:
  `repo-badge:<repo>\0<subpath>`.
- `reorder_pinned_repos` after itself: each call carries the whole
  order. Key: `pinned-repos`.
- `set_project_note_read` after itself ("Mark as unread", unawaited,
  then the viewer's read mark) and against `delete_project_note`, which
  left the main thread in b399803. Key: `project-note:<id>`, the
  counterpart of `note:<id>`.
- The twelve tag writes. `tagActions` chains catalog edits and each
  project's assignments separately, and only the main thread ordered an
  assignment in flight against a move or delete of its tag. One key,
  `tags`, keeps the order they are sent in.

Exposed reads, where a response sent earlier could land last and stay.
Each now keeps the response of the request sent last (b399803's
`LatestWins`):

- `list_repo_actions` and `list_action_contexts` in the repo settings
  panel. Picking repo A and then B could list A's actions under B,
  where Edit and Delete act on A's action ids.
- `list_project_actions` in `ActionRunner`, which every action bar
  uses. An adopted detection result also outranks reads in flight.
- `list_queued_session_messages` in the chat pane. The load, the polls
  and the refreshes now share one order, and a refresh is dropped when
  the pane has moved to another session. That covers the stale queued
  list b399803 and 013d1e4 recorded as not new.
- `list_control_grants` in the connected-apps panel, one load per
  change event.
- `get_project_note_by_session`: the row refetch now yields to a list
  load sent after it.

Not exposed: the action writes (awaited behind the edit dialog's
`saving` flag, or idempotent deletes), `create_project_note`,
`cancel_deferred_push`, `delete_queued_session_message`,
`unpin_repo`, `revoke_control_grant`, `confirm_reset_store`,
`resume_interrupted_sessions`, `quit_app`, `open_url`, and the reads
whose call sites already guard their responses. No frontend caller:
`create_branch`, `queue_branch_session`, `detect_default_branch`,
`update_branch_pr`, `clear_branch_pr_status`,
`update_project_repo_branch_name`, `set_primary_project_repo`,
`set_repo_default_branch`, `clear_project_repo_reason`,
`get_image_path`, `list_branch_images`,
`get_session_acp_metadata_messages_since`,
`get_session_acp_initialization`.

Residual risk

- The order is per window and per key. Two windows, a web client and
  stagedctl were never ordered against each other and still are not.
- `cancel_session` can still freeze the window on its no-runner path,
  for as long as the store lock is held by someone else.
- `get_store_status` stays synchronous and takes the mutex a store
  reset holds, so a window opened during a reset waits for it. The
  reset itself froze every window before.
- `get_branch_note_by_session` when opening a session's note: two
  opens in quick succession can show the one clicked first. Not
  guarded.
- The chat pane's load still reads ACP metadata beside the polls, the
  other half of the gap b399803 recorded. The next poll repairs it.
- A list of a repo's actions in flight while its context is deleted
  re-creates an empty context, since the lists get-or-create it.
- Two saves of different badges with the same short name can both pass
  the duplicate check. One user edits one badge at a time.
- A tag write that never settles holds back later tag writes. As a
  synchronous command it held back every command.
- The new scan reads text. It does not follow method calls and knows
  the blocking calls by the markers listed in the test.

Verified, with the pinned 1.99.1 toolchain:

- `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass.
- `cargo test --workspace --no-fail-fast --lib --bins --tests` at a
  machine load near 20: app lib 2105 passed, 0 failed; `stagedctl` (106)
  and `help_pins` (1) passed. That was the third run. The first was
  killed when the disk filled (os error 28). The second passed 2104 and
  failed `personal_overrides_cannot_mask_missing_native_binary`, which
  re-runs its own test binary: the shared cargo target directory was
  emptied in the run's last seconds and the spawn got "No such file or
  directory". It passes in the third run, on a rebuilt target.
- The store test was strengthened after that run. `store::tests` (186),
  the four guard tests, fmt and clippy were rerun with it and pass. With
  the lookup and the insert back under separate lock holds it fails 20
  runs of 20 on the unique index; with the fix it passes 30 of 30. Its
  first form, a single round, passed with the fix reverted.
- `synchronous_commands_touch_only_in_process_state` fails as intended
  when `get_note` is turned back into a plain `fn`; when a listed
  in-process command (`get_blox_env`) takes the store, spawns a process
  or calls a crate fn that reaches the filesystem; when it is renamed or
  made `async`; and for a new unlisted plain `fn` command, whether it
  reads the store or reaches nothing.
- The command guard fails when `get_note` drops its `spawn_blocking`.
  The arm guard fails when the `get_note` arm reads the store inline and
  when the `open_url` arm starts doing work. All four guard tests pass
  again with the code restored.
- Frontend: `pnpm check` (0 errors), prettier, and the full vitest suite
  (238 files, 3553 tests) pass. Nine new tests: six for the ordered
  wrappers, two for `ActionRunner` and one for the connected-apps
  panel. With `SendOrder` made to send at once the six fail, beside the
  seven older ordering tests; with `LatestWins` admitting every response
  the other three fail.

Not run:

- The app itself. The freezes were not re-measured, and the note's
  "done when" check (no `blocking-on-main-thread` or `main-thread-stall`
  line across launch, project create, note move and note delete) is
  still to do. The web transport and stagedctl were not exercised live.
- No component test drives the repo settings panel's or the chat
  pane's new response guards; `LatestWins` itself and the runner and
  connected-apps guards are tested.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Applies the findings of review 39e6a9f3 of d490643, the last of the Fix A
commits that moved the blocking Tauri commands off the main thread. Each
finding was checked against the code first. All six are real. Five are
fixed here, each with a test that fails without the fix. The sixth, the
guard gap, is left in the code as asked, and only the docs that overstated
the guards are corrected. Fix B is not touched.

Findings, as verified

- Multi-file drop order (BranchCard.svelte). Real, a regression from
  d490643. `read_text_file` became async, each note was created as soon
  as its own file had been read, and `writeOrder` keeps only the order
  `createNote` is called in. A small file dropped after a large one was
  created first.
- Quit gate (app_lifecycle.rs). Real, a regression from d490643.
  `request_quit` checks the pending-prompt flag, snapshots the blockers
  and then sets the flag. With `quit_app` on the pool, two requests could
  both pass the check, and both raised an alert. The doc comment called
  that safe because the flags are atomics.
- Reads over local writes. `LatestWins` only orders reads against reads,
  so a read sent before a write landed is admitted after the write's
  local update.
  - ActionsSettingsPanel.svelte: real. A deleted action came back and
    stayed, since the panel does not reload after a delete.
  - ProjectSection.svelte and `setBranchesByProject` in
    projectsData.svelte.ts: real, as a flicker. The deleted project note,
    or the old branch name or status, came back until the write's change
    event refetched.
  - noteReadActions.ts: real, but not new. "Mark as unread" flags the
    project only after it has found the note's project, which for a
    branch no view has loaded is a `get_branch` call. A read mark landing
    during that call found no flag to release, and the flag set
    afterwards stayed. The synchronous commands answered in the same
    order, so this predates the branch. Fixed anyway, as asked.
- ConnectedAppsPanel.svelte. Real. The failure path did not check its
  ticket and `finally` cleared `loading` for any load.
- SendOrder on the web transport. Real. `fetch` has no timeout, so one
  hung request held back every later write for its key until a reload.
- Guards (blocking_guard_tests.rs). Real, and not a regression. Commands
  that were already `async` still call the store inline on a runtime
  worker: `ensure_review`, `find_review` and `add_comment` were read to
  confirm, with the `ensure_repo_badges`, `list_repos_for_home` and
  `list_all_repo_actions` arms. No guard test looks at them.

Fixes

- Drop order. `addTextFileNotes` reads every file first and then calls
  `createNote` in drop order. A file that cannot be read is now reported
  once all the reads are back, not as soon as its own read fails.
- Quit gate. The whole check-then-act moved into `QuitState::decide`,
  which holds a new `gate` mutex and returns what to do: force, quit,
  ask, or nothing. `request_quit` acts on the result after the lock is
  released, so the lock is never held across the alert's hop to the main
  thread. Two racing requests are decided one after the other, as on the
  main thread: the first asks and an explicit second one forces. The
  blocker snapshot reads the store under the gate, so a Cmd+Q on the
  main thread can wait for a pooled request's read. Cmd+Q already makes
  that read on the main thread itself. The doc comments on `quit_app`
  and `request_quit` now say this.
- Reads over local writes. A local write is recorded as a landing under
  a key of its own, and a read that was sent before it reads again. It
  is not dropped: the stale read may be the only one carrying some other
  change, and in the settings panel it may be the read for a repo picked
  since. `LatestWins` documents the pattern.
  - New `RepoActionList` (settings/repoActionList.svelte.ts) holds the
    panel's action list, its read order and its local writes. The panel
    writes through it at detect, create, edit, delete, delete all and
    delete repo.
  - ProjectSection counts its optimistic removal as a row landing, which
    the list load already answers by reading again.
  - `setBranchesByProject` marks each project whose branch list really
    changed. `applyProjectBranches` then refetches that project and
    returns the list in place, so `refreshProject` still invalidates
    those timelines. A call that changes no branch marks nothing, and a
    transfer's `replace` list is still always applied.
  - noteReadActions keeps a claim per note for an unread flag that is
    waiting on its project lookup. A later read mark, unread mark or
    delete of the note takes the claim, and a flag without its claim is
    not set.
- ConnectedAppsPanel. The failure path checks the ticket like the
  success path. `loading` clears only when the newest load settles,
  through a new `LatestWins.isNewest`.
- SendOrder. New optional `maxWaitMs`: a request waits at most that
  long, from its own call, for the ones ahead of it, and is then sent
  beside them. Nothing is cancelled or failed, so a command that is only
  slow still completes. `writeOrder` sets it to 30 s over HTTP only
  (`WEB_WRITE_ORDER_MAX_WAIT_MS`). Under Tauri it is unset, the wait has
  no bound and the code path is the old one, so no reordering comes
  back there. Over HTTP a write that outlasts the bound can be
  overtaken, as every pair could before 013d1e4.
- Docs only. The guard test module, `OFFLOADED_COMMANDS` and its test
  now state that an async command outside the list is not checked for
  inline store calls, and name the known ones. The `createNote` wrapper
  doc says it keeps the call order and no more.

Not changed

- The already-async commands that call the store inline, and Fix B.
- ConnectedAppsPanel's revoke still filters its list with no ticket. An
  older load can put the row back until the grants-changed event
  reloads. That is the same shape as the read-over-write finding and
  heals itself. The review did not list it.
- `flushPrStatusEvents` and the other event-driven patches to the branch
  map write without a mark, as before.
- A failed drop read toasts later than it did (see above).

Verified, with the pinned 1.99.1 toolchain

- `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass.
- `cargo test --workspace --no-fail-fast --lib --bins --tests`, one run,
  at a machine load between 20 and 40 and with 208 GB free before it:
  app lib 2107 passed, 0 failed, 2 ignored; `stagedctl` (106) and
  `help_pins` (1) passed.
- New Rust tests: `racing_explicit_quits_raise_one_prompt` and
  `the_gate_decides_one_request_after_another`. With the gate lock
  removed the first fails with two `Ask` decisions. It passes again with
  the lock back. The four guard tests pass.
- Frontend: `pnpm check` (0 errors), prettier, and the full vitest suite
  (239 files, 3577 tests) pass. 24 tests are new.
- Each new frontend test was run against the unfixed source and fails
  there: the drop-order test, both ConnectedAppsPanel tests, the
  ProjectSection test, the projectsData re-read test, two of the three
  noteReadActions tests, and three SendOrder tests plus the HTTP wrapper
  test. With the re-read removed from `RepoActionList`, two of its tests
  fail. With the bound also applied under Tauri, the Tauri wrapper test
  fails. All pass again with the fixes restored.

Not run or not covered

- The app itself. None of the races was reproduced live, and the web
  transport was not exercised against a real hung connection.
- ActionsSettingsPanel has no component test. `RepoActionList` is
  tested by itself; the panel's use of it is covered by the type check
  only.
- The quit test drives `QuitState::decide` with a stand-in blocker
  snapshot. `request_quit` needs an `AppHandle` and was not run.
- No test drives `setBranchesByProject` from its real callers
  (workspaceLifecycle, ProjectHome); the store test calls it directly.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…efreshes

Applies the findings of review 0247953f of 28aa072, the commit that closed
the ordering gaps in Fix A. Each finding was checked against the code first.
All five are real. The two warnings are fixed, and the three cheap
suggestions are taken. The items the review marks as information or as
predating this work are left alone, and Fix B is not touched. No Rust
changed.

Findings, as verified

- SendOrder wait bound (sendOrder.ts). Real. Each waiter's clock started at
  its own call, so every write queued behind one hung request ran out in
  the same instant and all were sent together. With the old source, a hung
  request followed by three writes queued in one tick sends all three at
  the bound. Web transport only: under Tauri there is no bound.
- Set-aside refresh (projectsData.svelte.ts). Real. `applyProjectBranches`
  returned the list in place when a local write had landed under the read,
  so `refreshProject` resolved `true` with the old list still shown. The
  mark is per project, so a workspace status change on another branch was
  enough. The branch delete in ProjectHome awaits `refreshProject` and then
  clears `deletingBranches`, so the deleted card came back for one round
  trip.
- RepoActionList `loading`. Real. `finally` cleared it for any load, so an
  older read settling hid the spinner while the newest was still out.
- BranchCard drop. Real as a coverage gap: no test had a failed read among
  successful ones.
- ConnectedAppsPanel revoke. Real. The revoke filtered its row with no
  mark, so a load sent before it could put the row back until the
  grants-changed load answered.

Fixes

- SendOrder. A waiter's clock now runs only while the request ahead of it
  is out: it starts at the waiter's own call, or when the request ahead is
  sent if that is later. Behind one hung request the first waiter gives up
  and is sent; the second then waits for that send with a full bound of
  its own, and so on. Writes that are not hung keep their order among
  themselves. A chain still drains, one bound for each request in it that
  hangs. A waiter behind a request that has not been sent holds no timer,
  and a timer is cleared when the request ahead settles, so none is left
  behind. With no bound the path is the old one: the same promise chain,
  no timer, no extra tick.
- refreshProject. The branch apply is split. `offerProjectBranches`
  reports a list as applied, refused or set aside, and no longer starts a
  read itself. `applyProjectBranches` wraps it for the reads nothing
  awaits (hydration, the event refetch) and still reads again in the
  background. `refreshProject` reads the list again itself when its list
  is set aside, and resolves only once a list read after the local write
  has been offered. It applies repos and marks the project hydrated before
  that wait. If the re-read fails it resolves `false`.
- LatestWins. New `wrote(key)` records a local write as a landing. It
  takes a place in the order but is not a request, so `isNewest` still
  answers for the last request sent. The four call sites that wrote
  `admit(key, issue())` for a local write now call it (RepoActionList,
  ProjectSection, ActionRunner, projectsData), with no change in what they
  admit.
- RepoActionList. `loading` clears only for the newest load. A load with
  no repo selected clears it itself, since the read it overtakes no longer
  does.
- ConnectedAppsPanel. A revoke is recorded under a `revoked` key, and a
  load sent before it reads again, whether it answered or failed. The
  re-read is the newest load, so Refresh stays disabled until it is back.
- BranchCard. New test: four files dropped, reads answering out of order
  with one failure. Nothing is created or reported until every read is
  back, the other three are created in drop order, there is one toast,
  and only the failed file's "adding..." row is removed. The component is
  not changed.
- ButtonStub (test stub) now reports `disabled` as `data-disabled`. It
  still does not apply it, so tests can click through as before.

Behaviour to know about

- Over HTTP, a write queued behind several requests that all hang now
  waits one bound for each of them, where it used to wait one bound from
  its own call. A write sent after the connection recovers waits behind
  what is left of such a chain. A write that is only slow past the bound
  can still be overtaken by the next one, as before.
- `refreshProject` can now take one extra round trip, or more if local
  writes keep landing under its reads. Each round needs a new write that
  really changed a branch, so it ends when one read comes back with none.
- A refresh whose re-read is refused, because a read sent later landed
  first, invalidates no timelines. That is what any refused refresh did.
  Before, a set-aside refresh invalidated the list in place.

Left alone, as the review marks them

- One slow or hung file read holds back every note of a drop, and the
  shared catch says "Could not read" when it was `createNote` that
  failed. The second predates this work.
- ActionsSettingsPanel still has no component test.

Verified

- Frontend: `pnpm check` (0 errors), prettier on the whole tree, and the
  full vitest suite (239 files, 3593 tests) pass. 17 tests are new and
  one SendOrder test that pinned the old bound is replaced. The
  projectsData test that pinned the old `refreshProject` result is
  rewritten.
- Each fix was removed and its tests rerun:
  - Old `sendOrder.ts`: three of the four new SendOrder tests and the new
    HTTP wrapper test fail. The fourth pins the wait from a request's own
    call when the one ahead is already out, which is unchanged.
  - Old `projectsData.svelte.ts`: the rewritten test and three new ones
    fail. The test for the background re-read of an event refetch passes
    on both, as intended.
  - RepoActionList with `loading` cleared for any load: two tests fail.
    Without the clear for an empty selection: one fails.
  - Old `ConnectedAppsPanel.svelte`: two of the three new tests fail. The
    third pins that a load sent after the revoke is applied as is.
  - `wrote` made to count as a request: the new LatestWins test fails.
    The panel and RepoActionList tests pass either way, because every
    load a write overtakes reads again and that re-read is the newest.
    `wrote` makes `isNewest` right without relying on that.
  - The new drop test passes on 28aa072's BranchCard and fails on
    d490643's, where notes were created as each read answered.
- Rust: `cargo fmt --check` and `cargo clippy --workspace -- -D warnings`
  pass with the pinned 1.99.1 toolchain. The Rust test suite was not run,
  since no Rust file changed.

Not run or not covered

- The app itself. None of the races was reproduced live, and the web
  transport was not exercised against a real hung connection.
- No test drives the branch delete in ProjectHome. The store tests call
  `refreshProject` and `setBranchesByProject` directly.
- The ProjectSection and ActionRunner moves to `wrote` are covered by
  their existing tests and the type check only.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Applies the one warning of review 6eff2107 of e6069e4, the commit that
made `refreshProject` wait for a list read after a local write. The
finding was checked against the code first and is real. Nothing else in
the review is touched, and no Rust changed.

The finding, as verified

`offerProjectBranches` ran `admit` before it looked at `branch-writes`,
so a list that was set aside was still recorded as a landing for
`branches:<project>`. An older read then found itself overtaken by a list
the store never showed, and was refused. `refreshProject` takes a refusal
to mean a later-sent list is in place, so it resolved `true` over the
pre-mutation list, which is the symptom e6069e4 set out to remove.

The review's sequence, reproduced in a test on e6069e4's source:

- `refreshProject` sends its read after a branch delete.
- The delete's change event sends a refetch, with a later ticket.
- A local write lands: a workspace status change on another branch.
- The event refetch answers first. It is admitted, set aside, and sends
  a read of its own in the background.
- The refresh's read answers. It is refused, not set aside, the loop is
  skipped and the refresh resolves `true` with the deleted branch still
  listed. The branch delete in ProjectHome clears its "Deleting…" state
  on that result, so the card comes back until the background read
  answers.

The fix

The checks run in the order the review suggests. For a list that is not
a `replace`: refused if a later-sent list has landed; else set aside if a
local write has; and only then recorded with `admit` and applied. A list
that is set aside leaves no mark, so a refusal now always means a
later-sent list was really applied. In the sequence above the refresh's
read is set aside too, and the refresh reads again.

What was checked

- No list is applied that was refused before. A read that used to be
  refused only because a set-aside list had landed was sent before that
  list, which was sent before the local write. It is therefore set aside
  itself, never applied.
- The re-read loop is no more open than it was. A round still needs
  `setBranchesByProject` to change a branch list of the project after
  the round's ticket is issued, and nothing in the loop calls it. The
  refresh's first read can now enter the loop where it used to be
  refused, which is the point of the fix.
- The background re-reads stay bounded. More reads can now be set aside,
  and each asks for a background refetch, but that refetch is single
  flight with one queued follow-up, as before.
- The `replace` path is unchanged. It skips both checks, calls `admit`
  with the result ignored, and is always applied, as before. One edge
  differs: a `replace` list landing after a later-sent set-aside list
  now records its own ticket, where `admit` used to decline. Any read
  between the two is set aside by the same local write, so nothing new
  is applied.
- `branches:<project>` is read nowhere else.

Behaviour to know about

- A refresh in the review's sequence takes one more round trip, and its
  re-read can run beside the event refetch's background read. Whichever
  was sent later wins; the other is refused.

Verified

- New test, the review's sequence with hand-settled reads. On e6069e4's
  source it fails where the refresh resolves early. With the fix the
  refresh does not settle, reads again, resolves `true` with the deleted
  branch gone, and the background read answering last is refused with no
  further read.
- Second new test, the same start with the background read landing
  before the refresh's own read. The refresh's read is refused, the
  refresh resolves `true` and nothing more is read. It passes with and
  without the fix: it pins that a true refusal still ends the refresh
  without another round.
- `pnpm check` (0 errors, 0 warnings), the frontend prettier check
  (`src/**/*.{ts,svelte,css,html}`), and the full vitest suite (239
  files, 3595 tests) pass, at a machine load between 25 and 48.

Not run or not covered

- The app itself. The race was not reproduced live.
- No test drives the branch delete in ProjectHome. The store tests call
  `refreshProject` and `setBranchesByProject` directly and emit the
  change event by hand.
- No new test for the `replace` edge above. The existing transfer tests
  pass unchanged.
- The Rust checks and tests, since no Rust file changed.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…and list

Main made it an `async fn` in 1a3efbf (#1067), so after rebasing onto
main the source guard flagged the entry 5b095c7 added as stale.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@matt2e
matt2e force-pushed the syncronous-commands branch from 4b5f50a to a54376c Compare November 13, 2026 07:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant