mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fc4867586ef8afa5d692cc0e9a80d8a6962a4151
8981
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fc4867586e |
fix(browser): report creation-order indices and target the newest active claim
Round-five Opus findings (both reviewers converged independently): P1 — 'tab show', 'tab current' and 'tab switch --page' reported the bridge's registration index, which any park+wake permutes (unregister deletes the map key, re-register appends). The listing speaks creation order, so the reported number named a different tab than the listing — and chaining it into '--index' closed the wrong tab. Every index a headless caller sees now comes from the same creation-ordered listing. P1 — the strict parked-active claim let one flag exist per scope, but the scope-less implicit target still scanned broadly and returned the FIRST flagged page in creation order — the oldest scope's page, not the page that actually held the most recent active claim. It now prefers the most recently used flagged page, matching the bridge's global-pointer semantics; scoped queries see at most one flag and are unchanged. P2 — closeTab's promotion gate called getActivePageId, which resolves through resolveActiveTab and reassigns the bridge's active pointers as a side effect (the exact mutation this PR's own comment warns against), and its undefined scope meant 'anywhere' while the promotion is strict. The gate now reads the page book: promote only when the closed page's scope holds no resident page. P2 — an idle/grace window past 2^31-1ms (reachable via the documented 'set it very high' advice, e.g. 1e10) overflowed setTimeout, which Node clamps to 1ms — a permanent spin. The armed delay is now capped at the setTimeout ceiling; re-checking at the horizon is harmless. Invalid knob values also warn instead of falling back silently. P2 — the docs sold the download veto as a guarantee; reworded to what ships: a decision-time veto, with downloads that begin during teardown still cancelled, exactly as if the tab had been closed at that instant. |
||
|
|
b15a2ca6b6 |
test(browser): close the round-five coverage seams
Test-adequacy findings from the fifth review round — no production
changes. The real backend's listOpenPageIds order across park/wake was
only exercised through a test fake; the e2e index spec never asserted the
creation order end to end; nothing pinned that a live active tab
suppresses parked-active promotion on close; blank env knobs relied on an
untested guard (Number('') is 0); and stale ids in a creation order had
no case. Each new test was mutation-checked where a named mutant existed:
listOpenPageIds returning [] and the promotion condition dropped both go
red against exactly their test.
|
||
|
|
9a891b8307 |
fix(browser): keep headless tab order, active flag and env knobs honest
Compat-lens findings from the fourth review round (1 P1, 4 P2): P1 — the merged listing ordered resident tabs before parked ones, so a background park renumbered indices that pre-reclaim moved only on create/close: an index the agent read minutes ago could close a different tab, and a paired client's tab bar reordered whenever a tab parked or woke. Listings now sort by the page book's creation order, which parking cannot change. P2 — closing the page that carried the scope's only active claim (the parked-active holder, or the last live tab) left a worktree whose every open page reported inactive, breaking the one-selected-tab assumption paired clients render on. closeTab now promotes the most recently used parked page when nothing live claims active. P2 — claimParkedActive treated an undefined worktree as 'every worktree', so a worktree-less page (a remote 'tab create' without --worktree) parking while active wiped every other worktree's parked-active flag host-wide. The claim now scopes strictly, undefined matching only undefined. P2 — readPositiveIntEnv used parseInt, which reads '1e9' as 1: an operator following the docs' 'set the idle window very high' advice silently got a 1ms window. Number() consumes the whole string, and trailing garbage now falls back instead of truncating. P2 — the docs' closed enumeration of park vetoes omitted the download and in-flight-wake vetoes; the download one is the guarantee an operator most needs stated, since parking cancels a download. Renderer construction moved to offscreen-browser-renderer-attachment.ts — the backend crossed the max-lines budget, and building + wiring a renderer to its record was a coherent seam to split. |
||
|
|
c7cb075515 |
fix(browser): stop a park from nulling the window a concurrent wake rebuilt
Fourth external review round (2 Opus + 2 Antigravity reviewers), three fixes and four coverage seams: P0 — parkPage resumed after releaseRenderer with a continuation that runs AFTER a wake awaiting the same release promise. The wake re-materializes the window first, so the park's unconditional 'page.window = null' dropped the fresh renderer off the record on every wake-during-park overlap: leaked BrowserWindow (close/destroyAll walk page.window), the page listed parked and live at once, and the wake reporting success against a blank page whose load no-oped on the nulled field. The park now owns only the window it destroyed: it leaves the record alone when the field changed under it. The existing race test drove exactly this interleaving but asserted window identity, not residency; it now asserts both and was red before the fix. P1 — a park that rejected skipped the backoff assignment, so the next reschedule saw a deadline in the past with zero backoff: an instant re-sweep failing the same way forever. Rejections now continue to the backoff like a no-progress sweep. P2 — stop() was not terminal: it cleared sweepInFlight, so a still-running sweep's own re-arm no longer saw itself in flight and scheduled a check after shutdown (destroyAll's window teardown could do the same through the 'destroyed' handler). stop() now sets a terminal flag reschedule honors. Coverage seams the review found (each seam's mutation previously left the whole suite green): the real bridge isActiveBrowserPage (tests only ever stubbed it), the runtime isBrowserPagePinned composition the backend's pin option is wired to, explicit-page close of a parked page not waking it, and the CLI '(parked)' suffix. |
||
|
|
1158930a04 |
fix(browser): count a stream ending as page use so the idle clock restarts then
Streaming pins a page but frames are not activity, so a client that only watched for longer than the idle window had the page parked on the next 30s recheck after closing the pane — not 5 minutes later. Restamp the page's activity when the stream pin lifts (clearPageGate), the same markHeadlessBrowserPageUsed the subscribe path already routes through. At that instant the page is guaranteed resident (the pin vetoed any park, and pin-delete -> touch is synchronous), so the wake is a pure timestamp; a page closed or crashed mid-stream has no record left and the call no-ops. Download and certificate pins deliberately do not get the same treatment: a finished download implies no viewer to come back, and a resolved challenge is always followed by commands that stamp activity themselves. |
||
|
|
2c479aabd7 |
refactor(browser): share one retention budget and drop the reclaim sweep
Both hosts already bounded live browser renderers, but with two separate implementations: the desktop app evicted hidden worktrees' webview guests on a visibility change, and the headless page reclaimer re-evaluated residency every 15 seconds. The rule they were each expressing turned out to be the same one — keep the most recently used, never evict something in use, optionally shed what has gone stale — so it now lives in src/shared/browser-retention-budget.ts and each host supplies its own configuration and its own trigger. The desktop keeps its exact behaviour: rank-only, no clock (idleMs Infinity, graceMs 0), the active worktree left out of the budget entirely, and isEvictable still consulted lazily. Its 13 tests pass unchanged. Headless drops the recurring sweep. A repeating heartbeat was never needed: the limit can only be crossed when a page is created, woken or closed, and every one of those already re-arms. Only the idle window needs a clock, so the reclaimer now computes the earliest moment its own answer could change and arms one timer for it. A host with nothing resident holds no timer at all, and one pinned by a download or a streamed client re-asks on a bounded cadence because releasing those pins raises no event the backend can observe. Two guards fell out of building it. The deadline and the selector are separate functions, so a fuzz test asserts nothing can become evictable before its deadline — it catches an off-by-one in the rank branch. And a sweep that frees nothing now backs off, measured by observed residency rather than by trusting park(), which no-ops for a window that is already gone; without it a deadline that lands early re-arms at 0ms and spins. Headless semantics change in one case: a stale pinned page no longer forces fresher unpinned pages to be evicted in its place. That is the desktop rule, and the limit was already documented as exceedable by pinned work. Removes ORCA_HEADLESS_BROWSER_PARK_SWEEP_MS, which no longer has a meaning. |
||
|
|
85c7a44ee3 |
perf(browser): stop the reclaim sweep once nothing is resident
The sweep only stopped when the last page was closed, so a host whose pages had all been parked kept waking every interval to find nothing to reclaim — on a headless server that is meant to sit idle cheaply. It now stops as soon as nothing is resident, and a wake reschedules it, because a woken page has to become reclaimable again. The timer only ever served the idle evictor. The cap is inherently event-driven — residency changes only on create and wake — but a page going idle produces no event by definition, so something has to look. Polling is the cheaper form of looking here rather than the lazier one: activity is recorded on every command, so per-page deadlines would be rescheduled far more often than the interval fires. |
||
|
|
58e0960598 |
fix(browser): bound how long a wake can hold the request that triggered it
Design review found the one thing that made the "wake transparently" contract untrue in production. A wake awaited the full 30s page-load budget, but a wake happens inside browser.tabShow, which the paired client caps at 15s (remote-browser-page-session.ts, remote-browser-screencast-subscription.ts). So revealing a parked page whose site is slow reported the browser unreachable — the same "flaky remote" symptom the original ticket is about, reintroduced by the fix for it. The asymmetry was mine: createTab deliberately never waits for its load, and returns a tab that is operable and still navigating. A wake now does the same, with a budget comfortably inside the client's, and the load continues after it returns. Fast pages are still fully loaded when the wake resolves, which is what an agent expects; slow ones no longer stall the caller. Also drops the reclaimer's `close` dependency, dead since the retained-page close was removed. Reviewers were unanimous that park-and-wake is the right mechanism, the main process the right owner, and command activity the right signal; both other lenses returned no merge blocker. |
||
|
|
3d50a886c7 |
refactor(browser): match the desktop guest budget instead of inventing policy
Headless reclamation now has exactly the policy surface the desktop guest budget has, and nothing more. Dropped the retained-page limit and its outright close. Desktop destroys guests but never removes a tab, so closing a page past a count had no precedent here and was the one genuinely novel product decision in this change — it permanently destroyed a page id an agent still held. Parked records are ~200 bytes against the 220-480 MB renderers being reclaimed, so retaining them indefinitely is the right trade and matches what desktop already does. Dropped the download veto's progress window with it. Desktop treats an active download as work in progress for as long as it stays active, and headless now does the same. That accepts the same unbounded-veto shape desktop has: a download that never completes keeps its page resident. Deliberate, and the alternative was a threshold desktop does not have. What stays is the part with precedent: a resident cap of 4 (the desktop limit), LRU eviction, and the idle clock headless needs because it has no notion of a visible worktree. |
||
|
|
2b309dfb5a |
revert(browser): drop the park abort; keep the bounded download veto
Round eight. Both reviewers independently found the same defect in the abort I added last commit, and chasing it showed the abort was the wrong trade. releaseRenderer's pending branch discarded the boolean, so a `tab close` that joined an aborted park reported success while the renderer was deliberately left alive — orphaning the window, its guest mapping and its download, and arming a stale destroyed-handler that could unregister a page reusing the id. Two more followed from aborting after a destructive step: onTabClosed has already destroyed the helper session (losing the page's intercept patterns) and already moved the worktree's active pointer to a fallback, and the abort restored neither. So an aborted park left a resident page with reset automation state that implicit commands no longer routed to. All three exist only because the abort backs out halfway through a teardown that is not reversible. The veto is re-checked immediately before teardown starts; what remains is the narrow window during the helper-session await, where a download that begins is cancelled exactly as it would be if the tab were closed at that instant. That is the same decision-time-only guarantee the desktop guest budget gives, and it is now stated in the code. The bounded progress veto — the actual round-seven fix — stays. |
||
|
|
75ff579e51 |
fix(browser): bound the download veto and give up a park that would cancel one
Round seven, from three fresh reviewers. Compatibility came back clean; two of the three findings were real, and both are on the download veto added last commit. The veto was unbounded, which is the same shape as the load-pin bug found in round four: a download whose response never completes keeps its DownloadItem active forever, so the page stayed pinned forever, and one stalled download per page would defeat the resident cap outright. BrowserManager now reports when a page's downloads last advanced rather than a bare boolean, and the reclaimer vetoes only while that progress is recent — the bound lives with the other reclaim thresholds instead of inside the manager. The veto also only covered the sweep's decision. Teardown then awaits the helper session while the page keeps running, and the unregister that follows cancels the page's downloads — so a download started inside that window was cancelled despite the veto. Park now gives up if that happens, leaving the page resident; the helper session is already gone but the next command recreates it. The third finding — `tab current` failing against an all-parked worktree — does not reproduce: resolveActiveTab falls back to activating the first live tab when parking cleared the active pointers. Kept a test for it. Moving the pin predicate onto the reclaimer, where the other reclaim thresholds already live, also puts the backend back under the line cap. |
||
|
|
102df2754b |
test(browser): make the resident-cap spec assert recency, not creation order
CI on Linux caught a flaw in my own test. It assumed the pages created first are the least recently used, but a page's reclaim clock restarts when its load completes, and load completion does not have to follow creation order — under Xvfb it did not, so a different page was resident than the spec named. Touching two pages once up front had the same hole: a page finishing its load after the touch overtakes it. The spec now keeps two pages genuinely in use across the convergence poll, which is what its name claims and what makes the most-recently-used set unambiguous. It asserts those two are the residents, which proves recency is what decides — something creation order never showed. |
||
|
|
98441aeca5 |
fix(browser): merge the duplicated type import in the reclaimer
The focused code-quality plugin audit runs with --deny-warnings across the whole repo, so a single import(no-duplicates) warning in the new file failed CI. The changed-code gate did not surface it, so re-run `pnpm lint` rather than the changed gate alone before pushing. |
||
|
|
712e30aaac |
fix(browser): never park a page mid-download, and split the reclaim sweep out
CI was red on one thing: offscreen-browser-backend.ts had grown past the 300-line cap. The bare `oxlint` CI runs enforces it; the changed-code gate and the max-lines ratchet I had been running locally do not — the ratchet only catches new suppressions, not new overruns. The sweep is now its own module, which is the right seam anyway: deciding which pages keep a renderer is a separate job from owning the renderers. Comparing against the desktop guest budget also turned up a real defect. That budget vetoes evicting a worktree whose pages are still writing a download, because browser-manager cancels a page's in-flight downloads when its guest unregisters. The headless reclaimer unregisters guests the same way and had no such veto, so parking a page mid-download silently cancelled it — reachable through `orca download` on any headless host. Mirrored the veto, with a mutation-checked regression test. The two mechanisms now reference each other so the next person finds both. |
||
|
|
59a3c5138b |
fix(browser): stop an abandoned wake from clearing a replacement page's lock
Round five. Lifecycle came back clean; one finding left. A wake cleared its entry from the waking map unconditionally. An abandoned wake can outlive a close and the replacement page's own wake, so that cleanup could drop the replacement's lock — and the next command would then see a live window with no wake in flight and drive a renderer still being rebuilt, defeating the coalescing fix from the round-three pass. Clear the entry only when it is still the one this wake installed, matching how releases already do it. Mutation-checked. |
||
|
|
a1c9853e41 |
fix(browser): stop a stalled navigation from defeating the resident cap
Round four. Compatibility came back clean; two real findings remain, and the first is a regression the round-three fix introduced. Pinning a page while `webContents.isLoading()` was meant to protect a slow navigation past the load helper's timeout. But a navigation can stay running forever — a server that accepts the request and never finishes it — so that pin had no bound: one stalled page per create would hold a renderer indefinitely, which is exactly the failure this reclaimer exists to prevent. The pin is gone. `loading` still covers the load helper's own bounded window, so a page is never parked mid-create; past that timeout it is deliberately reclaimable, and waking retries the address. The test now asserts that directly, so the cap cannot be quietly re-broken. A wake also validated a page that had been closed and replaced underneath it: `createTab` waits for an in-flight release but not for an in-flight wake, so the stale wake resolved true and handed the original command a different page under the name it had asked for. The wake now checks record identity, not presence, after every await. Both mutation-checked. |
||
|
|
95c303bf5f |
fix(browser): make a second wake wait for the first to finish rebuilding
A wake materializes the window before it swaps the helper session and reloads the retained address, so the page reads as resident well before it is usable. The coalescing promise was consulted after that resident check, so a second command targeting the same page returned immediately and ran against a blank renderer whose session was still being torn down underneath it. Join the in-flight wake first. Mutation-checked: restoring the old order turns the new test red. |
||
|
|
de4fef9c5a |
fix(browser): close the round-three parked-page reporting and reuse gaps
- Reusing a page id while its close was still tearing down let the new renderer register and the old close then unregister it, leaving a page the runtime owned but no command could resolve. A create now waits out an in-flight release for that id, which orders the whole reuse. - Closing a parked page told nobody: it has no WebContents teardown for the session-tabs publication to piggyback on, so a paired client kept showing a ghost tab until an operation against it failed. - A page waiting on a certificate decision was parkable. Its challenge id dies with the renderer, so parking discarded both the warning and the ability to approve it. Such a page is work waiting on an answer and stays resident. - The load helper resolves on its own 30s timeout, so `loading` stopped covering a navigation that was merely slow. The renderer is asked directly. - An iframe changing its own hash fires did-navigate-in-page too, and its address was being recorded as the tab's — waking would have landed the page on the iframe's document. Only main-frame navigations move the record. - `tab switch --index` reported the position the wake had renumbered the target to, which described neither the request nor the listing the caller read. Each mutation-checked. An ownership guard added alongside the reuse fix turned out to be unreachable once creates are ordered, so it is not here. |
||
|
|
107f0b957b |
fix(browser): stop a stale sweep decision from reclaiming a page back in use
Round two, lifecycle. The sweep picks its victims once and then parks them one at a time, awaiting each helper-session teardown. That await is long enough for a page later in the list to be woken and driven, and the selection was being treated as a licence rather than a proposal: - A page used while an earlier park was in flight was parked anyway, destroying the renderer and session underneath a running command. - The retention close had the same shape and was worse: it ignored pins entirely, so a page woken mid-sweep could be deleted outright. - A wake is resident but not yet loading while it awaits the process swap, so it was neither pinned nor loading — a concurrent sweep could park a page in the middle of rebuilding it. Each page is now re-checked against live state immediately before its renderer goes, and a wake in progress pins the page for its duration. Also makes `tab current` count as using the page it names. Every other page-targeting command refreshes the reclaim clock; this one resolved the active id directly and skipped it, so an agent polling `tab current` could have that very page reclaimed under it. All four mutation-checked. |
||
|
|
e7d9e26c31 |
fix(browser): roll back a failed page create and stop closing by index from waking
Round two, resources. Two defects: - createTab added the page record before materializing it, so a window that failed to construct or register left the record behind — occupying the retention budget, listed as parked, and making a retry with the same id fail as "already exists" — without ever installing the crash handler that would have cleaned it up. The handler lives inside materialize, after the operations that can throw, so nothing else would have. materialize now destroys a window it cannot finish attaching rather than orphaning a hidden renderer, and createTab drops the record. - `tab close --index` on a parked page went through the shared index resolver, which wakes unconditionally so that switching counts as using a page. For a close that is pure churn: it rebuilds a renderer, tears down and recreates the helper session, starts a navigation, and then destroys all of it — and a wake that failed would turn a reclaimable page into an unclosable one. The resolver takes a no-wake mode and close uses it. Both mutation-checked. |
||
|
|
77a4c3693c |
fix(browser): correct what a parked page reports and which one a page-less command means
Round-two review of the merged listing. Four defects, all in what parking projects rather than in the reclamation itself: - A page-less command picked the most recently used parked page. Recency is not activeness: an explicit `--page b` makes b the most recently used while a is still the active tab, and a page-less command has always meant "the active tab". The retained active page now wins, with recency only breaking the tie. - Parking the active page promotes another live tab to active, which then parks claiming the flag too, so a listing could report two active tabs. Only the newest claim holds it, and the merge enforces at most one either way. - A parked page reported no load failure, showing a page that could not load as healthy at its address. The failure becomes unreadable once the guest is unregistered, so it is captured onto the record. Certificate failures are deliberately not carried: their challenge id dies with the renderer, so surfacing one would offer an action that cannot work. - Parking sniffed the committed address, which cannot tell an intentional navigation to about:blank from the blank page a failed load left behind. The record now follows did-navigate, so waking returns the page to where it actually is; chrome-error addresses are the failure, not a destination, and never replace the address that produced them. Each has a mutation-checked regression test. |
||
|
|
9f77906067 |
refactor(browser): split the headless page book out of the offscreen backend
The backend crossed the 300-line cap, and a max-lines suppression is not an option here. The split follows the distinction the fix is built on: a headless page and the renderer behind it have different lifetimes. OffscreenBrowserOpenPages is the book of open pages; the backend owns renderers. "Parked" is exactly a record in the book with no window attached, so the queries the runtime needs — which pages are parked, which is most recently used — belong with the records rather than threaded through renderer bookkeeping. Also fixes an existing spec my first commit broke: the implicit-target activity touch calls getActivePageId, which that test's partial bridge stub did not have. Caught here because a background full-suite run launched mid-edit had raced the files it was reading — the pass it reported was not of this tree. |
||
|
|
2bba8e17f5 |
fix(browser): close the reclaim gaps three independent reviewers found
Three fresh gpt-5.6-luna reviewers took lifecycle, resources and compatibility. Two of their high findings were the ones the previous commit already fixed (session snapshot, worktree teardown), which is a useful cross-check. Four were real and new: - `tab switch --index` resolved the index *after* target resolution had already woken the most recently used parked page. Waking makes that page live, which moves it to the front of the listing, so index 0 selected a different tab than the caller had just read. Indices are now resolved before any wake. - An in-flight command was only pinned while the queue was executing it. `enqueueTargetedCommand` awaits `ensureSession` first, and that can spend up to EXEC_TIMEOUT_MS (90s) closing a stale helper session — far past the 30s grace — so a sweep could destroy the renderer mid-setup. A page is now claimed from target resolution through completion. - A renderer lost on its own never told the bridge, so a crash loop leaked one helper session, CDP proxy and listening port per page. It now routes through the same teardown as park and close, and stops the sweep when the last page goes. Reading `win.webContents` in that handler throws once the contents are destroyed, which wedged the whole main process — the id is captured up front. `CdpWsProxy.stop()` also closes its socket even when detaching the debugger throws, which is exactly the case a crashed renderer produces. - Parking a page mid-navigation snapshotted the uncommitted `about:blank` address over the URL the agent asked for, so the wake loaded the wrong page. A loading page is no longer parkable and an uncommitted address is ignored. Also bounds the last unbounded thing: parking caps renderer processes but not the page records behind them, so past a generous retention limit the oldest parked pages are closed outright and logged. Each fix has a regression test that was mutation-checked against the exact defect. The `tab switch` case is covered at the runtime layer rather than e2e because driving a real switch needs the agent-browser binary, which cannot run in the sandboxed e2e HOME. |
||
|
|
ba0c0db15d |
fix(browser): keep parked headless pages visible to paired clients
Self-review found the first fix only taught the CLI's `browser.tabList` about parked pages. The paired-client session snapshot enumerated headless browser tabs straight off the bridge, which lists live WebContents only, so a parked page vanished from the client's tab bar and the next snapshot pruned it — the exact failure the file's own comment says the synthesis exists to prevent. Worktree teardown had the same shape and leaked parked page records. Route all three through one merge (headless-browser-tab-listing.ts): session publication, worktree teardown and `tab list` now agree on what is open. Parking also records whether the page was its worktree's active tab, because tearing the renderer down moves the bridge's active pointer — without that a park silently deselected the tab a user had open. A parked page only claims active when nothing live does, so a listing can never report two. Also guard the reclaim sweep against re-entry: a park waits on the helper session's own bounded teardown, which can outlast the sweep interval. |
||
|
|
c7320525eb |
fix(browser): reclaim idle headless browser tab renderers (STA-4341)
On a headless `orca serve` host every agent-created browser page is backed by
its own hidden BrowserWindow, i.e. its own renderer process, held in a map that
only ever shrank on an explicit `tab close` or process exit. Agents open pages
per measurement and rarely close them, so a long session accumulated renderers
until the host saturated: a reporter measured 9 tabs / 6 renderers at 220-480MB
each, the oldest at 1.3GB after 15h, load 6.85 on 4 cores. A hidden guest also
runs with background throttling disabled, so an animated page kept painting
where nothing could see it.
Reproduced on a real serve process (tests/e2e/headless-serve-browser-tab-reclaim
.spec.ts): 6 tabs -> 6 renderers, still 6 after 20s fully idle, and 2405 rAF
frames in ~25s on a window that is never shown.
Give the offscreen backend a lifecycle owner. It now keeps the page identity an
agent holds — id, address, worktree, profile — and treats the renderer as a
reclaimable resource. An idle page is parked (renderer destroyed, record kept)
and woken by the next command that targets it. Eviction mirrors terminal
hidden-view parking: a resident cap of 4 is the primary evictor, a 5 minute idle
window is the tail, and a page a client streams or that has a command in flight
is never parked. `tab list` keeps listing parked pages so an agent does not read
a reclaimed tab as gone and open yet another one.
Two related defects fall out of the same teardown path. The headless close path
never notified the agent-browser bridge, so every closed page left its helper
session, CDP proxy and listening port behind; park and close now both route
through the bridge before the mapping and the window go away. And because the
listing now spans resident and parked pages, `--index` resolves against that
same listing, so `tab close --index` can no longer address a different tab than
the one the caller read.
Parking discards in-page JavaScript state; cookies and local storage live in the
profile partition and survive. The thresholds are settable via
ORCA_HEADLESS_BROWSER_{RESIDENT_LIMIT,PARK_IDLE_MS,PARK_GRACE_MS,PARK_SWEEP_MS}.
Desktop is untouched — it has no offscreen backend.
|
||
|
|
fcdbcf85d0 | fix(terminal): keep a cold-parked pane's runtime-graph leaf while its PTY lives (STA-2854) (#15514) | ||
|
|
7675da363e | fix(remote): stop paired-tab resurrection, ghost agent rows, and frozen visible panes (STA-4593) (#15459) | ||
|
|
0a853a5c0d |
fix(agent-hooks): stop backgrounded Claude sessions posting a stale pane key (STA-4769) (#15304)
* fix(agent-hooks): stop backgrounded Claude sessions posting a stale pane key (#9236) A session started with `claude --bg` or `/background` runs in a worker under the shared daemon, and that worker inherits the environment of whichever pane first started the daemon — not the pane that dispatched it. ORCA_PANE_KEY there names an unrelated pane, so the session's hooks overwrite that pane's sidebar row, from any worktree. It fails silently and successfully: the script re-sources the endpoint file, so port and token self-heal and the POST lands, while the pane key has no refresh path and stays wrong. Measured on the wire against a throwaway listener: three sessions on one daemon, and the one dispatched from pane B posted pane A's key and pane A's worktree on SessionStart, UserPromptSubmit and Stop. CLAUDE_JOB_DIR is set only in those workers — absent from all 68 live foreground sessions on this machine — so it is the signal to decline. Declining is the only option that exists: normalizeHookPayload rejects an absent paneKey outright and AgentHookEventPayload.paneKey is a required string, so there is no "session with no pane" representation to post instead. A backgrounded session genuinely has no pane; attributing it to nothing is correct. The Windows guard exits rather than jumping to the stdin drain: the drain parks in more.com and a daemon worker is outside an Orca pane, which is exactly the abandoned-stdin hang #11549 guards against. * fix(agent-hooks): guard the Claude statusline against the same stale pane key The statusline command IS invoked inside a backgrounded worker — measured, with no client ever attached, and its ancestry terminates at the daemon rather than at any pane: statusline pid=41746 <- claude bg-spare <- claude bg-pty-host <- claude daemon CLAUDE_JOB_DIR=/tmp/.../jobs/f1f9edd2 A second session dispatched from a different pane saw the first pane's ORCA_PANE_KEY with its own correct session id, so this script is a live second producer of the same misattribution the hook guard closes. Windows uses exit /b 0 before stdin is owned, per the #11549 contract; POSIX places the guard after capture, since exiting mid-write there surfaces as EPIPE the agent can see (#8110). |
||
|
|
15d2e31777 |
ci: bound the shell-contracts apt install so a stalled mirror cannot wedge the run (#15532)
* ci: bound the shell-contracts apt install so a stalled mirror cannot wedge the run shell contracts wedges intermittently. It is not a lock, not a prompt, and not the PR under test - it is download throughput with no wall-clock bound. Measured on a passing run: apt-get update fetched 11.4 MB of index in 40s, then apt-get install fetched 8.9 MB of packages at 65 kB/s taking 2m17s, while the shell-contract tests the job exists to run took 14s. apt applies no wall-clock bound to a stalled mirror, so when throughput drops below that already-poor baseline the step runs indefinitely - observed at 12+ minutes and climbing while every other job had passed. The job also had no timeout-minutes, so it inherited GitHub's 6h default, and an in-progress required check holds the whole run open and blocks rerun --failed. Bounds both layers: timeout-minutes on the job (a passing run is ~4m30s), and Acquire timeouts plus retries in apt.conf.d so a dead mirror fails fast while a transient blip still passes. Written to apt.conf.d rather than onto the command lines because pr-workflow-parallelism.test.mjs parses those invocations. Does not make the job faster; the 3m38s of download is untouched. Scoping the index refresh to just the PPA risks installing against a stale base index and wants its own evidence. * ci: bound the apt commands by wall clock, not per-connection timeouts The first attempt at this set Acquire timeouts of 30s with 3 retries. That made the wedge worse and the job's own timeout-minutes proved it: on this PR the install step ran 14m26s and was killed by the 15 minute bound. The log shows why. Acquire timeouts are per-connection, so a dead mirror costs timeout x retries x every index file: 30s x 3 across roughly ten index files is ~15 minutes, which is what was observed. The azure archive mirror returned Ign for every suite, apt fell back to archive.ubuntu.com, and that connection then produced zero bytes for 14m26s. So per-connection bounds cannot bound this step; only a wall-clock bound can. Wraps both apt invocations in `timeout`, and drops Acquire::Retries to 1 so a dead mirror fails once instead of multiplying. The update is already tolerant by design, so bounding it just caps what a dead mirror costs before the install runs against whatever index exists. Also drops DPkg::Lock::Timeout: no lock contention was ever observed in these logs, and an option added on speculation is not worth carrying. |
||
|
|
174039a14e | fix(workspaces): re-seed a terminal when an emptied workspace is opened (#15513) | ||
|
|
9b8dbd8930 |
fix(mobile): keep a terminal lease alive when the handle lookup fails (#15463)
* fix(mobile): keep a terminal lease alive when the handle lookup fails `waitForTerminal(..., 'exit')` rejects with `terminal_handle_stale` whenever the handle cannot be resolved right now. The loudest source is `record.rendererGraphEpoch !== this.rendererGraphEpoch` — every renderer graph reload invalidates handles issued before it, on panes whose PTY is untouched. All three terminal.subscribe branches wired that rejection straight to teardown (`.catch(() => registration.releaseIfCurrent())`), so the host retired a live lease and emitted `end`. Mobile reads `end` as "PTY gone" and rearms; after MAX_REARM_ATTEMPTS = 3 it stops and leaves the composer on "Waiting for terminal…", recovering only when a replacement handle arrives. A live PTY keeps its handle, so no replacement ever comes: the pane is dead until the app is backgrounded and reopened. Demand proof instead. Retire on abort (this socket is going away) or when isLeafPtyProvenAbsent says the process is gone; a real exit still resolves and retires through the .then leg. Absence must be proven, never inferred from a lookup that failed. Reproduced live: a paired 0.0.44 client over LAN, fault injected on the handle lookup, composer locked on "Waiting for terminal…" and STILL locked 97s after the fault was lifted with the PTY alive and the handle unchanged. Narrows one behavior #14992 pinned: a stale handle alone no longer retires the owner. The ownership property is unchanged — once absence is proven, the owning registration is still what retires. That test is updated, not deleted. * fix(mobile): observe PTY exit independent of stale handles * fix(mobile): stop subscribe setup when an exited pty releases synchronously The runtime doubles for terminal.subscribe never stubbed subscribeToPtyExit, so the new lifetime watcher threw and killed every subscription under test. Also return early when the watcher releases synchronously: cleanup had already run, so the stream handlers, view subscribers, and mobile presence registered afterwards were never torn down. |
||
|
|
a3b3254885 |
fix(browser): grant storage-access so requestStorageAccess() stops rejecting (#15481)
* fix(browser): grant storage-access so cross-site frames can use their cookies AUTO_GRANTED_BROWSER_PERMISSIONS omitted storage-access, and the installed permission handlers deny anything absent from that set, so every document.requestStorageAccess() in the embedded browser was refused silently. Granting it unconditionally looks like a privacy hole, but that objection assumes Orca blocks third-party cookies. It does not: nothing blocks or partitions them and no Chromium switch touches cookie policy, so Electron's default applies. A cross-site frame therefore already reads and writes its unpartitioned cookies at the network layer, and denying the permission grants no protection - it only breaks sites that take the API's failure path. Chrome resolves requestStorageAccess() without a prompt under the same cookie policy; Electron has no such fast path and forwards to the embedder, so the embedder supplies it. The comment records the condition that would invalidate this. top-level-storage-access stays denied. requestStorageAccessFor() is a separate platform decision: Chromium consults Related Website Sets and has no third-party-cookie auto-grant, and Orca has no such data source, so granting it would invent a permissive answer to a restrictive question. A test pins it. Does not fix Google sign-in - that was #15216. Sign-in completes with this denial in place; this is an independent defect found while investigating it. * test(browser): pin the denial notice storage-access used to raise The suite proved the handler answers true but never pinned the symptom users actually reported — the "asked for storage-access, and Orca denied it" notice. The existing notified-list assertion runs before the storage-access request, so a regression that re-denied it would have left that list untouched. Assert the list after the new requests instead; it goes red pre-fix with 'storage-access' present. Drop the two vi.waitFor wrappers around the same requests. The request handler is synchronous for every non-media permission, so the wait bought nothing and imposed vi.waitFor's 1000ms default on a suite that allows 30s. The waitFor guarding the media path is genuinely async and stays. * refactor(browser): tighten the storage-access rationale and cover isolated partitions Trim the grant's comment to the facts that change a reader's decision. The mechanics of how the request reaches the embedder already live in the commit message; what belongs at the call site is why the answer is grant, and why the check handler must agree with the request handler — Electron builds neither Chrome's activation gate nor its auto-grant, so a disagreeing check pushes compliant sites onto the gesture path, where a rejection consumes the gesture. Move the top-level-storage-access note below the set. It sat after the last element with no trailing comma, so it read as a commented-out entry, and any permission appended at the natural insertion point landed above it. Cover the isolated-partition install path. createProfile and hydrateFromPersisted call installBrowserSessionPartitionPolicies separately from the default-partition path the persistence suite drives, and the two previous changes to this set each added a matching isolated test. Verified red without the grant. Rename the anti-detection case that claimed storage-access has a native denied state; it is granted in production now, and the case really pins pass-through. * docs(browser): correct the storage-access rationale The revisit trigger was backwards. If Orca ever blocked third-party cookies the grant would not become dangerous, it would become useless for cookies: Electron builds no HostContentSettingsMap, so no STORAGE_ACCESS content setting is ever written, and IsAllowedByStorageAccessGrant needs one. Point the tripwire at a cookie or storage-partitioning control instead, which is the change that would actually invalidate the reasoning. The premise was also narrower than the grant. Third-party storage partitioning is enabled by default and independent of cookie policy, and the same permission lifts it for localStorage, IndexedDB, CacheStorage and friends via StorageAccessHandle, which gates only on IsFullCookieAccessAllowed. So the frame does not "already have" everything this grants. It stays the right answer because Chrome grants the same permission under the same cookie policy, but the comment should not claim a narrower blast radius than the change has. * docs(browser): give the storage-access tripwire its consequence Say what goes wrong, not just when to look. If Orca ever blocks third-party cookies or gains a partitioning control, three separate gates stay shut in Electron - the network-service grant check, the frame's trusted status, and the STORAGE_ACCESS content setting that is never written - so the promise would resolve while access stayed blocked. Sites follow the documented pattern of reloading after a successful request, and on reload the check handler still reports granted, so no gesture is needed to ask again. That loops. Qualify the non-cookie clause: the no-arg call resolves undefined and touches only cookies. It is the dictionary form that returns a handle, and since the handler sees the permission name and never the call shape, one grant covers both. Also give "check must agree with request" its reason. Justify the isolated-partition test by the precedent it follows rather than by a call-site divergence the shared mock cannot actually distinguish. * docs(browser): scope the non-cookie clause to the handle A live probe pinned down what the grant actually widens. The frame's ambient window.localStorage and window.indexedDB stay partitioned before and after a successful request; the unpartitioned view is reachable only through the handle the dictionary form returns. Existing code in the frame is unaffected unless the site explicitly calls through that handle, so say handle-scoped rather than leaving a reader to assume the globals change. * docs(browser): correct the isolated-test justification and the tripwire scope "the isolated twin every other entry in this set already has" is false. Counting occurrences in browser-session-registry.test.ts: fullscreen, clipboard-read and clipboard-sanitized-write have none. Cite the pointerLock precedent the test actually mirrors, which is the case directly above it. Separate the two gates in the tripwire. The STORAGE_ACCESS content setting gates cookies; the handle path is gated on IsFullCookieAccessAllowed instead, and a live probe confirmed it works today. Saying "access stayed blocked" read as a claim that the handle is backed by nothing, which contradicted the sentence above it. Say cookie access, and say the handle survives. |
||
|
|
0b8107a41f |
fix(browser): stop the auth-host UA write from cancelling redirects (#15221)
* fix(browser): stop the auth-host UA write from cancelling redirects WebContents.setUserAgent() from will-redirect makes Chromium abort the in-flight navigation (ERR_ABORTED) and replay the original request, so any redirect crossing the Google auth-host boundary dies on a blank tab. Route that write through the CDP override, which retargets navigator.userAgent without touching the navigation. Fixes #15216 * fix(browser): recover auth UA override state * fix(browser): keep the viewport UA override on the session identity The auth-host switch writes the Firefox UA through WebContents.setUserAgent on a direct navigation and then moves to the CDP override, and nothing restores that WebContents UA. sendViewportUserAgentOverride read it back as its base identity, so a tab with a viewport preset republished the Firefox UA on every ordinary host afterwards, with sec-ch-ua still saying Chrome. Read the profile's session UA instead, which is the stable base identity at both call sites. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> |
||
|
|
73f7767edd |
feat(sidebar): name the host when a delete batch collides on one id (#15423)
* feat(sidebar): name the host when a delete batch collides on one id Two hosts can publish the same worktreeId, so a batch confirmation showed two rows with identical names and paths and nothing to tell them apart. Label each row with its execution host, but only when the batch actually contains a same-id collision — an unconditional chip is noise. * fix(sidebar): qualify colliding delete targets by saved host * fix(sidebar): preserve delete host collision scope |
||
|
|
4fc8b65792 |
docs: add WeChat group 8 as fallback when group 7 is full (#15466)
Provides alternative community group with QR code when primary group reaches capacity. Updates documentation in both English and Chinese. Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
391385fd5c |
fix(workspace-cleanup): require a host-qualified key for every candidate row (#15422)
The virtualizer keyed rows by bare worktreeId, so two hosts sharing an id collapsed onto one key in the confirm-remove dialog. getRowKey is now a required prop supplied host-qualified by each caller, making omission a compile error rather than a silent fall back to the colliding id. |
||
|
|
41c4d8e8a8 | Update README downloads badge | ||
|
|
90a292eec2 |
Fix WSL stall tests to reset gate state and fake performance timer (#15414)
Reset persistent WSL transcript filesystem gate state in beforeEach to prevent prior test stalls from quarantining subsequent tests. Fake the performance timer used by the route quarantine clock so tests don't block on real time. Update affected tests to wait out the back-off window rather than advancing by zero time. Co-authored-by: m4air <m4air@m4airs-Air.localdomain> |
||
|
|
1ca752a7a4 |
test(e2e): keep the native Hangul reproduction harness (#15438)
* test(e2e): keep the native Hangul reproduction harness This is the spec that reproduced #15299: it drives a real ibus-hangul engine through a real compositor and asserts the bytes reaching the pty. It is the first setup here that can exercise an input method end to end, and three IME issues this week were unreproducible without one. It does not run in CI, and the header says so rather than implying coverage. It needs a compositor session CI does not have, and this repo already carries native IME specs that are skipped everywhere and were mistaken for protection they never gave. The run recipe is in the header so the next person does not rebuild it. Recorded there too are the five things that decide whether a run is real or a silent false negative - nested rather than headless, an unused display, a session script that does not exit, forcing the window visible, and sending Escape before the byte reader starts. Each cost a failed attempt, and four of them are what defeated an earlier try. Keys and expected text are environment-tunable so other IME issues can reuse it unchanged. Refs #15299 * test(e2e): record three more silent-false-negative traps in the native IME harness A Hanja candidate-selection run on the same rig hit all three. Each produced an empty or misleading event log that reads as "the IME ignored the key" rather than as a broken harness, which is the failure mode this header exists to prevent. The panel one is the least obvious: a session whose ibus-daemon runs with --panel=disable never draws a lookup table, so any run that depends on seeing candidates measures nothing while appearing to work. Refs #15299 |
||
|
|
d4460d34f3 |
fix(terminal): stop a Hangul-terminating digit being eaten as a candidate pick (#15429)
* fix(terminal): stop a Hangul-terminating digit being eaten as a candidate pick A digit typed immediately after a Hangul syllable is dropped in the terminal on Wayland. Typing 아1 produces 아. The syllable composes and commits correctly; only the keystroke that ends it is lost. Reproduced on Ubuntu 24.04, GNOME Shell 46, ibus-hangul 1.5.5, in a nested Wayland session with real key injection. The pty receives 아 on Wayland and 아1 under X11 on the same machine with the same engine, and a GTK client in the same Wayland session receives 아1 - so the compositor, the input method and the digit are all behaving. The difference is where the digit is delivered. Under X11 ibus-hangul swallows it into the preedit and commits 아1 as composition text. Under Wayland it commits 아 and lets the digit through as an ordinary key - and the jamo before it arrive with no keydown at all, only keyups. That orphaned keyup is what breaks it. A plain letter keyup with no matching keydown arms a 1500ms window in which a bare digit is treated as a candidate selection, because a Pinyin engine indexes its candidate list by digit. The digit lands microseconds later, inside the window, and is suppressed. The window disarms after exactly one digit, which is why the syllable survives and only the terminating key vanishes. A Hangul engine has no numbered candidates over its preedit - a digit ends the syllable and is literal text - so the guard was spending its one suppression on a keystroke meant for the shell. The tracker now records whether the current preedit is Hangul and the guard declines. Read from compositionupdate rather than compositionend: a Pinyin preedit is the Latin spelling being narrowed while its commit is the Han text, so reading the commit would misclassify Pinyin and reopen the bugs the guard was added for. Space is untouched; only digits are reclassified. Verified A/B/A on that machine: unfixed drops the digit, this change preserves it, reverting drops it again, three runs each. The X11 case and both existing ibus-hangul specs still pass. Closes #15299 * fix(terminal): expire the Hangul preedit flag and narrow its digit exemption Review of the #15299 fix found the Hangul classification could not be retired and that it exempted more than the reported bug. The flag was written only on a non-empty compositionupdate and cleared only on blur, so it latched for the whole focus session. Switching input engine (Hangul -> Pinyin) under fcitx/ibus moves no DOM focus, and the #8241 orphan-digit path emits no composition and no input events, so nothing could refresh or clear it. The stale flag then turned the #8241/#7543 Pinyin candidate-digit guard off silently and permanently - the regression those guards exist to prevent. It now expires against lastCompositionEventAt on the same staleness window as the neighbouring guards, and compositionstart clears it because the following compositionupdate re-reads the preedit script. It is deliberately not cleared on compositionend or on input: the bug is a digit arriving after the commit, and both recordings deliver the commit's own insertText before it. Clearing on either reverts the fix - the orphan-window test fails when input clears it. The exemption also gated suppressCandidateKey as a whole, so it fired during a live composition as well. That is broader than the bug, which is exclusively a digit after compositionend, and it is unsafe: the earlier claim that no Hangul engine indexes candidates by digit over a preedit is wrong. ibus-hangul's Hanja conversion (Hanja key / F9) puts a numbered lookup table over a live Hangul preedit, and its symbol table behaves the same. Only the orphan-keyup guard - which arms off a bare keyup and cannot see which engine produced it - now declines. The tests drove a shape neither recording produces: an empty compositionupdate immediately before compositionend, which armed the 250ms post-composition window. The Wayland reproduction has no empty update, and the X11 fixture follows its empty update with deleteContentBackward + insertText, which disarms that window. The suite now follows the recorded trace, asserts the 250ms window never arms, and covers both the expiry and the live-preedit Hanja case. Refs #15299 |
||
|
|
3ffab9a6b3 |
feat(terminal): read the rendered screen with terminal read --screen (STA-4792) (#15380)
* feat(terminal): read the rendered screen with `terminal read --screen` (STA-4792) `terminal read` returns accumulated pty output with escape sequences stripped. That is the right answer for "what happened over time" and the wrong one for "what is on screen": any program that repaints a line comes back as stacked fragments, so one `clear` typed key by key reads as `cclclecleaclear`, and a prompt that draws a space by moving the cursor loses it. Nothing in the output said which question had been answered, so it was used as rendering evidence and produced false conclusions. The runtime already knew how to render — it replays the byte stream through a headless emulator — but only as a fallback for blank reads, alternate screen, and never-attached ptys. A normal attached terminal never reached it. `--screen` asks for it directly. Every read now reports its source, which also surfaces the pre-existing snapshot fallback that until now swapped rendered lines into an ordinary read with no indication. `screen-unavailable` distinguishes "asked for a screen, none could be rendered, here is the stream" from a stream the caller asked for, and an absent source means the host predates the field. Because an older host strips the unknown param and answers with its ordinary read, `--screen` against one fails with that explanation rather than passing the stream off as a screen. `--screen` and `--cursor` are mutually exclusive: a screen is the current frame and has nothing behind it to page. * refactor(terminal): stamp the screen source where rendered lines enter the read Inferring it from tail array identity worked but made a load-bearing contract out of reference equality; any later path spreading the read would silently mislabel. Rendered lines only enter through one builder, so it stamps there and anything still unlabelled is the stream. |
||
|
|
9b5538d786 | fix(runtime): scope create-with-activate navigation to the requesting client (STA-2802) (#15407) | ||
|
|
12724068b7 |
fix(sidebar): render folder workspaces under every Group by mode (#15362) (#15404)
* fix(sidebar): render folder workspaces under every Group by mode (#15362) Folder-workspace rows were emitted in exactly one place, inside the repo-grouping branch of buildRows, so switching the sidebar to Status, PR status or None dropped them from every lane. Membership is now decided once, above the groupBy switch; a mode only chooses which lane a workspace lands in. Lane assignment is an exhaustive WorktreeGroupBy switch with no default clause, so a future grouping mode is a compile error here rather than a silent regression. Not a regression: the groupBy !== 'repo' gate predates folder workspaces by two weeks (#2866), and #5172 added folder-workspace emission below it, unreachable for non-repo grouping from its first commit. Also fixes four further paths where the same row kind went missing: - Flat mode gated its whole section on worktree count, so an account with only folder workspaces rendered nothing at all. - A collapsed folder-only lane header rendered globally instead of under its host section, because empty worktree lists yield undefined host counts. Host id maps now carry explicit empty arrays so the host fallback cannot leak global worktree ids. - Reveal resolved folder workspaces to project-group header keys only, so revealing one inside a collapsed lane never expanded it. Reveal now reuses the same lane function as grouping, expands the host header, and covers the agent-send path and the host-qualified row lookup. - The Clear Filters empty state ignored folder workspaces, so a folder-only account lost them whenever any filter was active. This one reproduced under Project grouping too. Behaviour deliberately unchanged: archived folder workspaces are still not filtered, matching current behaviour rather than making membership mode-dependent. Pinned folder workspaces still render in their natural lane rather than the Pinned section, now uniformly across modes. * fix(sidebar): count folder workspaces in host badges |
||
|
|
200d8a5738 |
test(relay): stop port-scan fixtures colliding with the worker pid (#15413)
Both port scanners drop any row whose pid is the relay process or its
parent. The tests hardcoded fixture pids, so a vitest worker that happened
to be assigned one of them had its row filtered out and the assertion saw
an empty result.
Reproduced exactly: forcing the worker pid to 2468 fails
windows-port-scan with "expected [] to deeply equal [ { host: '0.0.0.0',
...(2) } ]", the same message seen in CI, where node 26 shard 1 failed
while node 24 passed on the identical SHA. Forcing pid 1000 fails
port-scan-handler the same way.
Pick fixture pids that cannot match the running process instead: the
literals stay 1234/2468/1000 unless the worker owns one, and only then
shift. No production change.
|
||
|
|
6386b540ad |
fix(quick-open): stop node:path from white-screening the renderer (#15405)
* fix(quick-open): stop node:path from white-screening the renderer #15158 pulled `quick-open-filter` into the renderer graph, but the module still used `import { posix, win32 } from 'node:path'`. The bundler stubs `node:path` in the renderer with an object that throws on any member read, and named bindings resolve at module evaluation — so the renderer threw before React mounted. `pnpm dev` white-screened on main. Switch to a namespace import and have `pathFlavor` return the flavor NAME instead of the module, so `node:path` stays untouched until the out-of-root `relative` fallback actually needs it. A namespace import alone is not enough: `pathFlavor` is called unconditionally by `buildExcludePathPrefixes`, so it still threw on any Quick Open with nested-worktree excludes. No behavior change for the main process or the relay. * fix(quick-open): keep exclude containment browser-safe |
||
|
|
fae9282f54 |
test(wsl): account for the route quarantine in stalled-mount tests (#15408)
* test(wsl): account for the route quarantine in stalled-mount tests #15381 added a route-level quarantine: a timed-out task blocks new admissions on that route for a back-off window, so the next real task probes recovery instead of hammering a hung mount. It merged with four tests already failing on main, and they still fail. Two causes, both test-side: - The recovery half of three tests read again immediately after releasing the stall, which the quarantine now refuses. They wait out the window, which is what a real caller does — resetting the gate would skip the admission the recovery assertions exist to prove. - `blockedRoutes` is module state that no test cleared, so a stall in one test refused an unrelated read in the next. Both files now reset the gate in beforeEach. Production behavior is unchanged and is working as designed: eviction still happens, only the immediate re-read is deferred by the back-off. * test(wsl): match the sibling suites' quarantine idiom #15381 applied this same fix to session-scanner-core-parser-wsl-stall, session-scanner-discovery-wsl-gate, and opencode-usage/scanner-wsl-gate, and missed these two files. Adopt that established shape rather than a parallel one: fold the back-off wait into releaseAndSettle instead of a separate helper at each call site, and fake performance explicitly, since performance.now drives the quarantine clock. The explicit toFake is not strictly required today — vitest's default already fakes performance — but it documents the dependency and stops a default change from silently unfaking the quarantine clock. |
||
|
|
c228030516 |
fix(tabs): keep an open diff focused while agents stream (STA-4697) (#15390)
* refactor(tabs): put the visible-tab-type projection in one place Three copies of toVisibleTabType had drifted: the runtime one omits 'simulator'. Move the canonical projection next to the two unions it maps between and replace the two copies that are already identical to it. The runtime copy is left alone on purpose - unifying it would change behavior, so it goes with the follow-up. * fix(tabs): keep an open diff focused while agents stream (STA-4697) resolveWebSessionVisibleTabId answered 'which tab is the user looking at' by inverting a many-to-one projection: it compared tab.contentType against the coarse activeTabType. Diff tabs open with activeTabType 'editor' but carry contentType 'diff', so the match never succeeded and the guard returned null - which is the reconciler's signal to fall through and activate a terminal. Every agent status echo republished the snapshot, so the diff lost focus ~300ms after opening, once per click. Same for conflict-review and check-details. Resolve the visible tab from group state instead, which is what is actually on screen and is the rule deriveActiveSurfaceForWorktree already uses. The coarse address survives only when there are no group records, now projected rather than compared exactly. Also follow the entity within the group when reconcile rematerializes the visible tab under a new id, and teach the browser-create focus guard to observe the group records the resolver now reads. |
||
|
|
8612a2dabb | Fix skill install dialog overflow and simplify file summaries (#15406) | ||
|
|
a3f3a74dcf | test(agent-hooks): prove the Windows hook actually receives its payload (#15403) | ||
|
|
4ec6bbf588 |
Kill hung WSL transcript filesystem operations via child process with route quarantine (#15381)
* fix(native-chat): kill hung WSL operations via child process
Stalled UNC file operations hold libuv permits even after the gate
timeout expires, blocking Chat tab recovery. Two stalled operations
fill both permits and freeze all WSL access until restart.
Fork file I/O for UNC paths into a separate child process. On deadline
expiry, kill the process to force the hung syscall to exit. This frees
the permit for the affected tab's next read. Temporarily quarantine the
stalled route to avoid retry storms.
* chore: drop internal review artifact from the repo root
* fix(native-chat): harden the WSL transcript fs sidecar
Review follow-ups on the sidecar isolation change:
- Only the deadline may abort running gate work. The sole waiter's
same-duration timeout fired first, killed healthy children on caller
abandonment, and settled the task before the deadline could quarantine
a stalled route - leaving the back-off dead for every dedupe:false op.
- Resolve the fork entry from out/main/chunks too: the resolver compiles
into a shared chunk, and the scanner service child has no
process.resourcesPath, so packaged WSL vault scans threw entry-not-found
(masked as an empty tree).
- Allowlist the fork env instead of spreading process.env; ambient
NODE_OPTIONS would halt or --require code into every child.
- Wrap transport faults (spawn failure, child death) in
WslTranscriptFsError('unavailable') so discovery reports them as scan
issues instead of misreading them as missing paths or empty trees.
- Gate the vitest in-process fallback on the vitest worker global so a
leaked VITEST=true cannot revert production to in-process UNC syscalls.
- Reap idle sidecar processes after 60s instead of holding them for the
app session.
- Split 'open' into its own protocol union member so the reusable-call
Exclude actually strips it from the pooled-process API.
- Guard kill('SIGKILL') against the teardown race where an exiting child
emits an unlistened 'error', and dispatch reads by handle kind before
path spelling.
* fix(native-chat): probe stalled WSL routes instead of a fixed quarantine
Remaining review follow-ups:
- Escalating route quarantine: first strike lifts after 5s so a distro
that was cold-booting when its op hit the deadline recovers on the
next poll (~35s total instead of ~90s); repeat stalls double the
back-off toward the prior 2x-timeout cap, and any settle the deadline
did not force clears the strikes. Queued same-route tasks fail fast
at quarantine instead of stranding one waiter deadline per file in
sequential scans.
- Single request implementation: the vitest in-process fallback now runs
the child's own dispatcher (WslTranscriptFsProcessOperations + decode),
so unit suites exercise exactly what the forked process executes and
the per-call-site fallback closures are gone. Dirent fixtures gained
the full kind-flag set the serializer reads.
- Dropped the production-dead per-route close queue; UNC FileHandles
(test fallback only) mirror the process-handle close contract.
- Error class, messages, and factories move to wsl-transcript-fs-error
(re-exported from the gate) to keep the gate under the lines budget.
* fix(native-chat): harden WSL transcript fs with route quarantine strike
Extract quarantine logic into a dedicated module with strike decay: stalls older
than 5 minutes restart from base back-off, and concurrent-lane timeouts count as
one incident. Allow joining live in-flight tasks on quarantined routes (they cost
no new I/O). Preserve quarantine across transport faults (child death). Handle
file shrinking during tail reads by detecting short reads and returning empty.
Defer file closes that arrive mid-read instead of refusing, preventing slot
leaks. Separate process slot and boundary-finding concerns into focused modules.
* fix(native-chat): enforce route quarantine windows and isolate lanes per
A late result arriving after the deadline was incorrectly lifting the route
quarantine, allowing subsequent work to start before the back-off period
expired. Now late results are correctly recognized as stale and never cut
the quarantine short.
Process work is now isolated per (route, priority) lane so a scan stall
cannot block exact reads on the same distro. Each lane gets its own client
and process pool; late results and handle faults stay scoped to their lane.
Tests now fake performance.now() alongside timers (the quarantine clock
depends on it) and wait for the full back-off window to expire rather than
advancing by 0. Gate state is reset between test cases since late releases
never lift the quarantine.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
|