Skip to content

feat(staged): queue branch git actions behind running sessions - #902

Merged
matt2e merged 9 commits into
mainfrom
queue-git-actions
Aug 5, 2026
Merged

feat(staged): queue branch git actions behind running sessions#902
matt2e merged 9 commits into
mainfrom
queue-git-actions

Conversation

@matt2e

@matt2e matt2e commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Rebase, squash, push, force-push, and pull were all disabled whenever a branch had a queued or running session ("Session in progress"). This lands all three phases of the queue-git-actions plan: those actions now join the per-branch session queue instead of being blocked, and they drain headlessly in FIFO order.

Phase 1 — rebase / squash

  • rebase_branch / squash_commits return BranchPipelineResponse (sessionId + sessionStatus) so callers can tell a started pipeline from a queued one; web_server.rs mirrors the new shape and now forwards target, so web mode no longer silently downgrades "Rebase onto Origin" to a base rebase.
  • Queue-vs-run decision, dedupe scan, and insert all run under branch_session_launch_lock_for, replacing a racy check-then-insert. Dedupe keys on kind and rebase target.
  • Frontend renders a queued-commit stub ("Queued — waiting for current session…") instead of flashing "Rebasing…" for work that hasn't started.

Phase 2 — push / force push

  • Migration 0021 adds nullable sessions.branch_id. Push pipelines create no commit/note/review, so the artifact joins could not see them; the branch queue/resolver queries now also match on that column.
  • New PipelineKind::Push plus a persisted push_force flag, so a dequeued push re-derives the same command and a queued normal push stays distinct from a queued force push.
  • New GitPipeline schedule kind (exclusive, queue-blocking). Queued pushes drain through start_queued_git_pipeline_for_branch, which rebuilds steps from the branch's current name rather than replaying queued ones.
  • Side effect of the above: a running push now blocks new notes/commits/reviews. Previously it was invisible to the queue and blocked nothing.

Phase 3 — pull

  • pull_branch_ff_only becomes pull_or_queue_branch: runs the direct git op when the branch is idle, otherwise enqueues a PipelineKind::Pull session. Returns the queued session id, or null when the pull ran immediately.
  • build_pull_pipeline_steps does fetch then merge --ff-only origin/<branch>, both aborting rather than handing off to an agent. An aborted pull ends the session in error with the failing step's label and output; an aborted push still completes so the UI can offer force push.

UI

  • pushState gains a queued state and a new pullState store mirrors it; the PR button and the git-push / diverged / origin-ahead timeline rows show "Queued" and cancel the queued session on click. Queued entries flip to running on the drain's "running" event, with the 5s poller as fallback.
  • BranchTimeline splits the busy gate into queueableGitActionDisabledReason and immediateGitActionDisabledReason. Reset-to-origin and discard-changes stay immediate-only (both validate against a point-in-time preview of what would be discarded), so they still respect branchSessionBusy.
  • Queued-pull failures surface as toasts from the session-status listener.

Notes for review

  • Session.branch_id and PipelineExecution.push_force are data-model additions, and PipelineKind::Push / Pull are new persisted enum variants — flagging per AGENTS.md.
  • Known gap: the queued badge lives in frontend state only, so a still-queued action drains without the UI badging it after an app restart. Re-clicking hits the backend dedupe and restores the badge.

Tests

Rust: run-when-idle / queue-when-busy, queued label + pending-commit link, repeat-click dedupe, per-kind and per-target separation, migration schema version/column, branch-linked queued and running push queries, drain-scan blocking and FIFO ordering, step rebuilding, and aborted_pipeline_error cases for pull (with and without step output) and push. Frontend: commands.test.ts pins the new response shapes and distinguishes an immediate pull from a queued one.

