Skip to content

fix(native-chat): a chat whose start never lands is released after the idle window - #22733

Closed
brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/chat-unproven-start
Closed

brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/chat-unproven-start

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​489 $\color{#cf222e}{\Huge{\mathbf{−}}}$​17 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​472
Prod 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​254 $\color{#cf222e}{\Huge{\mathbf{−}}}$​45 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​209

ELI5

When you send a message to a Claude chat that is still starting and then switch away, Orca keeps the Claude process until you come back. Before this change, a start that never finished and never crashed was kept forever. Now it gets the same 30-minute idle window as any other chat you've left. If the start still hasn't finished by then, Orca stops it and marks the message it was holding as not sent. The chat shows why ("Claude never finished starting, so Orca stopped it. Your message was not sent."), and Retry sends the message again, which starts Claude fresh. More generally, any message Orca first said was on its way and then couldn't deliver now shows the reason. On desktop it also gets Retry. On mobile the reason appears once, while the chat screen is open. Before, it looked sent on desktop and quietly vanished on mobile. A rejected message still blocks the messages sent after it until you press Retry, as it already did for messages refused right away. A follow-up will change that (see "Not changed").

What Changed

Before: Send a message to a Claude chat whose CLI is still starting, then leave the chat. If the CLI hangs before it answers initialize and never exits, Orca keeps that child process until the app quits. The message shows as pending the whole time. On desktop the pane says "still starting" while you are looking at it, but nothing ever ends the start unless you close the tab.

After: The chat is released after the normal 30-minute idle window with no viewer. Releasing it closes the child. That fails the startup gate, so the held message settles as rejected with the reason "Claude never finished starting, so Orca stopped it. Your message was not sent." It never reached Claude, so resending it is safe, and a send to the released chat starts a new Claude process. A slow start that lands inside the window is unaffected: the start landing restarts the window, and so does any journal write.

What the chat shows for that message, and for any send the host answered pending and rejected later (a close, a quit, a relaunch, or a handoff while it was held):

  • Desktop: the reason under the composer, "Message was not sent." with Retry, and the queue stops on that message. Retry sends it under a new id. This holds whether the chat is open when the rejection arrives or is opened later. Before, an open chat kept showing the message as sending, and a reopened chat said "Message delivery is unconfirmed." without the reason.
  • Mobile: if the chat screen is open when the rejection arrives, the same reason appears once, as the short send-error notice. Resending the same text is a new message. The message itself still drops out of the chat. Before, it disappeared with no explanation.

Mechanism: The release clock re-arms whenever hasOwedWork answers true (structured-agent-session-host-lifetime.ts). Until now hasOwedWork counted two things as owed work: a running turn, and "the child is starting and has a pending submission". This PR deletes the second clause. A running turn is still owed work. A pending message is not, because nothing guaranteed the start would ever land, so the clock re-armed forever. No new rule takes its place. The existing close path already settles the held message: eviction stops the child, the Claude adapter retires its dispatch waiters, failClaudeStartupGate rejects the held prompts, and that late settlement lands in the journal before the session is forgotten.

The late rejection in the chat: the desktop outbox now handles a journal update in foldStructuredAgentSessionJournal (structured-agent-session-send-disposition.ts), next to the function that handles a send's own answer. When the journal says a message the outbox still holds was rejected, the fold gives it the same result a rejected send answer gets (rejectedSubmissionDisposition): reason, blocked, and Retry under a new id. So a rejection shows the same way whenever it arrives. If the rejection arrives before the send's own answer, the fold also releases that send, so its late pending answer can't overwrite the rejection. A rejection that was already shown when the send answered is left alone, and a cancelled send is still dropped silently. Mobile has no outbox, so it keeps an in-memory list of the sends the host answered pending. When the stream settles one as rejected, it shows the same notice through settleAwaitedStructuredAgentSessionSends, which uses the same copy rule as an immediate rejection. The wire doesn't change: the reason was already in the submission.

The reason wording: before this PR, every deliberate close of a still-starting child rejected held messages with "The provider stopped before it finished starting: claude stream-json ended before startup completed", which reads as a crash. The Claude adapter's close (finalizeClaudePublishedSession) now fails a still-pending startup gate with a plain reason before it retires the waiters. The reason is decided where Orca decides to stop the child, not by matching text afterwards. A child that exits on its own keeps the diagnostic from its exit (for example "not signed in"). The wording deliberately doesn't say "after 30 idle minutes": the same close also runs when the user closes the chat, when Orca quits, and on a handoff to the terminal, and it is true in every one of those. It also deliberately doesn't say "send again": on desktop the rejected message stays blocked in the queue with Retry beside it, so a fresh send would queue behind it and Retry would then send both.

Why

  • No new mechanism. The release clock already has a 30-minute idle window, and eviction already settles held messages as rejected. The only defect was the clause that kept a hung start out of both, so the fix deletes it rather than adding a startup deadline or a second timer.
  • A startup deadline was rejected on purpose. Structured chat deliberately has no startup deadline: slow machines and slow sign-ins must not fail a start that would have succeeded. The idle window applies only when nobody is viewing the chat, and it renews on any activity, so a viewed start is never cut short.
  • One answer, however it arrives. A rejection used to look different depending on whether it came back with the send or later through the journal. Both paths now go through the same disposition, so they can't disagree. The late path isn't a second copy of the mapping.
  • Rejected, not "unconfirmed". A held prompt is written only after startup lands (see the header comment in claude-structured-session-startup-gate.ts), so a child that never started accepted nothing. The rejection is a proven fact and keeps Retry available.

Differences from the common pattern

  • Matches: the host that runs the provider child owns the idle release. The window is 30 minutes with no viewer, a running turn keeps the child, and any activity starts the window over.
  • Deviation, and the reason for it: the usual idle reaper also exempts a session whose start is still in progress, or that holds a message not yet taken by the provider. It bounds a hung start separately, with a request timeout (about two minutes) on creating the session, after which the create fails with an error. Orca doesn't exempt a start in progress or its held message, so a start that never lands is released after one full unviewed window instead. Orca doesn't use a creation timeout because structured chat deliberately has no startup deadline (decided in fix(claude): open structured chat without a startup deadline, and make Retry start fresh #22364). Slow machines and slow sign-ins must not fail a start that would have succeeded, and any fixed timeout has to pick a number that fails some of them. The idle window only runs while nobody is viewing the chat and restarts on any activity, so it can't cut short a start someone is watching. The cost: a start that hangs while you are viewing it isn't bounded. The pane shows "still starting" and tells you to close the chat to give up.
  • Deviation: the usual reaper also keeps a session with live background work. Orca's hasOwedWork doesn't consider background tasks. This PR leaves that alone because it is a separate question.
  • Deviation: some designs show an error after about two minutes when a sent turn hasn't started, telling the user to stop it. Orca doesn't add that watchdog. The desktop pane already shows "still starting" while you're viewing it.

Not changed

  • On desktop, a rejected message still blocks the ones after it. This is how the desktop queue already treats a message refused right away; this PR only makes a late rejection behave the same way. A follow-up PR will make a rejected message a "not sent" row in the chat history that never blocks later messages, and it will replace this PR's late-rejection handling in the desktop queue. Until then:
    • If you type a new message instead of pressing Retry, the new message waits behind the rejected one, and the reason text goes away. Only "Message was not sent." with Retry stays. Pressing Retry sends the rejected message and then the new one, in that order.
    • If two held messages are both rejected, one Retry sends the first again as a new message. The second is not delivered: Orca answers it with its recorded rejection, and it needs its own Retry.
  • Mobile only reports a late rejection while the chat screen is open. It remembers which sends went pending only while that screen is mounted, so a rejection that lands after you leave the chat or close the app is never announced. The notice is the short send-error banner or toast, shown once. The message drops out of the mobile chat, and mobile doesn't put the text back in the composer, since it has no queue to hold it. You see the reason and type the message again.
  • After a crash, a held message still shows as unconfirmed. If Orca crashes or is force-quit while a chat is still starting and holding a message, the message comes back as "delivery unconfirmed" after the restart, as it does today. A normal quit already records held messages as not sent, with the reason. On quit, Orca stops every chat through the same eviction step (evictHeldStructuredAgentSession, called for each session by evictOwnedStructuredAgentSessions) that the new test in structured-agent-session-starting-release.test.ts exercises through the idle release. I did not run a separate quit-specific test.
  • Mobile doesn't show "still starting" yet. The desktop pane reads the startup phase from the host's status feed, but mobile only uses the per-session chat stream, which doesn't carry the phase. With this change, a hung start on a chat that is no longer open anywhere is released after the 30-minute idle window.
    • What a mobile user can do while a start hangs: the chat tab's action sheet has Close (MobileSessionSheets.tsx, the agent-session action sheet built by closeWithBulkActions). It sends session.tabs.close with reason user. The host closes an agent-session tab through closeStructuredAgentSessionTab, which calls the structured host's close. That is the same eviction this PR relies on, so the child stops and the held message is rejected with the reason above. I traced this in code and did not run it on a device. Leaving the chat without closing it drops mobile's hold, so the 30-minute idle window starts once no other screen has the chat open.

Linked Issue

Follow-up to #22364.

Visual Proof

N/A. No new UI. Existing UI now shows up where it didn't: a message rejected after it went pending gets the existing "Message was not sent." row with the reason and Retry on desktop, and the existing send error on mobile while the chat screen is open, instead of looking sent or disappearing with no explanation. The tests below assert the exact reason text at the outbox hook and at mobile's error callback. I didn't record it in the running app.

Testing

  • Automated tests added/updated

structured-agent-session-starting-release.test.ts runs a real Claude adapter over a fake CLI, uses fake timers, and uses a 5 ms grace in place of 30 minutes:

  • A start that never lands: the chat is kept one tick short of the window. At the window the child is closed, and the pane's subscriber receives the held message as rejected with the exact plain reason. Nothing was written to the CLI.
    • Ablation 1: with the deleted clause restored (main's host-lifetime.ts), the test fails because the session is never released.
    • Ablation 2: with failClaudeStartupGate deleted from the close path, it fails with unknown / provider_closed_before_acknowledgement. That shows the rejection assertion depends on the close path.
    • Ablation 3: with the new plain-reason block deleted from finalizeClaudePublishedSession, it fails and receives the old "claude stream-json ended before startup completed" wording.
  • A slow start that lands just before the window closes: it is not released, the held message is written, and it gets a full window to open its turn.
  • The existing cases still pass: a finished turn is released, and a start that owes nothing is released.
  • Checked by hand, not kept as a test: after the release, reading the journal back from disk shows the held message as rejected with the plain reason, so reopening the chat finds the rejection. The "start landing restarts the window" case goes red only when both renewals are removed (the start-landed restart and the journal-write restart). Either one alone keeps it green.

use-structured-agent-session-outbox-late-rejection.test.tsx (desktop outbox hook):

  • the chat is open when the rejection arrives: the reason is shown, the queue is blocked, nothing is resent, and Retry sends under a new id without retryUnknown;
  • the chat is reopened with the rejection already recorded: same result, and no resend or probe;
  • the rejection arrives before the send's own pending answer: the rejection stays;
  • a rejection already shown when the send answered, then repeated by the journal: handled once, and Retry rotates once;
  • a cancelled send: dropped, with nothing on screen.
  • another message is still sending when the rejection arrives, and its answer comes after: the rejection's reason stays on screen. Before this, that answer cleared the reason and left "Message was not sent." with nothing under it.
    Deleting the late-rejection step from the fold makes the first three fail. Deleting only the in-flight release makes the third fail. The last two pass on main too; they guard against double handling.

use-mobile-structured-agent-session-late-rejection.test.tsx (mobile session hook):

  • a pending send later rejected: the reason is reported exactly once across repeated stream updates, and resending the same text uses a new id;
  • the stream rejects before the send answers: reported once;
  • an immediate rejection repeated by the stream: reported once;
  • a cancelled or accepted send: nothing reported.
    Reverting the mobile change makes the first two fail. Deleting only the check made when a send is registered makes the second fail.

Desktop renderer and shared suites pass (10954 tests). The mobile suite passes (9524 tests) and the mobile app typecheck passes, with mobile dependencies installed locally. pnpm tc, check:code-quality:changed, check:react-doctor:changed and oxlint on the changed files all pass.

pnpm test src/main/native-chat src/main/claude src/main/runtime passes except these, all local-only:

  • the two real-binary Claude suites, which are known to fail locally;
  • one env-inheritance integration case, which fails only because this shell exports an extra API key. It passes with that variable unset.

tsc --noEmit -p config/tsconfig.node.json passes. macOS only. SSH: the release clock and eviction run on the execution host, so a remote host behaves the same way. There is no wire change.

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

  • Author X handle: @BrennanKB5
  • Security: none. Cross-platform: no platform-specific code. Remote SSH: host-owned, no wire change. Mobile: a mobile viewer holds the session the same way desktop does, so a viewed chat is never released, and a late rejection now shows its reason on mobile too, while the chat screen is open. Backward compatibility: nothing new is saved, and there's no wire change. An older client talking to this host just doesn't show the reason, as before. Performance: the journal fold runs on the same updates as before, and mobile's list of pending sends only lasts while the chat is loaded.

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)

…e idle window

The release clock treated a message held for a Claude start as owed work and
re-armed forever, so an unheld start that never answered initialize and never
exited kept its child for the host's lifetime. A pending send is no longer owed
work: after the normal 30-minute idle window the child is closed, which fails
the startup gate and rejects the held message with the reason. A start that
lands inside the window renews it, as before.

@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

  • Release a chat whose start never lands. hasOwedWork in structured-agent-session-host-lifetime.ts drops the providerChildPhase === 'starting' && pendingSubmissions().length > 0 clause, so an unviewed session holding a prompt for a start that never answers initialize is evicted after the 30-minute idle window instead of being kept for the host's lifetime. Eviction closes the child, which rejects the held prompt as rejected (with the startup-failure reason) through the existing close path.
  • Docs updated to match. Comments in both structured-agent-session-host-lifetime.ts and structured-agent-session-release-clock.ts now describe owed work as "a turn running" only.
  • Test rewritten to the new contract. structured-agent-session-starting-release.test.ts now uses fake timers and asserts that at the window's edge the child is closed, nothing is written to the CLI, and the pane's subscriber sees the held message as rejected with the "before it finished starting" reason. The slow-start test now lands init just inside the window and keeps asserting the turn is protected.

I verified the change directly: the new first test fails with the old clause restored (starting-release.test.ts:162), and the whole src/main/native-chat/agent-session-wire/ suite (106 files / 839 tests) passes. providerChildPhase: 'starting' is set only by the Claude adapter, no wire surface changes, and a held session is never touched by the clock (isHeld is re-checked at fire time), so the blast radius matches the described scope.

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

@coderabbitai

coderabbitai Bot commented Sep 24, 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: 67966428-55b9-4cdc-8d4b-ded17954a324

📥 Commits

Reviewing files that changed from the base of the PR and between ff1da39 and db8f4ba.

📒 Files selected for processing (9)
  • mobile/src/session/mobile-structured-agent-session-send.ts
  • mobile/src/session/use-mobile-structured-agent-session-late-rejection.test.tsx
  • mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
  • mobile/src/session/use-mobile-structured-agent-session.ts
  • mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts
  • src/main/claude/claude-structured-session-startup-gate.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-late-rejection.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts
  • src/shared/structured-agent-session-send-disposition.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/claude/claude-structured-session-startup-gate.ts

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


📝 Walkthrough

Walkthrough

The host now treats only an active session turn as owed work. A pending submission during provider startup does not count. When a session closes while startup is pending, the startup gate fails and held writes receive the startup-abandoned rejection reason. Tests verify release at the grace-window boundary when startup does not land and session availability when startup lands before the boundary. Shared send-disposition logic reconciles journal submissions with renderer outbox entries and mobile sends, including late rejections, retries, and cancellations.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to db8f4

The session-release and late-rejection changes are mergeable after normal checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to db8f4

The change affects when an abandoned chat process stops and how unsent messages can be retried. The reviewed paths preserve host ownership of accepted messages and do not show a new access path. Some startup and recovery behavior remains incompletely covered.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The identified effect is on the affected chat session’s child process and message-delivery state across its clients, rather than a demonstrated expansion of submission authority.

Trust Boundaries and Controls

  • observed — Pending or accepted journal submissions remain host-owned in reconciliation. Only rejected submissions enter the late-rejection path; cancellation is excluded from reported rejection notices.

Resilience and Maintainability Implications

  • observed — Mobile settlement requires an accepted or rejected journal state; a merely pending response registers the operation for later settlement instead of declaring it delivered.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 33 functions across 13 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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.
Description check ✅ Passed The description is comprehensive and follows the required structure. It explains the user impact, implementation, rationale, testing, compatibility considerations, and visual-proof status. The linked …
Title check ✅ Passed The title clearly and concisely describes the primary change: releasing a chat when startup never completes after the idle window.
  • 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.

…I failed

Closing a Claude child that had not finished starting rejected its held
messages with "The provider stopped before it finished starting: claude
stream-json ended before startup completed", which reads as a crash. The
deliberate close now fails the startup gate with a plain reason first: Claude
never finished starting, so Orca stopped it; send again to retry. A child that
exits on its own keeps the diagnostic from its exit.

Also imports the subscribe event type from the wire module in the test.

@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

  • A start Orca stops now says Orca stopped it. finalizeClaudePublishedSession in claude-structured-session-close.ts fails a still-pending startup gate with CLAUDE_STARTUP_ABANDONED_REJECTION ("Claude never finished starting, so Orca stopped it. Send again to retry.") before retireClaudeDispatchWaiters, so a prompt held by a chat Orca deliberately releases is rejected with that plain reason instead of the crash-sounding "claude stream-json ended before startup completed". The gate ordering matters: the close-path failure splices the held prompts first, so the later retireClaudeDispatchWaiters call finds nothing to reject.
  • failClaudeStartupGate gained an explicit rejection. The new optional third parameter defaults to providerStartupFailureRejection(error), so the exit path (observeClaudeSessionExit), the startup-fault path, and retireClaudeDispatchWaiters keep their existing wording unchanged.
  • Test pins the new copy. The idle-release case now asserts the held message is rejected with exactly CLAUDE_STARTUP_ABANDONED_REJECTION rather than a substring of the old reason, and the prior ablation coverage confirms the assertion is load-bearing.

This addresses the risk flagged in the previous review — that an intentional idle eviction surfaced the internal "claude stream-json ended before startup completed" detail. A child that exits on its own still keeps its own diagnostic because observeClaudeSessionExit marks the gate failed first.

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

…desktop and mobile

A send the host answered pending could still be rejected later, when the child
holding it was stopped before writing it. The desktop outbox left the entry as
if it were still in flight, or showed "delivery is unconfirmed" after a reload,
and never the reason. Mobile had already cleared the draft, and the message just
disappeared from the chat.

The journal side of the outbox now folds a rejected submission through the same
disposition a rejected send result gets: the reason under the composer, the
queue stopped on that message, and Retry sending it under a new id. The fold
covers a live update, a reopened chat, and a rejection that beats its own send
answer. Cancelled sends still drop silently. Mobile remembers the sends the host
answered pending and reports one the stream later rejects, once, with the same
notice an immediate rejection gets.

@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

  • Late rejection on desktop now gets the same disposition as an immediate one. foldStructuredAgentSessionJournal folds a rejected journal submission through disposeStructuredAgentSessionLateRejection, so a send the host answered pending and later rejected gets the reason under the composer, the queue blocked on it, and Retry under a new id — whether the chat is open, reopened with the rejection already recorded, or the rejection beats its own send answer.
  • The fold releases single-flight when the journal answers the in-flight send. answeredInFlight now also covers the case where the late rejection requeued the in-flight entry, so the still-pending send promise cannot overwrite the rejection.
  • Mobile reports a late rejection once. useMobileStructuredSendOperationReconciliation remembers ids the host answered pending and reports a later stream rejection through settleAwaitedStructuredAgentSessionSends, using the same copy rule as an immediate rejection; cancelled and accepted sends stay silent and a resend is a new message.
  • Shared disposition extracted. rejectedSubmissionDisposition centralizes the reason/block/Retry mapping now used by both the send-result path and the new late path; reconcileStructuredAgentSessionOutbox is called from inside the fold.

I read the full diff end-to-end and traced the new fold against the inline logic it replaces. answeredInFlight and clearsError are supersets/equivalents of the old admittedInFlight and error-clearing branches, and the single-flight guarantee (at most one live send promise) makes the multi-entry requeue orderings unreachable. No new issues.

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

…ejection's reason on screen

A journal rejection can block an earlier entry while a later send is still in flight. That
send's clean answer preserved the block but cleared the error, leaving "Message was not sent."
with no reason. A clean answer that leaves another entry blocked now keeps that entry's reason.

Co-Authored-By: Claude <noreply@anthropic.com>

@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

  • A clean send answer no longer wipes another entry's rejection reason. applyDisposition in use-structured-agent-session-outbox.ts now only calls setError when the result itself carries an error, so a pending/accepted answer for one in-flight entry leaves the reason recorded for a still-blocked earlier entry (blockedClientMessageId unchanged) on screen instead of replacing it with an empty "Message was not sent." row.
  • The guard is load-bearing and covered. The new case in use-structured-agent-session-outbox-late-rejection.test.tsx sends a second message while the first is held, rejects the first through the journal, then lets the second answer pending; it asserts both the reason and the blocked id survive. I ran the file — 6/6 pass — and the assertion would go red against the previous unconditional setError(disposition.error).

The existing answeredInFlight generation guard only neutralizes the promise for the rejected entry, so it could not cover this case: here the in-flight id is a different entry, and its clean answer is applied. The fix belongs at that application point, which is the single owner of the error state, so the two rejection paths cannot disagree.

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

…er to send again

On desktop the rejected message stays blocked in the queue, so a new send
queues behind it and Retry then sends both. State what happened instead;
desktop offers Retry beside it and mobile resends.

@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 critical issues — minor suggestions inline.

Reviewed changes

  • Reworded the abandoned-start reason. CLAUDE_STARTUP_ABANDONED_REJECTION no longer says "Send again to retry"; it now says "Your message was not sent." The desktop and mobile test fixtures that hardcode the string were updated to match, and no "Send again to retry" references remain in the tree.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏


/** Why a held prompt was rejected when Orca itself stopped a child still starting. */
export const CLAUDE_STARTUP_ABANDONED_REJECTION =
'Claude never finished starting, so Orca stopped it. Your message was not sent.'

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.

The reason now ends with "Your message was not sent.", but on desktop that sentence is already the label rendered beside Retry (NativeChatDeliveryRetry shows Message was not sent.), so an abandoned start reads as "not sent" twice in the same stack. Mobile does need the clause because it shows the reason alone — worth deciding which surface should carry it.

Technical details
# Desktop renders the reason and the generic label side by side

## Affected sites
- `src/main/claude/claude-structured-session-startup-gate.ts:149` — the reason now ends with "Your message was not sent."
- `src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx:33-36` — renders the generic `Message was not sent.` label plus Retry whenever an entry is blocked (`admitStructuredAgentSessionOutboxEntry` returns `blocked` for the entry named by `blockedClientMessageId`, including a requeued one).
- `src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx:71-75` — renders `controller.error` (this reason) in red, in the same stack.
- `mobile/src/session/use-mobile-structured-send-operation-reconciliation.ts` — mobile surfaces the reason alone through `onSendError`, with no label or Retry.

## Required outcome
- Desktop should not state "message was not sent" twice in the same error stack; mobile must keep a self-contained statement because it has no label/Retry.

## Open questions for the human
- Should the desktop label drop its copy when a reason is present, or should the shared constant stop carrying the clause (accepting that mobile then reads only "Claude never finished starting, so Orca stopped it.")?

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Hands-on check in the running app (commit db8f4ba)

I ran this branch as a hidden dev build with a fresh profile and a scratch repo. A small wrapper stood in for the Claude command line so I could make it hang, start slowly, or fail on purpose. To make the test practical, I shortened the 30-minute idle window to 45 seconds in my local copy only. That change was never committed. Everything else is the branch as pushed.

The main case does not happen for a desktop user yet

You send a message while Claude is still starting, then switch to another worktree or tab. With the branch as is, the starting chat is never released. Claude was still running and the message was still waiting well over twice the window later. Main behaves the same way, so in this situation the branch doesn't change what a desktop user sees.

Before leaving, and after coming back 100 seconds later (still starting):

before leaving
after 100 s away

Two things keep the chat held, and I confirmed both inside the running app:

  1. On purpose, in the desktop chat. While the desktop has a message it hasn't delivered, the chat keeps listening to the session even when it is hidden (use-structured-agent-session-transport.ts). Listening counts as holding the chat open. The waiting message is also saved in local storage, so this survives a reload.
  2. An older leak, not in this PR's files. When the desktop stops listening to a chat, the host never finds out. In src/main/ipc/runtime.ts, the handle used to cancel a listener is thrown away as soon as the listen request finishes setting up, so the later "stop listening" message has nothing to cancel. I watched the desktop send "stop listening" several times; every one of those listeners was still registered on the host minutes later. Even a starting chat with no message was never released after I switched worktrees.

So on desktop, the only things that end a hung start today are closing the chat (the child exited about 1.6 seconds after the close) or quitting.

When nothing holds the chat, the new behavior is right

To test the change itself, I removed the host's record of who holds one hidden chat, using the app's debugger (a test step, not something a user can do). The idle timer, stopping Claude and the chat screen all ran unmodified.

  • This branch: Claude was stopped 45–50 seconds later, in three separate runs. Going back to the chat showed the exact new reason, "Message was not sent." and Retry. It did not say "delivery is unconfirmed", and nothing was re-sent on its own.
  • Main, same test: Claude was still running after 110 seconds (over twice the window), with "still starting" and "Working for 4m 0s".

branch: reason and Retry
main: still starting after 110 s

Two things to look at, both seen only after this test step:

  • Retry failed with "Expected runtime fence 1; the session is at 3." When I came back, the desktop kept the connection it had before Claude was stopped and never opened a new one, so it was still using the old session number. After a page reload the same message went through and Claude answered. A user can't get here today, but if the leak above is fixed so a chat can be released while the desktop still has it open, this will show up.
  • The same message still waiting on the desktop "still starting" line. Viewing a released chat starts Claude again right away. If that start also hangs, "Claude is still starting…" sits directly above the red "never finished starting" reason, which reads oddly.

retry fails with old session number
after reload, the retried message was answered

Typing a new message instead of pressing Retry (the known issue you're fixing separately) behaved as described. The new message waits behind the rejected one, the red reason disappears, and later both are sent in order.

typed instead of Retry

Other checks: all as expected

  • Watching a hung start for 100 seconds: not stopped, still "still starting".

    still starting after 100 s

  • A 20-second slow start, leaving for 25 seconds: delivered and answered ("slow ok").

  • The command line failing on its own: its own reason is shown ("not signed in"), not "Orca stopped it".

    real failure keeps its own reason

  • Main, with the same hang-and-leave steps: still waiting, as expected.

    main still waiting

  • No errors in the app's console in any run.

Mobile (iPhone simulator paired to this dev build)

The mobile app ran this branch's code. With the mobile chat open and the same test step used to release the chat, mobile showed the new reason above the message box, in red, once. I took screenshots every 0.3 seconds, and it appeared in one continuous stretch of about 4 seconds. The waiting message was removed.

mobile pending
mobile reason

Notes:

  • The reason stays up for only 4 seconds (NATIVE_CHAT_SEND_ERROR_HOLD_MS). My first run took screenshots every 5 seconds and missed it completely; the message just looked gone. Someone not watching the screen will likely see the same thing.
  • Things a user can do that would reject the message don't show the reason on mobile. Closing the chat on the desktop removes it from mobile too, so mobile moves to another tab and there is no chat left to show the reason in. Changing the model while the chat is starting doesn't restart it.
  • Resending the same text afterwards failed. Mobile showed "The provider stopped before it finished starting: provider close unproven." I think this was left over from my test step (mobile still thought it was holding the chat), so I'm not calling it a product bug. Two things are worth a look, though: that close path uses a different reason from the new plain one, and I couldn't check that the resend used a new message id.

Not covered: quitting Orca while a message is waiting, and a rejection that arrives while the mobile chat is closed.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing without merging: this is folded into a larger native-chat redesign. QA showed the host change works once nothing holds the chat, but on desktop an open chat keeps holding it. Reading a hidden chat with an unsent message counts as holding, so the hung start is never released. There is also a second problem: after a release, Retry hits a fence mismatch until reload. Fixing those means changing what keeps a chat's agent alive, which is the redesign's scope. This PR's host change, its test and its 'Claude never finished starting, so Orca stopped it' message move into that work.

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