Skip to content

fix(terminal): retry first-terminal seeding until a decision applies - #22919

Merged
brennanb2025 merged 2 commits into
mainfrom
brennanb2025/terminal-first-seed-guard
Sep 27, 2026
Merged

brennanb2025 merged 2 commits into
mainfrom
brennanb2025/terminal-first-seed-guard

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​106 $\color{#cf222e}{\Huge{\mathbf{−}}}$​17 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​89
Prod 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​13 $\color{#cf222e}{\Huge{\mathbf{−}}}$​8 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​5

ELI5

When you open a workspace that has never had a terminal, Orca is supposed to create one for you. It first runs a short background check for agents or sessions it should restore. The code wrote down "done" for that workspace before the check finished, so if the check was interrupted or came back "not yet", no terminal was created and nothing ever tried again. Now it writes down "done" only after it has acted on the check's answer.

What Changed

Before: the workspace could be left with no terminal for the rest of the session. It stayed empty until you opened it from the sidebar or the worktree palette, which create terminals through a different path. What users can hit:

  • Production: you switch away from a never-visited workspace during its short check (tens of milliseconds), then come back through a route that doesn't create a terminal itself. Several routes select a workspace directly without the sidebar's terminal creation, for example opening a file preview or following a terminal link. Main left the workspace blank in 4 of 4 production-build trials. Startup and ordinary activation were not affected in production: 8 of 8 trials got a terminal.
  • Development builds: React runs the effect twice on first mount (StrictMode), which cancels the first check. So in pnpm dev a never-visited workspace that is active at startup, or the first one you open, always stayed blank.
  • A 'blocked' answer (for example, a live agent process whose recovery isn't settled yet) marked the workspace done without creating anything. I could not produce 'blocked' for a never-visited local workspace, so this path is covered by unit tests only.

After: in each of those cases the next run of the effect checks again, and the workspace gets its terminal (4 of 4 production trials, and every development-build trial). Once the effect has acted on an answer, it still never checks that workspace again, so it cannot create a second terminal.

Mechanism (use-terminal-watcher-effects.ts, the startup seeding effect):

  • The "already handled" mark (startupActivationGateWorktreeIdsRef) used to be added before await gateWorktreeAgentActivation(...). It is now added inside the result callback, and only when the run wasn't cancelled, the answer wasn't 'blocked', and the workspace is still the active one.
  • A cancelled run, a 'blocked' answer, a rejected check, or a workspace switch mid-check leaves no mark, so the next run tries again.
  • A rerun during an in-flight check does no extra work: gateWorktreeAgentActivation already hands every caller the same in-flight promise for a workspace.
  • The callback now reads "does this workspace have a terminal row" from the store when it applies the answer, instead of the value captured at render. The check resolves asynchronously, so the captured value can be stale: if the user closed the last terminal while the check ran, the stale value would re-seed a workspace they emptied. That value is no longer an effect input, so a row appearing mid-check (for example the host restoring its terminals) no longer cancels and restarts the check.
  • The rule for when to create a terminal (shouldAutoCreateInitialTerminal), including "an explicit empty row means the user closed the last terminal", is untouched.

Why

The bug was a mark that nothing ever cleared. Clearing it on each failure path would add a delete in the cleanup, one on 'blocked' and one on rejection. Setting it only once an answer has been applied means there is nothing to clear, so this approach uses the fewest moving parts. It adds no new state: the existing in-flight dedupe in the activation gate is what makes a retry cheap.

Alternatives considered:

  • Delete the mark in the effect cleanup and on 'blocked'. Same behavior, more code paths, and a late cleanup could race a run that is already applying.
  • Keep "does this workspace have a row" as an effect input and rely on a rerun to refresh it. A rerun only happens after React commits, and the check's answer can arrive between the store write and that commit, so the old callback would still act on the stale value. Reading the store at decision time closes that window, and it keeps the value to a single read.
  • Route this effect through the shared seeding helper (ensureWorktreeHasInitialTerminal). Worthwhile, but a larger change than this bug needs, and the helper's closed-last-terminal handling differs (explicit activation re-seeds). Left for separate work.

Differences from the common pattern

  • The startup-resume effect right below this one marks before its check and deletes the mark on 'blocked' or rejection. This effect marks after instead. The difference comes from cancellation: the resume effect has no cancelled path, so its in-flight run always reaches its callback. This effect cancels on cleanup, so an eager mark would have to be cleared in three places. The retry trigger is the same as the resume effect's: the next change to the effect's inputs, such as leaving and returning to the workspace.
  • The in-flight dedupe of the check itself lives in the gate, not in this effect. That matches how the gate is already shared by the resume effect and by explicit activation.

Linked Issue

No ticket. Found during QA of the chat-send work, where the first activation on a fresh profile showed no terminal.

Visual Proof

Production build. Workspace A was selected, the app switched to B while A's check was still running, then selected A again without the sidebar:

Before (main) After (this PR)
Before: workspace A selected, empty main area, no terminal tab After: workspace A shows Terminal 1 with a shell prompt

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

New tests in initial-terminal-structured-launch.test.tsx mount the effect and assert whether a terminal is created:

  • StrictMode double run: exactly one terminal. A later rerun neither checks again nor creates a second one.
  • Input change mid-check: exactly one terminal after the shared check resolves.
  • 'blocked', then leave and return: a terminal is created on return.
  • Two separate 'empty' checks, separated by leaving the workspace: exactly one terminal.
  • Last terminal closed while the check runs: no terminal is created.

Each test was checked against the code without the fix:

  • The effect file from main: all three fail with "created 0 times".
  • Deleting only the new mark: the StrictMode test fails with "created 2 times", a double seed.
  • Deleting only the 'blocked' exclusion: the blocked test fails with "created 0 times".
  • Reading the render-time row instead of the store: the closed-during-check test fails.
  • Ignoring cancellation: three tests fail.

Other checks:

  • src/renderer/src/components/terminal/, terminal-parked-watcher-sync-entries.test.tsx and app-startup-routing.test.ts pass: 29 files, 287 tests.
  • pnpm tc:web, oxlint on the changed files, check:code-quality:changed and check:react-doctor:changed all pass.

Manual Electron checks, comparing main's version of the effect with this PR. Each check used an isolated profile and relaunched the app before every trial:

  • Production build (build:electron-vite, production React):
    • A never-visited workspace active at startup: a terminal on both, 4 of 4 each.
    • Add a project, then select it: a terminal on both, 4 of 4 each.
    • Switch away while the check is running, then select it again without the sidebar: main blank in 4 of 4, this PR one terminal in 4 of 4.
    • Coming back through the worktree palette gets a terminal on both, through the palette's own path.
    • Every check answered 'empty'; none answered 'blocked'.
  • Development build:
    • A never-visited workspace active at startup: main blank in 5 of 5, this PR exactly one terminal in 5 of 5.
    • Adding a project and selecting it right after launch, before any terminal view has loaded: main blank in 8 of 8, this PR one terminal in 8 of 8.
    • The terminal can take several seconds to appear in a development build, the same on main. That time is the first load of the lazily loaded terminal view, before this effect runs; the check itself answered in 34-130 ms.
  • Unchanged on both builds:
    • Clicking a workspace in the sidebar after closing its last terminal brings a terminal back. That is the sidebar's own behavior, which this PR does not change.
    • Startup with a workspace whose last terminal was closed stays empty.

Platforms: macOS. The change is platform-independent renderer logic.

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • SSH and remote: unchanged. The effect still returns early unless the execution host has answered that it owns no terminals (activeWorktreeHostAuthority === 'none'). A retry after an interrupted check goes through that same gate, so it cannot seed into an SSH workspace whose host hasn't answered.
  • Folder workspaces: they take the same path as worktrees; no special case.
  • Wire and persisted data: none changed.
  • Performance: a rerun during an in-flight check shares the existing promise. After a 'blocked' answer, the check runs again only when the effect's inputs change, the same cost the resume effect already pays.

Author X: @BrennanKB5

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

The seeding effect marked a workspace as attempted before its async
activation check finished, so a StrictMode double run, an input change
mid-check, or a 'blocked' result left the workspace marked with no
terminal. Mark it only once an uncancelled, unblocked result is applied;
a rerun shares the gate's in-flight check.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Retryable first-terminal seed — in the startup activation gate effect, the "already handled" mark is now added inside the gate's result callback instead of before the await, and only when the run wasn't cancelled, the outcome isn't 'blocked', and the workspace is still active. A cancelled run, a 'blocked' answer, or a mid-check workspace switch now leaves the worktree unmarked so the next effect run retries; adopted/resumed/structured still mark once, and 'empty' seeds exactly once.
  • Regression tests — three new tests mount the real effect and assert createTab call counts for the StrictMode double run, an input change mid-check, and a 'blocked' answer followed by leaving and returning.

I read the full diff, traced the gate outcome vocabulary in worktree-agent-activation-gate.ts ('blocked' | 'adopted' | 'structured' | 'resumed' | 'empty'), confirmed the in-flight dedupe in gateWorktreeAgentActivation makes a retry cheap, and verified the source-text assertions in app-startup-routing.test.ts still hold. Ran pnpm test on both touched test files: 41 passed.

One pre-existing note, not introduced here and not blocking: this effect's .then has no rejection handler, so a rejected gate never reaches the callback (which is why it correctly leaves no mark) but surfaces as an unhandled rejection. The sibling resume effect handles rejection explicitly. Out of scope for this fix.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 62acabfd-3130-43a6-b439-63e10aa59e25

📥 Commits

Reviewing files that changed from the base of the PR and between 553f8a0 and 3fe9970.

📒 Files selected for processing (3)
  • src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
  • src/renderer/src/components/terminal/initial-terminal-wiring.test.ts
  • src/renderer/src/components/use-terminal-watcher-effects.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The startup activation pass now leaves cancelled, blocked, and stale worktree checks eligible for retry. It marks a worktree processed after an accepted gate result, and only an empty result continues to default-terminal checks. After tab reconciliation, it reads terminal state from the current store. Tests cover StrictMode, changed inputs during a pending check, and returning to a previously blocked worktree.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 3fe99

The terminal-seeding change is mergeable after normal checks; no unresolved issue requiring a fix was identified.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 3fe99

The change is limited to terminal startup behavior. The reviewed path still waits for host authority, checks for existing terminal state, and permits only one default terminal after an accepted decision. No new security concern was established, though coverage is incomplete.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The changed production behavior affects default terminal creation for an active renderer worktree, rather than adding a new external entrypoint.

Trust Boundaries and Controls

  • observed — The watcher checks host authority before starting the gate, rejects cancelled or no-longer-active results, and checks current terminal state before creating a tab.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description is detailed and covers the user impact, mechanism, rationale, testing, visual proof, and compatibility considerations. However, the required Linked Issue section explicitly states “No … Add the issue link in the Linked Issue section, using the required “Fixes #” format. If no issue exists, create or associate one before merging.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: retrying first-terminal seeding until the activation decision applies.
Full details: Description check

Explanation

The description is detailed and covers the user impact, mechanism, rationale, testing, visual proof, and compatibility considerations. However, the required Linked Issue section explicitly states “No ticket,” and the template requires an issue link.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…on applies

The seeding callback runs after an async activation check, so a row captured
at render time can be stale by then. Read it from the store at decision time
and drop it from the effect dependencies, which no longer need to restart the
check when the row appears. Add tests for two separate empty checks and for a
last terminal closed while the check runs.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes — the delta since the prior pullfrog review (553f8a0 → 3fe9970):

  • Decision-time tombstone read — the startup activation gate now derives the closed-last-terminal tombstone inside the gate callback (Object.hasOwn(useAppStore.getState().tabsByWorktree, activeWorktreeId), after reconcileWorktreeTabModel) instead of from a render-captured projection, so a row that appears or disappears while the check runs is seen. The raw store read agrees with the projected controller prop on key presence because createWorktreeTabBucketProjection preserves every source key, and reconcileWorktreeTabModel never creates a tabsByWorktree row from nothing.
  • Dropped the now-unnecessary dependency — activeWorktreeHasTerminalState left the effect's dependency list, since the value is read at decision time rather than captured at render.
  • New regression coverage — a workspace whose last terminal closes during the in-flight check is not seeded, and leaving/returning across two empty checks seeds exactly once.
  • Updated source-text wiring test — pins the live-store Object.hasOwn read; the old assertion that the tombstone derivation stayed in the effect deps was removed to match the intentional dep removal.

I read the full diff and the range-diff, traced the gate outcome vocabulary, and checked the projected-vs-raw tabsByWorktree equivalence plus the reconcile/orphan-cleanup side effects. Ran pnpm test on the three touched files (45 passed) and the full components/terminal/ directory with terminal-parked-watcher-sync-entries.test.tsx (259 passed); app-startup-routing.test.ts:434 still holds.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready

The head, 3fe997047b, is reviewed, tested in both a development and a production build, and green in CI.

Review loops

  • Loop 1 found one gap and fixed it (3fe997047b). The result callback used the "does this workspace have a terminal row" value captured at render. That value can be stale by the time the async check answers, so closing the last terminal during the check could re-seed a workspace the user had emptied. The callback now reads the store at decision time. Two tests were added: two separate 'empty' checks seed once, and a terminal closed during the check does not seed. Each test fails when its fix is removed.
  • Loop 2 was clean.
  • The readiness checklist passed at the start and at the end.
  • Scope is unchanged: the seeding rule, the shared seeding helper and the neighbouring startup-recovery code are untouched.

Electron QA (main's version of the effect vs this PR; isolated profiles; relaunch per trial; before/after screenshots are in the description)

  • Production build: switching away while the check is running, then selecting the workspace again without the sidebar, left main blank 4 of 4 times. This PR created one terminal 4 of 4 times. Startup and ordinary activation already worked on main in production (8 of 8), and still do.
  • Development build: React's double effect run on first mount left main blank on every startup and first-activation trial (13 of 13). This PR created exactly one terminal in all 13.
  • No second terminal: no trial on either build created two terminals.
  • Sidebar, palette and closed terminals: the sidebar and the worktree palette create a terminal on both builds through their own path. A workspace whose last terminal was closed stays empty at startup on both.
  • 'blocked': never occurred for a never-visited local workspace, so that path is covered by unit tests only.

Performance

  • A rerun during an in-flight check reuses the same promise.
  • After the check answers, it reruns only on a real input change. Nothing polls and there is no timer.
  • One slow first appearance, up to 16 s, showed up only in development builds and equally on main. It is the first load of the lazily loaded terminal view, before this effect runs. The check itself answered in 34–130 ms.

Not caused by this PR (noted for follow-up)

  • Clicking a workspace in the sidebar after closing its last terminal brings a terminal back. That is the sidebar's own re-seed.
  • If a structured agent launch is pending when the check answers, the workspace is still marked done. If that launch then fails, it stays empty. Main behaves the same way.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Ready to merge

Checked at head 3fe997047b: mergeable, 18 checks pass, none failing.

  • Scope: matches the plan. The first-terminal seeding in a workspace now marks the workspace only when a decision actually applies. Before, it marked the workspace before its async check, and a cancelled, blocked or superseded check could leave the workspace without a first terminal for good. Nothing outside the seeding effect changes.
  • What a user sees, measured on a production build: switching away from a new workspace during its first-terminal check and coming back without the sidebar (file preview, links, dashboard launch, automation focus restore) left the workspace blank on main 4 of 4 times, and it gets exactly one terminal with this PR, 4 of 4. Ordinary startup and activation already worked on main in production. The blank seen in development builds came from React's double effect run.
  • Review: two loops, the second clean, plus readiness checks at the start and end. The one review fix reads the closed-last-terminal state at decision time, as the plan specified.
  • Expected conflict: fix(terminal): main records every tab close, and seeding reads the records #22955 changes the same seeding line. Whichever lands second swaps in the other's check; both PR bodies describe the swap.
  • Not caused by this PR, noted in the status comment: clicking the sidebar re-seeds a workspace whose last terminal you closed (handled in fix(terminal): main records every tab close, and seeding reads the records #22955), and a failed structured-agent launch leaves the workspace empty, the same as on main.

@brennanb2025
brennanb2025 merged commit 484598b into main Sep 27, 2026
30 checks passed
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