@matt2e
matt2e requested review from baxen and wesbillman as code owners August 4, 2026 06:47

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b037fce1b7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +576 to +578
if !branch_has_work_in_flight(store, branch_id)? {
return Ok(None);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard idle pipeline starts under the branch lock

When the branch is idle, this returns None and drops the launch lock before the caller creates the running session. Two concurrent git-action requests against the same branch can both pass this check, both create running pipelines, and mutate the same worktree/remote concurrently instead of queueing; the regular branch-session path avoids this by rechecking and inserting the running row under the same lock.

Useful? React with 👍 / 👎.

Comment on lines 460 to 463
case 'originAhead': {
const disabledReason = pullDisabledReason(state);
// A queued pull keeps the button live so it can cancel the queued session.
const disabledReason = pullQueuedOrigin ? undefined : pullDisabledReason(state);
rows.push({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep queued pulls visible after the branch diverges

The queued-pull cancel affordance is only rendered while the cached git state is originAhead. If a pull is queued behind a commit/rebase and that earlier work creates a local commit before the pull drains, the branch becomes diverged while pullQueuedOrigin is still true, so the queued pull disappears from the UI and cannot be cancelled before it later runs into the expected --ff-only failure.

Useful? React with 👍 / 👎.

matt2e added a commit that referenced this pull request Aug 5, 2026
Codex's second PR #902 comment (note ae57c7a6): the "Pull queued" badge and
its Cancel button rendered only inside the `originAhead` arm of the upstream
switch, but the queued session they describe is relation-independent. Queue a
pull while origin is ahead, let the session ahead of it in the branch queue
land a local commit, and the relation flips to `diverged` — the
`git-origin-ahead` row disappears and takes the badge and the only Cancel
affordance with it. The session still drains, into a `merge --ff-only` that
cannot fast-forward, so the user gets a failure toast for work they could no
longer see or call off.

A queued pull now gets a footer row of its own whenever the relation isn't
`originAhead`: same `git-footer` slot and order the origin-ahead row occupied,
titled "Pull from origin" with the "Pull queued" meta and the Cancel button
wired to `onCancelQueuedPull`. The `originAhead` arm is unchanged, so exactly
one of the two renders.

The decision is `standaloneQueuedPullRowCopy` in a new `queuedPullRow.ts`,
which also covers `inSync`/`localAhead`/`missing` (no upstream row at all) and
a null relation. The row is therefore built outside the `if (timeline.gitState)`
block in the item derivation rather than inside `gitStateRows`, so a timeline
that comes back without a git state — an unprovisioned or removed worktree —
still leaves the queued session cancellable. That required lifting the
`git-footer` timestamp to a `gitFooterTimestamp` const shared by both.

Scope note: a *running* drained pull is still only surfaced on the
origin-ahead row, so it can also go unseen on a diverged branch. Left alone
deliberately — it is transient and ends in a toast either way, and covering it
would flash a footer row every time an immediate pull succeeds (the timeline
reloads to `inSync` before `pullingOrigin` clears).

Frontend only; no backend, data-model, or store changes. Five
`queuedPullRow.test.ts` cases pin which relations get the standalone row.
`pnpm test` (492), `pnpm run check`, and `prettier --check` pass.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added 7 commits August 5, 2026 12:16
Rebase and Squash were disabled whenever the branch had a queued or
running session ("Session in progress"), even though the backend already
knew how to enqueue them: `start_or_queue_commit_pipeline_for_branch`
creates a queued session plus pending-commit artifact that
`drain_queued_sessions_for_branch` picks up in FIFO order. Only the
frontend stood in the way. Phase 1 of the queue-git-actions plan removes
that block and makes the queued state legible.

Backend:
- `rebase_branch`/`squash_commits` now return `BranchPipelineResponse`
  (`sessionId` + `sessionStatus`) instead of a bare session id, so the
  caller can tell a started pipeline from a queued one. Mirrored in the
  `web_server.rs` dispatch arms.
- The queue-vs-run decision, the dedupe scan, and the insert all run
  under `branch_session_launch_lock_for`, replacing a racy
  check-then-insert. Clicking Rebase twice now reuses the queued session
  rather than stacking a second one. Dedupe keys on kind *and* persisted
  rebase target so "onto base" and "onto origin" stay distinct.
- `web_server.rs` also forwards `target`, so web mode no longer silently
  downgrades "Rebase onto Origin" to a base rebase.

Frontend:
- Dropped `branchSessionBusy` from the `startBranchCommandPipeline`
  guard and from `branchCommandDisabledReason`; identity warnings
  (detached HEAD, wrong branch) still disable both actions.
- A queued response now renders a `queued-commit` stub labelled with the
  pipeline name and "Queued — waiting for current session…", matching the
  persisted row instead of flashing "Rebasing…" for work that hasn't
  started.
- `BranchTimeline`'s `rebaseBranchDisabledReason` gated push, force-push,
  and reset-to-origin rather than rebase. Renamed it to
  `immediateGitActionDisabledReason` and fed it a reason that keeps the
  busy check, so those three stay blocked while sessions are in flight —
  their queueing is Phases 2 and 3, and their handlers still guard on
  `branchSessionBusy`. Without the split, removing the busy arm would
  have made them look clickable while silently doing nothing.
- `BranchCardActionsBar`'s `newCommitDisabled` only ever gated the
  Rebase/Squash menu items, so it is now `rebaseSquashDisabled`.

Tests: seven `prs.rs` cases cover run-when-idle, queue-when-busy, the
queued label and pending-commit link, repeat-click dedupe, per-kind and
per-target separation, and a queued pipeline alone keeping the branch
busy. A `commands.test.ts` case pins the new return shapes. `just
fmt-check`, `just lint`, `just typecheck`, `just test` (559), and
`pnpm test` (482) all pass.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Phase 2 of the queued git actions plan: push and force push now join the
per-branch session queue instead of being disabled while the branch is
busy, and a push in flight blocks other branch work the way a commit does.

Backend:
- Migration 0021 adds nullable `sessions.branch_id`. Push pipelines create
  no commit/note/review, so the artifact joins could not see them;
  `get_queued_sessions_for_branch`, `has_running_session_for_branch` and
  the branch/project resolvers now also match on that column.
- `PipelineKind::Push` plus a persisted `push_force` flag, so a queued push
  re-derives the same command on dequeue and a queued normal push is
  distinguishable from a queued force push.
- New `GitPipeline` schedule kind: exclusive and queue-blocking. Queued
  pushes drain through `start_queued_git_pipeline_for_branch`, which
  rebuilds the steps from the branch's current name rather than replaying
  the queued ones.
- `push_branch` returns `BranchPipelineResponse` (running vs queued);
  queue-vs-run, dedupe on `(kind, push_force)`, and the insert all happen
  under the branch launch lock, mirroring rebase/squash.
- Bonus fix: because a running push resolves to a `GitPipeline` schedule,
  it now blocks new notes/commits/reviews. Previously a push was invisible
  to the queue and blocked nothing.

Frontend:
- `pushState` gains a `queued` state; the PR button and the git-push /
  diverged timeline rows show it and cancel the queued session on click.
  The queued entry flips to `pushing` on the drain's "running" event, with
  the 5s poller as a fallback.
- Push and force-push no longer check `branchSessionBusy` — the backend
  owns that decision. Reset to origin still does: it is validated against a
  point-in-time preview of what would be discarded, so it stays
  immediate-only, as does discard changes. `BranchTimeline` therefore takes
  a separate `queueableGitActionDisabledReason` alongside the existing
  `immediateGitActionDisabledReason`.

Known gap: the queued badge lives only in the frontend store, so after an
app restart a still-queued push drains without the UI showing it as queued
first. Also note that per AGENTS.md the new `Session.branch_id` and
`PipelineExecution.push_force` fields are data-model additions that want
human review; they are the shape the plan note specified.

Tests: migration schema version/column, branch-linked queued and running
push queries, drain-scan blocking and FIFO ordering for pushes, queue-vs-run
and dedupe for push vs force push, step rebuilding, and the new
`pushBranch` response shape.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Phase 3 of the branch git-action queue: pull now follows the same
start-or-queue path as rebase/squash (Phase 1) and push/force-push
(Phase 2). When the branch is idle the pull still runs as an instant
direct git operation; when the branch has work in flight it enqueues a
`PipelineKind::Pull` session that the branch drainer runs headlessly in
FIFO order.

Backend:
- Add `PipelineKind::Pull` and route it through `PipelineBranchLink::Branch`
  (no commit artifact), so it schedules as `BranchSessionScheduleKind::GitPipeline`.
- Replace the `pull_branch_ff_only` command with `pull_or_queue_branch` in
  `prs.rs`, which takes the branch launch lock, dedupes against an existing
  queued pull, and otherwise falls through to `pull_branch_ff_only_impl`.
  It returns the queued session id, or null when the pull ran immediately.
- Generalize `start_queued_git_pipeline_for_branch` over push/pull and add
  `build_pull_pipeline_steps` (fetch, then `merge --ff-only origin/<branch>`,
  both aborting rather than handing off to an agent).
- Surface drained-pull failures: an aborted pull now ends the session in
  `error` with the failing step's label and output, while an aborted push
  still completes so the UI can offer force push.

Frontend:
- Add a `pullState` store mirroring `pushState`, badge the origin-ahead
  timeline row with "Pull queued", and turn its button into Cancel while
  queued.
- Loosen the dirty-worktree gate: pull moves to
  `queueableGitActionDisabledReason`, so agent-transient dirt no longer
  disables it while the branch is busy.
- Toast queued-pull failures from the session-status listener, and teach
  the session registry / labels about the `pull` session type.

Tests: pull coverage in the git-pipeline scheduling tests, new
`aborted_pipeline_error` unit tests (pull with and without step output,
push unaffected), and a `commands.test.ts` case distinguishing an
immediate pull from a queued one.

Data-model note for review: `PipelineKind::Pull` is a new persisted enum
variant. The queued badge lives in frontend state only, matching Phase 2,
so a badge is lost across an app restart (re-clicking Pull hits the
backend dedupe and restores it).

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to the queued git actions work (review 5368736f). The queue was
correct once a session existed; the gaps were all in the moment before one
did, plus two frontend signals that could disagree with the backend.

Backend:
- The rebase/squash and push run-now paths checked `branch_has_work_in_flight`
  under the branch launch lock, then released it to resolve the pipeline
  context and inserted the running session with no second look. Two
  near-simultaneous actions could both see an idle branch and both start.
  Both paths now re-check and insert under the lock, mirroring
  `start_or_queue_branch_session_for_store`: `queue_*_pipeline_if_branch_busy`
  splits into a locking wrapper plus a `_locked` body, and
  `start_running_*_pipeline_for_branch` splits into an
  `insert_running_*_pipeline_session` (under the lock, writes the rows that
  make the branch look busy) and a shared `launch_running_pipeline_session`
  (after it, emits + hands off to the runner). The pre-flight check stays so
  an already-busy branch still queues without resolving a context it may not
  be able to.
- An immediate pull created no session row, so for a network fetch plus
  `merge --ff-only` the branch looked idle to `has_running_session_for_branch`
  and the drain scan — the one mutating git op the queue could not see.
  `claim_or_queue_pull_for_branch` now decides queue-vs-run and records either
  outcome under one lock: a queued pull as before, or a running
  `PipelineKind::Pull` session linked to the branch (no artifact, like a push)
  that marks the branch busy for the pull's duration. It emits no status
  event, because the caller still awaits the pull and reports the outcome
  itself; `finish_immediate_pull_session` ends it, then the branch queue is
  drained since the marker bypassed the runner that normally would.
  `pull_or_queue_branch` therefore takes the registry and app handle now.

Frontend:
- `branchSessionBusy` was purely timeline-derived, and a push or pull creates
  no artifact, so a mid-push branch read as idle: Pull stayed disabled by a
  dirty worktree even though the click would have queued. The push/pull store
  reads move above it and fold in through a new tested
  `isGitActionInFlight` helper. Reset to origin and discard changes now also
  stay blocked during a push or pull, which they should have been.
- Queued pulls had no polling fallback, so a missed `session-status-changed`
  left the "Pull queued" badge stuck until the user hit Cancel and lost the
  failure toast. BranchCard polls the pull session every 5s, like
  BranchCardPrButton does for pushes.
- `pullDisabledReason`'s doc comment claimed the dirt clears before the pull
  drains; it now states the actual accepted failure mode (a stale timeline
  means the pull runs immediately and fails with "Cannot pull with
  uncommitted changes"). `BranchPipelineResponse`'s doc mentions push and
  calls out pull's different return shape.

No data-model changes: the marker session reuses `PipelineKind::Pull` and
`sessions.branch_id`. `PullDisposition` and `RunningPipelineSession` are
in-memory only.

Tests: the marker session's shape, that it blocks a concurrent rebase, push
and pull, and that finishing it frees the branch and records the failure
message; that inserting a running commit or push pipeline is what the
re-check reads; and `isGitActionInFlight` cases. `just fmt-check`, `just
lint`, `just typecheck`, `just test` (590), and `pnpm test` (487) pass.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Review d8635210 on the run-now race fixes: the immediate-pull path
joined its spawn_blocking task with `?`, so a JoinError (the pull
closure panicking, or runtime shutdown) returned before
finish_immediate_pull_session ran. The marker session is exactly what
makes the branch look busy and nothing else ever ends it, so every
later git action and commit session on the branch would queue behind a
session that never finishes, until an app restart's owner_pid recovery
cleared it.

The join result now folds into the pull's own error path with
unwrap_or_else, so both arms — a pull that ran and a task that died —
flow through finish_immediate_pull_session (which records the error
and frees the branch) and the queue drain, and the caller still gets
the failure as the command's error.

No new test: constructing the JoinError requires driving the real
async runtime plus an AppHandle for the drain, which the unit tests
don't build, and the behavior the fold guarantees (finishing the
marker frees the branch and records the message) is already pinned by
finishing_an_immediate_pull_frees_the_branch_and_records_the_failure.
`just fmt-check`, `just lint`, and `just test` (590) pass.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Codex's second PR #902 comment (note ae57c7a6): the "Pull queued" badge and
its Cancel button rendered only inside the `originAhead` arm of the upstream
switch, but the queued session they describe is relation-independent. Queue a
pull while origin is ahead, let the session ahead of it in the branch queue
land a local commit, and the relation flips to `diverged` — the
`git-origin-ahead` row disappears and takes the badge and the only Cancel
affordance with it. The session still drains, into a `merge --ff-only` that
cannot fast-forward, so the user gets a failure toast for work they could no
longer see or call off.

A queued pull now gets a footer row of its own whenever the relation isn't
`originAhead`: same `git-footer` slot and order the origin-ahead row occupied,
titled "Pull from origin" with the "Pull queued" meta and the Cancel button
wired to `onCancelQueuedPull`. The `originAhead` arm is unchanged, so exactly
one of the two renders.

The decision is `standaloneQueuedPullRowCopy` in a new `queuedPullRow.ts`,
which also covers `inSync`/`localAhead`/`missing` (no upstream row at all) and
a null relation. The row is therefore built outside the `if (timeline.gitState)`
block in the item derivation rather than inside `gitStateRows`, so a timeline
that comes back without a git state — an unprovisioned or removed worktree —
still leaves the queued session cancellable. That required lifting the
`git-footer` timestamp to a `gitFooterTimestamp` const shared by both.

Scope note: a *running* drained pull is still only surfaced on the
origin-ahead row, so it can also go unseen on a diverged branch. Left alone
deliberately — it is transient and ends in a toast either way, and covering it
would flash a footer row every time an immediate pull succeeds (the timeline
reloads to `inSync` before `pullingOrigin` clears).

Frontend only; no backend, data-model, or store changes. Five
`queuedPullRow.test.ts` cases pin which relations get the standalone row.
`pnpm test` (492), `pnpm run check`, and `prettier --check` pass.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ping the badge

Review 5af0ac18 (note ad5e8094): the queued-pull poller in BranchCard
treated any `getSession` rejection as "session lost" and cleared the
pullStateStore entry on the first failure. Since the backend command
returns `Ok(None)` for an unknown or deleted session, a rejection is
only ever a transport or dispatch failure — a web-mode network blip, the
backend restarting, a laptop waking from sleep. So one blip permanently
removed the "Pull queued" badge and its Cancel button while the backend
still held the queued session, which then drained invisibly; the
completion toast survived only via the `session-status-changed` event
that this poller exists to back up.

`createPollFailureTracker` in branchCardHelpers gives a poller a budget
of consecutive failures (3 by default, so ~15s at the 5s cadence) that a
success resets. Below the budget the catch arm warns and keeps polling;
once exhausted it does exactly what it did before. Extracted rather than
an inline counter per effect so the three call sites stay consistent and
the reset-on-success decision is testable. "Never clear" was rejected: a
genuinely unreachable backend would leave the badge up forever with a
Cancel button that cannot work.

Applied to the push and PR-creation pollers in BranchCardPrButton too.
The review suggested matching the push poller, describing it as logging
and continuing, but it also gives up on the first failure — and worse,
`setPushError` flips the button to "Push failed" for a push that is
still queued. Fixing all three is what actually removes the
inconsistency the comment was pointing at.

A session cancelled elsewhere still clears promptly: that is the `null`
success path, untouched. Effect re-runs get a fresh tracker, which is
correct — the budget is per polling episode.

Out of scope (its own fix): the review's second comment, that
`cancelQueuedPull`/`cancelQueuedPush` clear the store before
`cancelSession` resolves, so a failed cancel loses the affordance.

Tests: four `branchCardHelpers.test.ts` cases pin the budget, the
reset-on-success semantics, a custom `maxFailures`, and the default. The
`$effect` intervals stay untested, as they were. Frontend-only; no
backend, store, or data-model changes. `pnpm test` (496), `pnpm run
check`, and `prettier --check` pass.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the queue-git-actions branch from 8907631 to 744d271 Compare August 5, 2026 03:05
matt2e added 2 commits August 5, 2026 13:07
…onfirms

Review dc6c3fa5, and the gap commit 744d271 explicitly deferred: all
three "cancel a queued git action" handlers cleared the frontend store
entry *before* `cancelSession` resolved. `cancel_session` answers `Ok`
for an unknown, already-finished, or already-cancelled session, so a
rejection only ever means the request never reached the backend — a
web-mode network blip, the backend restarting, a laptop waking — which
is exactly when the queued session still exists. The badge and its
Cancel button were gone anyway, so the push/pull went on to drain
invisibly, potentially into the `merge --ff-only` failure the
standalone queued-pull row was added to surface.

`createQueuedSessionCanceller` in branchCardHelpers inverts the order:
await the cancel, clear on success, and on failure report the error and
keep the state so the affordance survives and re-clicking retries. It
also carries an in-flight flag, because leaving the state set means the
call sites' own `pullQueuedOrigin` / `pushState !== 'queued'` guards no
longer stop a second click. Extracted rather than inlined three times,
following `createPollFailureTracker`, so the ordering decision is
testable and the sites stay consistent.

BranchCard's `cancelQueuedPull` and `cancelQueuedPush` now delegate and
only invalidate/reload the timeline on success. BranchCardPrButton's
`cancelQueuedPush` additionally drops `setPushError` for a toast: on a
failed cancel it flipped the PR button to "Push failed" for a push that
was still queued, replacing Cancel with a retry dialog.

Races considered, no further changes: both clears are idempotent
`Map.delete`s, so the 5s fallback poller racing the helper is harmless,
and a genuinely unreachable backend still clears the badge via the
poller's 3-failure budget with the cancel toast explaining why. A drain
that flips the entry to pulling/pushing before the click no-ops the
existing guards.

Frontend-only: two components plus branchCardHelpers. No backend,
store-shape, or data-model changes. Four `branchCardHelpers.test.ts`
cases pin the clear-after-confirm ordering, state survival on
rejection, re-entrancy, and retry-after-failure. `pnpm test` (512),
`pnpm run check`, and `prettier --check` pass.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Review dc6c3fa5 on the poll-failure-tracker commit (744d271): the push
poller in BranchCardPrButton never handled `getSession` resolving `null`.
A missing session fell through all three status guards, so the tick was a
no-op and the poller ran forever behind a "Push queued" badge (or a
"Pushing…" button) for a session the backend no longer had. The
PR-creation poller a few lines up has the identical shape, leaving the
button on "Creating PR…" forever, so both are fixed — fixing only the
flagged one is the inconsistency 744d271 was itself cleaning up. The
window is narrow, since `cancel_session` transitions to `cancelled`
rather than deleting: only `delete_session` or the branch-delete cascade
on `sessions.branch_id` produce `null`.

The naïve `null → clear` arm would have been wrong. `handleCreatePr` and
`handlePush` (and BranchCard's push rows, into the shared store) seed
`sessionId: '__pending__'` before the launch command resolves, and both
effects guarded only on falsiness — so a launch slower than one 5s tick
polls the sentinel, `get_session` is a plain `SELECT … WHERE id = ?1`
with `.optional()`, and the badge would be cleared for a push that is
actually starting. So each poller now skips the sentinel first (as
`cancelQueuedPush` already did); both ids are `$derived` from their
store, so the effect re-runs with the real id.

`classifyPolledSession` in branchCardHelpers holds the per-tick decision
— `gone` / `waiting` / `active` / `finished` — following
`createPollFailureTracker` and `standaloneQueuedPullRowCopy` in putting
`$effect` decisions in testable plain TS. Its `gone` contract is the one
`createPollFailureTracker`'s docstring already leaned on: a rejection is
always transport, a `null` is always deletion.

The push poller's `gone` arm is `clearPushState`, not
`clearSessionTracking` (which keeps the `queued` state and nulls the id,
leaving the badge up with a Cancel button that early-returns) and not
`setPushError` (flipping to "Push failed" for a session someone
deliberately deleted is the same misreport 744d271 removed). The PR
poller clears to "Create PR", and now tolerates a hypothetical queued
status instead of reporting it as a completion. The pull poller in
BranchCard folds onto the same classifier with no behavior change —
`gone` and `finished` share the arm `null` already took — so all three
call sites read alike, which was the point of extracting the tracker.

`handleSessionModalClose` keeps its `session && status !== 'running'`
shape: it is a one-shot check explicitly backed by these pollers, and
that backing is now true for a deleted session too.

Frontend-only; no backend, store-shape, or data-model changes. Four
`branchCardHelpers.test.ts` cases pin the classifier; the `$effect`
intervals stay untested, as they were — the decision they switch on is
now the tested surface. `pnpm test` (516), `pnpm run check`, and
`prettier --check` pass.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e merged commit af7a8ba into main Aug 5, 2026
4 checks passed
@matt2e
matt2e deleted the queue-git-actions branch August 5, 2026 03:27
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