mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(worktrees): close an idle structured chat on delete instead of refusing `worktree rm` refused whenever any structured chat session was attached to the workspace, so an idle Codex/Claude chat that had already answered was harder to delete than a terminal actively running the same agent. The PTY sweep stops every terminal it owns and refuses only for the ones whose exit it could not verify. The structured sweep refused on `live` alone and never attempted the close, which ran only under force. `live` is lease state — a provider child is attached — not work in flight, so it was never the right proxy for "you would lose something". Close first, refuse only on what did not settle. The refusal now means the same thing the unverified-PTY one does, so the toast takes that wording. * fix(worktrees): fence, bound and word the structured-session sweep Review follow-ups on the close-first structured sweep. The close-first direction is unchanged; four things it got wrong are not. Host fence. `listLiveStructuredSessionsForWorktree` matched on `location.workspaceId` alone, and a `repoId::path` id names a DIFFERENT workspace on every host (STA-4343). Once the sweep started closing rather than refusing, deleting a local workspace could close a live chat on an SSH or paired-runtime copy of the same id. It now takes the same two host fields the PTY sweeps already fence on, compared against the session's own `location.executionHostId`; neither field set means this machine. Shared budget. The close ran to completion before the first PTY sweep was constructed, and it is serial with a provider round trip per session — so a slow one spent the whole budget and the sweeps then rejected with a timeout for a stop they never attempted. It is now issued first but joined before the verdict, so the agent plane is still asked ahead of the terminal plane while the two share the clock. Timeout wording. The close raced the deadline fail-closed, and that sentinel carries the PTY timeout prefix, which the classifier reads first — so a wedged session close refused in terminal wording and refused identically again under the Force Delete meant to clear it (#11960). A close that ran out of time is now a session the removal could not confirm closed, which is what the refusal already words. Tracked, so a forced removal still waits out the abandoned-sweep grace before deleting files. Verdict fidelity. `closeStructuredAgentSessionChild` re-observes after the close, and that verdict was being discarded — so a session Orca watched stay attached and one it merely could not reach produced the same message, while the toast asserted "could not confirm" for both. `removal.ts` documents flattening those two as the thing not to do. The unclosed sessions now carry their post-close status, the detail uses the shared `still live:` marker, and the toast branches on it like the PTY pair above it. Also: the close takes the enumerated list instead of re-deriving it, so it no longer runs every liveness observation twice or names a session it never touched; and both teardown log lines count structured closes, since closing a chat is now an ordinary outcome of this verb. * fix(worktrees): keep a proven-exited session from refusing removal The structured sweep re-observes after a close that reported `stopped: false`, but folded a proven `exited` into `unverifiable` — so a close that threw past its own observation, or one whose death evidence landed a beat later, refused a delete over a child that is demonstrably gone. That is the defect this sweep exists to remove, and the PTY gate it mirrors never refuses on a proven exit. Take the proof, and run the tab retirement the close skipped when it gave up: a chat tab left behind re-attaches a released session pointing at a workspace that is about to be deleted. * fix(worktrees): name every unclosed structured session, not just the live ones The refusal named only the proven-live subset when any session was live, so a sweep that left one attached and two unconfirmed told the user "1 agent session (claude)" while three were about to be discarded — and dropped the providers of the ones it hid. The PTY sibling may drop everything outside its live list because a fresh inventory PROVED those exited; nothing proves that here, so both groups are counted. The `still live:` marker still leads, so the delete toast keeps showing the stronger warning. Also carries the structured close count through the forced-removal early return: that path skips the per-PTY verdict, not the sweep that already ended a user's chats, so the removal log claimed `structured=0` for chats it had just closed. * fix(worktrees): stop the forced-removal warn asserting a verdict it does not have The structured sweep splits its post-close verdict in two on purpose: "we watched it stay attached" and "we could not confirm it closed" are different things to waive, and `removal.ts` keeps a marker and a matcher together so the delete toast can tell them apart. The force-path warn then appended "still attached" to whichever verdict it got, so a removal forced over a close that merely ran out of time logged that Orca had seen the session running. That line is the only record a forced removal leaves of a child left pointing at a deleted `cwd`, so it is the one place the two must not be flattened. Carry the verdict verbatim, the way the unstopped-PTY warn above already does. * fix(worktrees): report the closes that landed when the sweep budget expires The structured close loop is serial, so the shared sweep budget can expire part-way through it. The timeout fallback was assembled by the caller and could only name the whole list: sessions this removal had already closed were reported as unclosed, named in the refusal the user reads, and logged as `structured=0`. The loop now records progress into a structure the timeout path reads, so both the refusal and the count say only what was observed. A session with no recorded outcome reports `unverifiable` — the same verdict as an attempted close that stayed unproven, because "never asked" and "asked, unconfirmed" are both exactly "not observed exited", and neither may claim `live`. The loop also checks the deadline before each close, so one slow provider round trip no longer starves every session behind it. It stops ISSUING closes; an in-flight one is left to finish, since nothing here can cancel a round trip. The structured host fence now reuses the PTY fence's own type instead of a look-alike that read `undefined` as local while the other read it as match-all, with both claiming the same precedence. `null` means this machine on both sides; ABSENT stays narrowed to local here, documented and pinned, because a single-host-id comparison cannot express match-all. Also pins a tradeoff that was accepted rather than wanted: the PTY sweeps run concurrently with the structured close, so a removal that refuses over a stuck session has already killed that workspace's terminals. * fix(worktrees): put the chat tab back when a structured close does not land `closeStructuredAgentSessionChild` hides the session's chat tab before it issues the close, so every failure past that point left a refused delete having still taken the tab out of the durable restore index. The conversation survived under `userData`, but nothing brought the tab back at the next launch. Both failure shapes now roll the hide back: `host.close` throwing, and the post-close observation coming back not-`exited`. The restore is gated on the visibility read taken BEFORE the hide, so it never publishes a tab for a session that was already hidden, and on a fresh observation, so it never resurrects one for a child a throwing close still took with it — which is what the worktree sweep reads when it counts such a session closed. It cannot throw out of the function, so the caller's original reason is still what the user is asked to act on. * fix(worktrees): keep the chat-tab rollback out of removals that delete the workspace The rollback added for a refused close ran on every unproven close, including the two shapes of removal that cannot refuse. Force Delete warns and deletes the checkout; a folder-workspace removal never refuses at all. Putting the tab back on those paths leaves a durable restore-index entry for a workspace that is then gone, and the chat republishes at the next launch pointing at it — the outcome this sweep exists to remove. The close now takes `restoreTabOnUnprovenClose`, on by default so `worker-stop` and `worker-release` keep the rollback, and the teardown sweep passes it only when the removal can still refuse. Second hole, same chain: `host.close` can return before the child's exit is recorded, so the close's own observation reads unverifiable and restores the tab, while the sweep's re-read one store write later proves the exit and counts the session closed. The two observations straddle that write and disagree. The sweep now re-drops the tab reference when it takes that proof, and the comment claiming the re-observation alone covers this is corrected. * fix(native-chat): stop a closing chat reading as a conversation that would not load Deleting a workspace now closes the structured chats inside it, and the chat pane outlives that close by a few frames. Every read it makes in that window — `agentSession.history` on refresh, `agentSession.subscribe` on reconnect — resolves through the host's `requireSession`, which refuses with `agent_session_ownership_unknown` for a session it no longer holds. The pane turned that into its terminal error surface, so an ordinary delete flashed `Could not load conversation` over the transcript before the tab retired. That code, raised by a READ, never means the transcript could not be read. It means this host has no session object by that id: one it has just closed, or one it has not attached yet, since the surface's hold is what attaches a session at all. Both windows end on their own. The genuinely latched lease — Orca cannot prove the previous owner exited — reaches the client through the acquisition path instead, so narrowing on the code costs a read no real diagnosis. So the read transport classifies before it reports: an unattached refusal stays on the reconnect loop it is already the subject of, and the pane keeps the transcript it has. It is a window, not a mute. A read still refusing that way past the grace is no longer transitional, and the pane is owed the failure rather than a spinner that never resolves. Every other failure still surfaces immediately, unchanged. The refusal code now has one definition, shared by the host that raises it and the client that narrows on it, so the two cannot drift into a red error nobody meant. Deliberately NOT changed: the order of teardown. The tab is retired after the close proves, not before it, because a close that does not settle has to put the user's chat tab back — the rollback this PR already establishes. Retiring the pane first would unmount it ahead of a close that may be refused, so the pane instead treats a session that has gone as a neutral terminal state. Mobile's structured chat reaches the same reducer but has no reconnect loop, and its hold refusal is what carries the diagnosis there, so the grace does not transfer; it keeps reporting as before. --------- Co-authored-by: Merge Sim <sim@local>