Repository navigation
fix(native-chat): mark a chat whose start failed on its tab and workspace row - #24904
Conversation
…pace row A chat whose start the host refused said so only inside its own tab. Its row now carries the failed verdict the host publishes for a refused start, so the tab and the workspace row show it from elsewhere until a retry starts it or the tab closes. The + menu no longer shows a failed start as still starting, so launching the agent again retries it.
… + menu change for a separate PR The failed-start mark on a chat's tab and workspace row is now dated by when the start failed, the way a failure the host reports is dated: it ages out on the same schedule, a restart does not bring an old mark back, and a failure the user already saw stays read. The failure time is saved with the launch record so a restart keeps it; records saved without it fall back to the tab's creation time. The + menu change (letting a failed agent be picked again) moves to its own PR: its correct form makes a new chat instead of retrying the failed one, which changes how launches join.
Review summaryThe problem. When a native chat can't start (the host refuses to create it), Orca says so only inside that chat, with a Retry line. #24588 removes the extra toast so the failure is reported once. That leaves a gap: if you were on another tab or workspace when it failed (for example a workspace created in the background), nothing outside the chat told you. The chat's tab and its workspace row read their status from the host, and the host has no status for a chat it refused to start. What changes for you. The failed chat's tab, and its workspace in the sidebar, now show the same red "failed" mark Orca already shows when an agent's turn fails, and Activity counts it as unread. Opening the chat marks it read. Retrying it clears the mark (and brings it back if it fails again); closing the tab clears it. There is no new text, toast or banner. What the review changed.
Deferred (not fixed here).
What was verified.
Not verified. In the app, opening a chat that failed while you were elsewhere and seeing it turn read was not exercised (tests and a code read cover it; a chat you watch fail becomes read, as captured); closing the tab, a restart, and the 30-minute fade were covered by tests but not captured; the app was checked on Windows only (the change has no platform code). Native chat runs only in local Git worktrees and folder workspaces, so SSH and paired remote workspaces are not affected and were not tested. The mark is not shown to a paired web client viewing a desktop chat; that gap is older than this PR. |
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Important Review skippedReview was skipped as selected files did not have any reviewable changes. ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (5)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe change records and persists a timestamp when a structured launch fails. When the host has no session status, the bridge uses that timestamp to project a completed failed status. Legacy failed records without a timestamp use the tab creation time. Host census events trigger rechecks for launches with unknown visibility that belong to the returning host. Tests cover status projection, retry and recovery behavior, tab closure, unread state, restart behavior, and stale status aging. The reference table documents the bridge’s failed-start row. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The failed-chat status and host-return recovery changes have no established merge-blocking issue. Merge after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to Automatic recovery can reissue a previously requested chat start when its host returns. It preserves the recorded host and request identity, and cancelled chats are excluded. No introduced security defect was established, but reconnect identity continuity and coordinated merge requirements remain incompletely verified. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
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. Comment |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Failed-start projection.
StructuredAgentSessionStatusBridge.tsxnow reads the launch lifecycle and failure time; when the host has published nothing and the launch isfailed, it writes the host's own failed verdict (done+failure) onto the chat's pane key so the tab, workspace row, dashboard, and Activity mark it failed. - Failure timestamp.
settleStructuredLaunchCallersrecordsfailedAt; the persisted launch record gains an optionalfailedAt; the reload path restores it. The mark survives a restart dated by the failure, and a retry starts a fresh caller group so the time never outlives its failure. - Tests. A new bridge integration test drives a real refused launch through the real store and the real bridge (mark, retry, tab close, re-failure, restart seen/unseen, 30-minute ageing). The persistence test covers the new field plus legacy and malformed records. The two suites pass (12 tests).
Verification. createTestStore composes the production createAgentStatusSlice, so the new test exercises the real setAgentStatus/buildAgentStatusLiveEntry; no production-only guard silently drops the write (allowOlderTimestamp bypasses the only stale rejection, the retirement/recently-closed maps are empty for a mounted tab, and identity suppression requires a different agentType). The projection is gated on !summary?.status, and a refused create leaves no host session, so it cannot overwrite a live host row; a successful retry flips the group to pending/published before any host row appears.
Deferred items are documented by the author (+ menu spinner → #24917; startup-exit case → #24450) and are not regressions from this PR.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
… reachable again A chat create whose reply was lost, or that a reload interrupted, stayed unknown until the user pressed Retry in its tab. Each host subscription opens with one full inventory; on it, every launch that host owns whose outcome is unknown (including ones restored from the saved launch record) is re-checked through the existing Retry path, so it settles to started or failed. One still unknown waits for the host's next return; cancelled launches are skipped.
…ent to it A blank chat that is still starting (a + pick, the empty-workspace default chat) is empty, so the first request with text, such as notes sent to a new agent, now goes into it instead of opening a second chat beside it. That request becomes the chat's own request: an identical repeat joins and is sent once, and any other request opens a new chat. Move the logic that decides which launch a start joins out of the launch registry into its own module, so the registry stays under the line limit once #24904 lands beside it. Pin that a repeat arriving while the opening text is still sending is sent once.
Follow-up: starts Orca couldn't confirmThe problem. A chat start whose result Orca never got — the reply was lost, it timed out, or Orca reloaded mid-start — stayed "Chat connection could not be confirmed." until you opened that chat and pressed Retry. With the failure toast removed by #24588, a background start in that state showed nothing anywhere outside its tab. What changes for you. When the chat's host can be reached again (Orca starting up, or the connection to a paired server coming back), Orca checks that start again by itself. If the chat was in fact created, it just starts; if the host refuses it, the red failed mark from this PR appears on its tab and workspace row. While it is still unconfirmed there is no mark, which is how the common pattern treats a create it has not heard back about. A chat you closed is never re-created, and a refused start is not asked again until you press Retry. How. The trigger is a signal the app already has: each time a host's session-tab stream opens, it starts with one full inventory. On that event, each unconfirmed launch owned by that host is re-checked once, through the same path Retry uses and with the same operation id, so the host answers idempotently. There is no timer and no retry loop. An unconfirmed start on a host whose connection never dropped is re-checked at the next reconnect or app start (Intended: by the time Orca calls a start unconfirmed, it has already re-checked and re-sent it once). Also in this round. The PR body now says plainly that for Claude, a signed-out Claude or a Claude CLI that quits during startup is not marked yet; #24450 (chats start on the first message) covers that. Codex is covered. Merge order. Merge #24917 first, or together. On its own, this re-check could put a restored failed chat back where a new "Send notes to a new agent" would join it and drop its text; #24917 removes that joining. The two PRs conflict only in one type (keep both new fields). I merged them together locally: lint, 126 test files (1,286 tests) and the web typecheck pass. Review. Round 3 and a final readiness check found no P0–P2 issues. Deferred P3s: a comment that misstates why one ordering is safe, and one test assertion that cannot fail (production behaviour verified by reading the code). Verified / not verified. Captured on macOS (isolated test builds, stand-in Claude, before = base, after =
The body's timing note (one after run re-sent 0.16 s after the lost request, before the reload) is the launch's existing in-session recovery: when a create gets no answer it re-sends the same request once, with the same operation id, in both builds. Not captured: a paired server reconnecting, Codex, and the 30-minute fade. The earlier Windows captures still hold. |
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
87e01669-461c-4c0a-a261-0ab9ca14b16e
📒 Files selected for processing (6)
src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsxsrc/renderer/src/lib/structured-agent-session-launch-unconfirmed-recheck.tssrc/renderer/src/runtime/local-structured-session-tabs-sync.test.tssrc/renderer/src/runtime/local-structured-session-tabs-sync/subscription.tssrc/renderer/src/runtime/web-session-tabs-sync-reconnect-resubscribe.test.tsxsrc/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.
| settleHydration?.() | ||
| } | ||
| }) | ||
| // Each subscription opens with one census: the host is reachable again. Chats it lists were | ||
| // already settled as published above, before any recovery await. | ||
| recheckUnconfirmedStructuredAgentLaunches(toRuntimeExecutionHostId(environmentId)) | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '115,190p' src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts
sed -n '265,315p' src/renderer/src/lib/structured-agent-session-launch.tsRepository: stablyai/orca
Length of output: 3618
🏁 Script executed:
set -e
printf '%s\n' '--- relevant symbols ---'
rg -n -C 5 'recheckUnconfirmedStructuredAgentLaunches|retryStructuredAgentSessionLaunch|recover.*inventory|inventory.*recover|queueAcceptedWebSessionTerminalSnapshot|recordInventory|settle.*Structured|published' src/renderer/src/runtime src/renderer/src/lib/structured-agent-session-launch.ts
printf '%s\n' '--- target outline ---'
ast-grep outline src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts --view compact
printf '%s\n' '--- launch outline ---'
ast-grep outline src/renderer/src/lib/structured-agent-session-launch.ts --view compactRepository: stablyai/orca
Length of output: 42045
🏁 Script executed:
set -e
printf '%s\n' '--- exact definitions ---'
rg -n '^(export )?(async )?function (recheckUnconfirmedStructuredAgentLaunches|applyWebSessionTabsStorePatch|queueAcceptedWebSessionTerminalSnapshot)|recheckUnconfirmedStructuredAgentLaunches|settlesHostMirror|publishedSnapshotCount' src/renderer/src/lib src/renderer/src/runtime/web-session-tabs-sync src/renderer/src/runtime
printf '%s\n' '--- target handler ---'
cat -n src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts | sed -n '1,205p'
printf '%s\n' '--- recheck implementation ---'
files=$(rg -l 'function recheckUnconfirmedStructuredAgentLaunches|recheckUnconfirmedStructuredAgentLaunches' src/renderer/src/lib src/renderer/src/runtime --glob '*.ts' --glob '*.tsx' | head -5)
for f in $files; do
echo "### $f"
rg -n -C 35 'recheckUnconfirmedStructuredAgentLaunches' "$f"
doneRepository: stablyai/orca
Length of output: 31973
🏁 Script executed:
set -e
printf '%s\n' '--- structured launch restart and retry ---'
cat -n src/renderer/src/lib/structured-agent-session-launch.ts | sed -n '115,170p'
cat -n src/renderer/src/lib/structured-agent-session-launch.ts | sed -n '250,282p'
printf '%s\n' '--- launch intent and create callers ---'
rg -n -C 8 'createStructuredAgentSessionLaunchIntent|retryStructuredAgentSessionLaunchIntent|restartStructuredLaunchState|callers.outcome|visibilityUnknown' src/renderer/src/lib/launch-structured-agent-session.ts src/renderer/src/lib/structured-agent-session-launch.ts
printf '%s\n' '--- store patch and mirror settlement ---'
cat -n src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts | sed -n '1,180p'
cat -n src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts | sed -n '90,165p'Repository: stablyai/orca
Length of output: 33227
Run the launch recheck after applying the recovered census.
handleGlobalSessionInventoryEvent starts orphan recovery and calls recheckUnconfirmedStructuredAgentLaunches(...) synchronously. The census snapshot is applied only later in the promise’s .then(...) callback. The recheck can therefore restart a visibility-unknown launch before the listed published session is visible, causing a second reconcileUnknownLaunch attempt.
Suggested fix
}
coordinator.recordInventory(environmentId, visibilityGeneration, inventoryFrame, missing)
+ recheckUnconfirmedStructuredAgentLaunches(toRuntimeExecutionHostId(environmentId))
})
@@
- // Each subscription opens with one census: the host is reachable again. Chats it lists were
- // already settled as published above, before any recovery await.
- recheckUnconfirmedStructuredAgentLaunches(toRuntimeExecutionHostId(environmentId))
}There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Failed-start mark.
StructuredAgentSessionStatusBridge.tsxnow reads the launch lifecycle and failure time; when the host has published no status and the launch isfailed,projectFailedStartwrites the host's own failed verdict (done+mainAgent.outcome: 'failure') onto the chat's pane key, so the tab, workspace row, dashboard and Activity all mark it failed. It is dated byfailedAtwithallowOlderTimestamp: true, so an already-seen failure stays read across a restart and a retry that fails again is unread again. - Failure timestamp.
settleStructuredLaunchCallersrecordsfailedAt; the persisted launch record gains an optional, finite-checkedfailedAt; the registry persists it only onfailed; the reload path restores it; the legacy fallback istab.createdAt. - Unconfirmed-start re-check. New
structured-agent-session-launch-unconfirmed-recheck.tsre-runs the existing Retry path (same operation id) for everyvisibility-unknownlaunch a host owns when that host's subscription opens with its one census:local-structured-session-tabs-sync/subscription.tsfor the local host andweb-session-tabs-sync/global-session-inventory-event.tsfor a paired host. In-memory launches and launches restored from a saved record (found through their tab) are both covered. - Tests. Two new bridge suites (refused start; unconfirmed start), a persistence-
failedAtrow, and a re-check row in each sync suite.
Verification. The 5 touched suites pass (42 tests). The host emits type: 'snapshots' exactly once per session.tabs.subscribeAll subscription (session-tabs-inventory.ts:268), so the re-check is one per host return, not per frame. A launch enumerated twice (in memory and via its tab) is retried once: reconcileUnknownLaunch clears visibilityUnknown synchronously before its first await, and restartStructuredLaunchState sets outcome = 'pending' synchronously. Closing an unconfirmed chat records a cancellation tombstone (shouldRetainStructuredAgentSessionLaunchTab covers visibility-unknown), so the re-check skips it. The paired-path comment holds: acceptPairedHostStructuredSessions runs synchronously at the top of recoverWebSessionTerminalOrphansBeforeApply, before the re-check call. Legacy records without executionHostId load as local, so owner matching still works for pre-paired launches.
The author's documented deferrals (+ menu spinner → #24917; Claude startup-exit / signed-out → #24450) are not regressions from this PR.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
) * fix(native-chat): start a new chat after an earlier start failed A chat whose start the host refused stayed registered under its agent and workspace, so the + menu and new-tab search kept that agent disabled with a spinner, and any later "new chat" for that agent (for example "Send notes to > New agent") restarted the failed chat instead and dropped its own prompt. Only launches in flight now hold the agent/workspace slot that later starts join. The registry keeps every launch by session id and derives the holder, so a failed chat keeps its own Retry while a new start makes a new chat. * test: import the launch status from its own module * test: name the owning host on the launch intents the new tests build * fix(native-chat): a new start never joins an unconfirmed or retried chat A new start for an agent joined a chat whose create answer was lost, or a failed chat whose Retry was in flight, and dropped its own text: the source-control "Fix with AI" action reported success with nothing sent, "Send notes to > New agent" sent nothing, and after a reload the text became an unsent draft in the old chat. Only a new start's own first create now coalesces later starts (double click, two callers at once). Each attempt records whether it is that first create or a Retry/re-check of an existing chat; an unconfirmed or retried blank chat keeps its own Retry and a new start opens a new chat with its text. A resume still re-checks or joins its conversation's launch, since the host refuses a second adoption. * fix(native-chat): a different new request always opens its own chat A new start used to join any blank chat still in its first create or still sending its opening text, so a second "Fix with AI" for another check, or notes sent to a new agent, landed in that first chat. Each first attempt now keeps the request it was started with (its text and whether it is sent or drafted). Only a repeat of that request (a double click, a retried call) joins it, and it shares the text already staged, so the text is sent once. Any other request opens a new chat with its own text. The + menu, new-tab search and send-notes menus disable an agent only when their own pick would join. Resume launches are unchanged. * fix(native-chat): an empty chat still starting takes the first text sent to it A blank chat that is still starting (a + pick, the empty-workspace default chat) is empty, so the first request with text, such as notes sent to a new agent, now goes into it instead of opening a second chat beside it. That request becomes the chat's own request: an identical repeat joins and is sent once, and any other request opens a new chat. Move the logic that decides which launch a start joins out of the launch registry into its own module, so the registry stays under the line limit once #24904 lands beside it. Pin that a repeat arriving while the opening text is still sending is sent once. * test: an empty chat delivers the claiming text the way its request asked * fix(native-chat): only a chat its user has not used yet takes another request's text A blank chat that is still starting was claimable by the first request with text even after its user had sent a message into it or typed into its composer, so notes or a Fix with AI prompt could land in a conversation the user had already started. Emptiness is now read from what the chat holds: nothing in its outbox and no text in its composer draft. A chat its user has used stays theirs, and the request opens a new chat. If the claiming text cannot be saved, the claim is not recorded: the request falls through to a new launch, whose save failure shows on that chat as for any new launch, instead of reporting a failure nothing showed. * fix(notes): notes handed to a send leave the next send until it settles Notes sent to an agent stayed in the notes shelf until their chat delivered them, so a second "Send notes" made while the first new chat was still starting collected the first notes again. With every different request now opening its own chat, those notes reached two chats. When notes or browser annotations are handed to a send (a new agent, a running agent from the menu, or a sidebar agent row), they are held in memory against that send's own delivery result and left out of the next send. Delivered notes are removed as before; a failed, refused or undelivered send releases the hold, so they come back for the next send. The notes menu now builds each scope's prompt from the notes it will send. * test: give the annotation tray fixture its hand-off callback * fix(notes): offer no send when every note is already on its way When every note or annotation in a send is held by an earlier send still in flight, the built prompt is empty. The notes menu already disabled its trigger then, but its New agent rows still started an agent with no text, the annotation Send buttons still opened, and a sidebar agent row could be sent an empty prompt. The New agent rows, the annotation Send buttons and the sidebar send now offer nothing when there is nothing to send. * fix(notes): a new chat's staged prompt decides when its notes leave the shelf Notes sent to a new agent were released back to the shelf as soon as the chat's start failed, while that failed chat kept the same text staged for its own Retry. Re-sending and pressing Retry put the notes in two chats, and Retry alone left delivered notes listed as unsent. For a new chat, the notes now follow the prompt it staged: held while that message is in the chat's outbox, cleared from the shelf when any dispatch (Retry or re-check included) sends it on, and back on the shelf when the chat is closed and its outbox thrown away. A paired server's chat is found once its start settles. Running-agent sends keep their own result. While notes are only on their way, the send button reads "Sending…" rather than "All notes sent". * fix(notes): a new chat's saved message keeps its notes out of another send across a reload The hold that keeps notes sent to a new agent out of the next send lived only in memory. A reload while that chat had not yet sent them put the notes back on the shelf while the chat's saved message still carried their text, so a second "Send notes" sent them twice. The staged message now saves the send keys of the notes it was built from. The shelf treats a note as on its way while any saved message carries its key, read from the saved outboxes on first use and kept current by the outbox's one write funnel. When a message carrying notes is sent on (its own start, a Retry or the re-check), those notes are cleared from their shelf; when it is thrown away with its chat, they come back. Browser annotations sent to a new chat use the same keys while the app runs. Running-agent sends keep their in-memory hold. This replaces the per-message watch and outcome promise from the previous change. * fix(notes): release a gone chat's notes, and clear sent ones in the web client too Two gaps in keeping notes out of another send while a saved chat message carries them: - The web client never installed the clearing that removes notes once a chat sends them on, so after a Retry there the notes stayed listed. It is now installed, once per renderer, by the background services both the desktop and the web client load with App. - A saved message carrying notes was thrown away only by this window's own close. A chat closed from the phone, another window or while Orca was off, or closed here without a known host, kept its notes held for good. A chat the host stops listing (affirmed, and not a launch still starting or failed) now has its queued messages thrown away once that sync lands, as does a close that can name no host, so the notes come back. * Revert "fix(notes): release a gone chat's notes, and clear sent ones in the web client too" This reverts commit ae0b9f3. * Revert "fix(notes): a new chat's saved message keeps its notes out of another send across a reload" This reverts commit b88d0dc. * fix(native-chat): a new chat joins only a re-delivery of the same user action "The same request" was recognised by its content (agent, workspace, trimmed text, sent or drafted), which split one action whose text changes between deliveries ("Fix with AI" re-fetches logs) and merged two different actions that happen to carry the same text. Each user action now mints one request id where it is handled (the click, menu pick, notes send, shortcut, Fix with AI press, quick command; a worktree create uses its creation id; programmatic starts mint once at their entry) and passes it through the launch plan, its verdict and the structured launch options, where it is required. A new start joins a chat only when its first attempt carries the same id: a double click or a caller retrying its own call makes one chat and sends the first delivery's text once; any other action opens its own chat, whatever its text. An empty chat that is still starting is still claimed once by the first action with text, and then belongs to that action's id. Resume launches keep joining their conversation's launch. The + menu and new-tab search no longer grey out an agent while one of its chats starts: every pick is a new action and opens its own chat. The id lives in memory only; nothing reads it after a reload (a restored launch is reached by its session id through Retry, never joined). * fix(native-chat): a new chat with no text reuses an empty one instead of stacking another Two bare "+ > Claude" picks (or new-tab search, the new-agent shortcut, the dashboard) opened two empty chats. A request with no text now focuses an existing empty chat for that agent in that workspace: one still starting (joined in the launch, as a re-delivery is) or one that published and sits idle (its host's journal holds no request, read live from the status feed). Empty also means nothing queued, no composer text or images, and no launch draft its composer has not taken. Failed, unconfirmed, resumed and other-agent/workspace chats are never reused. A request with text still opens its own chat, or claims an empty starting one as before; request-id dedupe is unchanged. * fix(native-chat): a new chat with no text reuses an empty chat only in the split it was opened from Pressing + in the right-hand split focused an empty chat sitting in the left-hand split. The reuse now looks only in the tab group the pick targets (the caller's group, else the workspace's active group, which is where its tab would open); a pick in another split opens a new chat there. Applies to both an empty chat still starting and one that published and sits idle. * fix(native-chat): text sent to a new agent claims an empty starting chat only in its own split Notes sent from the right-hand split could land in, and focus, an empty chat still starting in the left-hand one. A request with text now claims an empty starting chat only in the tab group it opens in, the same rule a request without text follows; elsewhere it opens its own chat there.
…keep both caller-group fields; give the tests' starts their request ids
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Merged
origin/main(#24917, "start a new chat after an earlier start failed") into the branch. The merge touched the launch caller group, registry, reload path, and the two new bridge test suites. - Recombined the caller group with both fields.
structured-agent-session-launch-callers.tsnow carries #24917'sattempt: StructuredLaunchAttemptand this PR'sfailedAt?: number— neither side was dropped. - Kept both sides of the launch state.
structured-agent-session-launch-registry.tsand-reload.tsauto-merged; per-parent diffs confirm #24917's session-id-only registry andcreateStructuredLaunchCallerGroup({ kind: 'retry' })reload survive alongside this PR'sfailedAtpersistence and restore. - Updated the new bridge tests to the request-scoped launch API. Each start now passes a
requestId, kept unique per failed start so #24917's per-request coalescing cannot silently reuse a launch. - Confirmed the re-check still holds under the merged control flow.
reconcileUnknownLaunchclearsvisibilityUnknownsynchronously (structured-agent-session-launch-recovery.ts:133) andrestartStructuredLaunchStatesetsoutcome = 'pending'synchronously, so one re-check per launch per host-return is preserved; a reloadedvisibility-unknownrecord restoresvisibilityUnknown = true(structured-agent-session-launch-reload.ts:52) soretryStructuredAgentSessionLaunch's guard admits it and reuses the savedclientOperationId(idempotent re-check).
Verification. pnpm tc:web passes; the five touched suites pass (42 tests). The author's documented deferrals (+ menu spinner → #24917; signed-out / startup-exit Claude → #24450) remain non-regressions.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…2025/chat-failed-start-mark
…-history StructuredAgentSessionStatusBridge: main's failed-start row (#24904) and this PR's batched status projection are both kept. A chat with no summary drops any queued projection for its pane before it shows the failed start or clears the row, so a queued row cannot land after it. Main's failed-start test now waits for the tick its batched row lands in. The status derivation's inputs changed only in comments; the golden digest is unchanged, so the rules stay at 3.
…ative chat's does The failed-start row #24904 writes carried no native-chat provenance, so its Failed faded from the tab and card after 30 minutes while every other native chat's Failed stays until the chat changes. It now carries the same marker ('held': no host runs its agent), and past the freshness window it sits in the card's retained tier like the rest.

ELI5
If a native chat can't start while you are looking at another tab or workspace, Orca now marks that chat's tab, and its workspace in the sidebar, with the same red "failed" mark it already uses when an agent's turn fails. Before, nothing outside the chat told you.
A start Orca could not confirm (its reply was lost, it timed out, or the app reloaded mid-start) is now checked again automatically when its host can be reached again, instead of waiting until you press Retry in that chat. It then ends as started, or as failed with the same mark.
For Claude, this does not yet cover the most common cases: a signed-out Claude, or a Claude CLI that quits during startup (for example one that is too old or crashes). Those are still reported only inside the chat until #24450 makes a chat start its agent on the first message. Codex is covered: when it quits during startup, its chat's start fails and gets the mark.
Follow-up to #24588. Merge after #24917 (or together with it): see "Merge order" below.
What Changed
The problem. A native (structured) chat starts by asking the host to create its session. When that create is refused (for example the agent cannot be started at all because it is not installed or not runnable, the host refuses the request, or the prompt could not be saved locally), the chat says so inside its own tab: "Chat could not be started" with a Retry button. #24588 (now merged) removed the toast that also said it, so the failure is reported once, in the chat. That leaves a gap: if you were not looking at the chat, nothing outside it tells you it failed. That happens when a workspace is created in the background (the chat opens in a tab you never see, and a green "Worktree ready" toast even arrives first), or when you switch away during a slow start.
Why nothing showed: the tab's status mark and the sidebar's workspace status read the chat's status row, and that row comes from the host. A refused create leaves the host with no status to publish for that chat (the agent never ran), so no row appears, and the failure was invisible to every status surface.
Not in this PR: after a failed start, the + menu keeps that agent disabled with a spinner, as if it were still starting. Re-enabling it is not a one-line fix. Picking the agent would then join the failed launch and retry it, and any prompt carried by the pick (for example notes sent to a new agent) would be silently dropped. The fix needs a new chat to stop joining a failed one, which changes how launches are shared, so it has its own PR: #24917.
Before / after (what you see)
No new text, toast, banner or status line: the mark is the existing failed verdict on the existing surfaces.
Which failures get the mark today: any start whose create fails, which covers a Codex that quits during startup, an agent that cannot be run at all (not installed or not runnable), the host refusing the request, and a prompt that could not be saved locally. Not yet: a Claude that is signed out or quits during startup (see "What this does not cover yet").
The mechanism.
failed, and it is persisted so it survives a restart. The status bridge (the renderer component that turns each chat's host status into its tab and sidebar row) now reads that lifecycle too. When the host has published nothing for the chat and its launch failed, the bridge writes the chat's row as the failed verdict the host itself publishes when an agent's start refuses a message: finished, with the main agent's outcomefailure. The tab mark, the workspace status, the dashboard rows and the Activity feed already render that verdict as failed.pending, and the bridge removes the row. When the chat starts, the host's own status takes over: none until the first message, as for any new chat. Closing the tab unmounts the bridge's projection, which already removes the row.docs/reference/agent-status-store.mdgains one row in its list of renderer status writers: this one stays, because a refused create leaves the host no session to publish from.Why
What this does not cover yet
Merge order
Merge this after #24917. On its own, the automatic re-check here loads a launch saved before a restart back into memory. If that re-check ends refused, today's launch code would let the next new request for that agent and workspace (for example "Send notes to > New agent") join and retry the old chat and drop its text. #24917 removes exactly that joining, and with it this case. The two PRs also touch the same per-attempt record (
failedAthere,attemptthere), so whichever merges second keeps both fields.Differences from the common pattern
How an unconfirmed start is resolved. The common pattern re-reads the host's truth when the connection comes back and shows no row mark for a create it has not confirmed. Here too: Orca re-checks on the host's reconnect and shows no mark until the answer arrives. Intended difference in reach: an unconfirmed start on a host whose connection never dropped is re-checked at the next reconnect or app start, not immediately, because by the time Orca calls a start unconfirmed it has already re-checked it and re-sent the same create once during that start.
Where the failed state lives. In the common pattern the host keeps the thread record and marks it failed; the client derives the row mark from it. Here the host refused the create, so the fact lives on the client's launch record, and the client derives the row from it. Intended: the host never ran the agent and has no status for it; some failures (a prompt that could not be saved locally) never reach the host at all. As a result,
orca worktree psand the mobile app do not list a chat whose create was refused; they never listed it before either.How long the tab and workspace marks last. The common pattern keeps a failed thread's mark until the next start, or until it is read. Here the tab and workspace marks follow Orca's existing display rule for any finished row whose agent is no longer running: they stop showing 30 minutes after the failure. The Activity unread count and the chat's own Retry line stay until you act. Intended: this is exactly how a failed first message's mark already behaves in Orca, so the two kinds of failed start read the same; a longer-lived mark would be a change to that shared rule for every agent row, not to this one.
No OS notification. Intended: desktop notifications for chats are driven by the host's turn completions, and a refused create has none. The unread count and the marks carry it instead.
Linked Issue
Follow-up to #24588 (its review deferred "a start that fails while you are looking elsewhere is reported nowhere outside the chat" and "the + menu shows a failed start as still starting").
Visual Proof
Added: macOS (M4Air), on this PR's final commit. Hidden, isolated test builds on a Mac. Before:
3284b4c70c9(this branch's base on main). After:ba208fdcb58(this PR). A stand-in Claude program was used, never the real one. These cover the automatic re-check of an unconfirmed start, which the Windows run below did not cover.How the states were made: the chat's start request was lost on its way to the app's back end, and the window was then reloaded, the way the app comes back. Nobody pressed Retry. A log of every start request shows which chat each one belonged to.
An unconfirmed start after a reload. Before: 40 s after the reload the chat still says "Chat connection could not be confirmed." with Retry, and no second start request was sent. After: the app re-checked the same chat by itself, with the same request id, and the chat started. A message sent afterwards got the stand-in's reply (right).
No failed mark while a start is unconfirmed or being re-checked. The app's own re-check was held. Seen from another tab, the chat's tab has no red mark and the probe workspace row stays green, in both builds. The after build shows the plain empty chat while the re-check runs (middle). After release it started.
The re-check is refused. Before: the chat stays "Chat connection could not be confirmed.", with no status row and no mark. After: "Chat could not be started." with Retry. The chat's tab has the red mark, and the probe row is red with "Claude Chat - Failed", also seen from another workspace.
How the refusal was made: the re-check request was refused at the back end, the way a host without that request answers. On macOS a Claude program that cannot be run does not refuse a start: Claude runs under a small supervisor, so it shows "Claude stopped before it finished starting." instead. In that case the after build's re-check was accepted, and the chat then showed "Claude stopped before it finished starting. Send your message to try again." with no mark. That is the Claude startup-exit case this PR lists under "What this does not cover yet"; the before build stayed unconfirmed.
Timing note: in this refused run, the after build sent its re-check 0.16 s after the lost request, before the reload. Both requests carried the same session and request id. In the other after runs, the re-check came only after the reload. The end state matched either way.
The earlier Windows run saw an unexplained second start request in the before build. It did not reproduce here: with every request logged by chat, the before build never sent a second request in any case.
No new pop-up, toast or banner appeared in the after build. The "Enjoying Orca?" card in one after capture is Orca's existing star prompt, shown after the first finished chat turn.
Not captured on macOS: a paired server's connection coming back, Codex, and the 30-minute fade.
Earlier: Windows captures (still hold).
Windows, in hidden, isolated test builds. Before:
de77c4b065a(this branch's base on main). After:355fdc0ce2a(this PR). A stand-in Claude program was used, never the real one.How the failure was made: the Claude program the app finds first cannot be run, so the start fails before any process exists. Both builds then show "Chat could not be started." with Retry. A stand-in that starts and then quits right away gives a different line in both builds ("Claude stopped before it finished starting. Send your message to try again.") and no mark in either build, because that chat's create already succeeded.
The failed chat itself. Both builds show the same in-chat line; no pop-up appeared in either. After, the chat's tab and its workspace row also show the red failed mark.
Seen from another tab in the same workspace. Before: the "Claude Chat" tab has no mark. After: it has a red dot.
Seen from another workspace. Before: the demo row is green with nothing under it. After: the demo row is red, with "Claude Chat - Failed" under it.
Activity. Before: "No agent activity matches these filters." After: the failed chats are listed under "FAILED". A chat whose failure you have not looked at keeps its unread state (orange time, "Mark thread as read"). A chat you watched fail is read once you switch to another tab.
Retry clears it (after only). With the cause removed, Retry started the chat in the same tab. The red dot left the tab and the demo row went back to green. The other red row in these shots is a separate chat from the earlier failure check in a third workspace ("probe"), which was not retried.
Known uncovered case (not a regression). An agent that starts and then quits during startup is not covered yet; see "What this does not cover yet". In the after build such a chat shows "Claude stopped before it finished starting. Send your message to try again." Its tab and workspace row have no mark, the same as before this PR.
Unchanged, for the record: in both builds the + menu still shows Claude greyed out with a spinner after a failed start. A separate PR handles that.
Not captured:
Testing
structured-agent-session-status-bridge-failed-start.test.tsx(new) drives a real launch whose create is refused, through the real bridge and test store: the row, the tab and the workspace status read failed; a retry clears the mark and the started chat is left to the host; closing the tab clears it; a retry that fails again is dated by the new failure and is unread again; after a restart a failure already seen stays read and one not yet seen is unread; the mark ages out 30 minutes after the failure, like a host-reported failed chat; a failed row already at that pane key is re-dated.structured-agent-session-status-bridge-unconfirmed-start.test.tsx(new): no mark while a start is unconfirmed; when the host turns out to have had the chat, the re-check settles it as started with no second create; when the host refuses on the re-check it is marked failed and a later event does not ask again; a start interrupted by a reload is restored and re-checked without Retry; a closed chat is never re-created; another host's reconnect does nothing, and a still-unconfirmed result waits for the next reconnect instead of looping.The two tab-stream tests: the re-check runs on a stream's opening inventory only (not on ordinary updates or reads), again after a dropped stream resubscribes, and for a paired server when contact comes back.
structured-agent-session-launch-persistence.test.ts: the failure time survives a reload; records without it still load.Ablations, each piece removed alone: the failed-start projection; failure-dated freshness; the saved failure time; the date check; each hook-up of the re-check (this machine, paired server); the tab scan after a reload; the owning-host check; re-checking only unconfirmed launches. Each makes its test fail.
123 test files (1018 tests) covering the launch modules, the status bridge, the session-tab syncs and everything that mocks them pass. The web typecheck (from scratch, and tc:node for the merge with main), oxlint, the changed-code quality gate and React Doctor are clean.
Platforms: renderer-only logic with no platform or path code, and no wire change. Native chats run in local Git worktrees and folder workspaces, and now also on paired servers; SSH workspaces use the older chat. A refused create on a paired server goes through the same launch record and status bridge, so it is marked the same way, but that case was not tested in the app.
I manually tested these changes locally
Automated tests added/updated, or explained why not below
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)