mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 16:02:38 +00:00
ae2e410ce2bb2dcb7ef26faef6ef08595e6dfc78
4212
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ae2e410ce2 | Merge remote-tracking branch 'origin/brennanb2025/agent-launch-mode' into brennanb2025/revive-agent-launch-rpc | ||
|
|
8c6597eeaa |
docs(agent-launch): stop the executor comment claiming a migration that has not happened
The header asserted two things the tree does not support: that every launch
surface routes through the executor, and that the mode decision "already lived"
in `agent-launch-mode`. `agent.launch` is the executor's only consumer, and
`orchestration-worker-start-mode.ts` is byte-identical (blob
|
||
|
|
7a72fc0a1f |
docs(agent-launch): stop the receipt-wording comment claiming a migration
The decision was never moved out of orchestration-worker-start-mode; this PR adds a second copy beside it. Say so, and name the unenforced agreement. |
||
|
|
3837ae8d51 |
chore(agent-launch): carry agent.launch through main's RPC typing and casting gates
The typed-method contract, the generated params catalog and the `assertionStyle: never` casting scan all landed after this branch's base. - AGENT_LAUNCH_METHODS kept an `RpcMethod[]` annotation, which widened its method name to `string` and broke assignability; every sibling infers instead. - `agent.launch` binds a schema under src/main, so it joins the catalog's RPC_METHODS_WITHOUT_SHARED_PARAMS and the parity gate's hand-listed twin. - The now-typed methods make most test casts unnecessary; the few that remain carry the line-specific SAFETY rationale the casting gate requires. |
||
|
|
56fb79b1f0 |
Merge remote-tracking branch 'origin/brennanb2025/agent-launch-mode' into brennanb2025/revive-agent-launch-rpc
# Conflicts: # src/shared/protocol-version.ts |
||
|
|
1dd74f6309 |
chore(agent-launch): carry a SAFETY rationale on the agent placement cast
The type-assertion gate landed after this branch's base, so the new file's copy of the worker-start cast is now a changed-code finding. |
||
|
|
a1163f1339 |
fix(agent-launch): drop the deleted draft-prompt blocker from the reason map
main removed the draft-prompt blocker in #19681 (a structured session now holds an unsent draft), so the exhaustive Record no longer typechecks. |
||
|
|
77f8098202 | Merge remote-tracking branch 'origin/main' into brennanb2025/revive-agent-launch-mode | ||
|
|
ab6b86dd5c | fix(orchestration): require registered structured worker pane key (#20664) | ||
|
|
4a5b0583b2 |
fix(runtime): keep listed handles when graph sync learns a PTY incarnation (#20779)
reconcilePtyIncarnationHandles compared a null retained incarnation against the learned one and staled the handle. Daemon-hosted PTYs are recorded from first output before the spawn commit reports an incarnation, so on Windows `orca terminal create` returned a handle that was stale by the next graph publish. Treat null-to-known as un-fenced like every other site; keep the known-to-different and preallocated-handle invalidations. |
||
|
|
99062ed80b |
fix(worktrees): preserve unverifiable disk witness (#20713)
* fix(worktrees): preserve unverifiable disk witness * fix(worktrees): follow gitdir/commondir markers in disk witness The disk witness validates created worktrees by reading the repo's common directory from disk. Previously it only checked for a direct .git directory and returned a status object that conflated different failure modes. Now it properly follows .gitdir and commondir pointer files to locate the true common directory, fixing detection on repos with linked git directories (worktrees, submodules) and WSL scenarios. Error handling is simplified: definitive absence returns undefined, other read failures throw with proper cause chains, eliminating the ambiguous "unverifiable" state that would mask real errors. * fix: validate gitdir marker targets are directories When a .git marker points to a missing or non-directory path, that's unverifiable—not the same as an absent .git file (bare repo). Validate accessibility before reading commondir to catch these errors clearly. |
||
|
|
ffc331212c |
Fix PTY child process verdict to preserve unverifiable state (#20729)
* fix(pty): preserve unverifiable local child reads * fix(pty): make child-process inspection synchronous Separate foreground and child-process sampling. Sample child processes synchronously after confirming foreground availability, returning unverifiable verdicts when pty reads fail. Handle both transport loss and local read failures uniformly in the completion coordinator. |
||
|
|
b8554f1c59 |
fix(composer): clarify failed attachment drops (#20704)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape * fix(composer): name the attachments a drop could not add, in one toast * fix(composer, source-control): use one stable failure toast slot - Replace per-worktree toast IDs with single slot that replaces on each failure - Remove destructive retry actions; discard must confirm in dialog - Consolidate filesystem import types to shared location - Add compactIpcErrorMessage for string error handling * refactor: centralize filesystem import types and clarify failure naming Move import result types from main/ipc to shared layer so they're available across preload and renderer. Rename uniformFailure → commonFailure and skippedOrFailed → failureCount for clarity. Simplify preload/API type definitions by reusing shared types directly instead of duplicating inlined union shapes. * Reuse single toast slot for composer drop failures Multiple drop failures now replace the previous toast instead of stacking, preventing notification clutter. Uses a dedicated toast ID separate from Source Control's stage/discard notifications. |
||
|
|
767b7c14f1 |
fix(ai-vault): expand nested OMP session history (#20663)
Expand saved OMP descendants lazily while preserving exact child targets for Resume and View Log. Retain expanded branches across virtual scrolling and reject late responses/cycles. Includes the independently reviewed child-workspace correction from #20629. 61 combined target/map/nesting tests and actual OMP child/grandchild storage/CLI smoke pass. Earlier hidden Electron proof covers eight generations and narrow sidebar layout. Folder-only unresolved child targets remain disabled. No live delegation or full terminal-launch proof claimed. Addresses #12885 Scope 2. |
||
|
|
f55b7ba680 |
fix(native-chat): cancel pending prompts precisely (#20601)
* fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds * fix(native-chat): cancel pending prompts precisely * fix(native-chat): consolidate capability imports * fix(native-chat): harden precise prompt cancellation * fix claude cancellation teardown races * retry claude prompt lifecycle admission * bound claude prompt cancellation retry work * fix(codex): bound prompt turn identity on registration * fix(native-chat): route rejected late dispatch settlements * fix(codex): retain exact cancellable prompt turn ids --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
dd85e5fc81 |
fix: keep OMP terminals when folder workspaces become Git repos (#20653)
Preserve the original folder locator through Git upgrade and subsequent listing, persistence, and removal decisions after proving it still names the same checkout. Independently reviewed with 60 focused persistence/listing/removal tests and six native Windows real-Git/NTFS cases covering case/slashes, junction retention and retargeting, remote-host isolation and unrelated checkout preservation. Prior source-connected native OMP proof confirms process survival. Full PR CI passed; no rebuilt full-app after-proof claimed. |
||
|
|
41e42beab4 |
fix(worktrees): safely remove prunable git-file registrations (#20617)
Preserve checkout files and the named branch when removing a positively attested malformed Git-file registration. Reject file/symlink targets in deferred directory deletion. Verified exact head with 75 focused tests including actual Git malformation, preserved marker/file bytes and branch HEAD. Independent review and complete product CI passed. WSL routing is covered by unit tests; direct SSH fails safely without local recovery. Fixes #17316 |
||
|
|
bac96b212e |
fix(hooks): actually terminate a timed-out hook's process tree (#20576)
Repairs #20559, whose termination was a no-op: `detached` is a spawn-only option and `exec` ignored it, so the shell never became a group leader. Verified against real processes. Refs #19334 |
||
|
|
4a027626e9 |
fix(agent-session): honour the backup-recovery fence floor on surface release (#20708)
* Fix surface release fence recovery floor * fix(agents): advance backup recovery floor past lost mint |
||
|
|
49fba59925 |
fix(runtime): apply the tui-idle evidence ranking to mailbox delivery (#20578)
fix(runtime): retry a delivery that the idle gate refused Gates delivery at the two points where each implementation commits to typing into the pane, rather than at each caller, and parks-and-re-offers a refusal so late idle evidence cannot strand a queued message. Refs #6011 |
||
|
|
389d672dab |
fix(omp): preserve saved conversation names in session history (#20636)
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> |
||
|
|
68f0b2e835 |
feat(runtime): stream file uploads instead of buffering whole files (#16106)
* feat(runtime): stream file uploads instead of buffering whole files Staging read each dropped file whole with readFile(), base64-encoded it (a 4/3 expansion), and passed the string through IPC to the renderer, which re-chunked it. Peak memory was ~2.3x the file size before a byte moved, so a 25 MB per-file cap existed to protect the heap. Staging now records identity only. The byte pump moves into main, where the file handle and the runtime socket both live: 384 KiB slices (512 KiB once base64-encoded, matching the chunk size the renderer used) appended through the existing files.writeBase64Chunk RPC. Peak memory is one slice regardless of file size, so the ceilings become user-safety limits on an unattended transfer — 2 GB per file, 8 GB per drop — and over-limit errors name both the size and the limit. Because staging and streaming are separate calls, the staged entry carries size, inode, device and mtime, and the streamer re-checks all four against the pre-open lstat and against the handle it actually reads. A source replaced or rewritten at the same size between the two calls is refused rather than uploaded under the original name. The post-read check compares mtime as well as size, so an in-place rewrite mid-transfer aborts before commitUpload renames anything into place. O_NOFOLLOW, realpath containment and stat identity are preserved, and the pairing revision plus the runtime id ride every chunk, so a re-pair or a replacement runtime aborts instead of appending the rest of the file to a different host. No wire change: files.writeBase64Chunk and its params are untouched, so old and new hosts behave identically. The SSH import path is separate and unchanged. The web client has no local filesystem to stream from and says so instead of failing obscurely. * fix(runtime): close the empty-upload and per-drop budget holes Two gaps the first pass left open. A zero-byte source returned before the post-transfer identity check, so a file that gained content during the empty write's round trip committed as an empty file at the user's chosen name. The empty chunk now falls through to the same final check the slice loop uses. Each staged source also started its own byte counter, so the 8 GB ceiling capped one source rather than the drop: five 2 GB files staged cleanly at 10 GB total. The IPC handler now carries one budget across sourcePaths and adds only what each source actually staged. The per-file ceiling is still re-enforced where the bytes move; the drop total holds at staging because identity enforcement means each file streams exactly the bytes measured. * docs(runtime): name the invariants the upload helpers carry * fix(runtime): name the source in errors and stop uploads with their window Three problems an independent review turned up. A dropped file's relative path is '', so the over-limit error read "'' is 3 GB, over the 2 GB per-file remote import limit" — the message this change exists to fix, naming nothing. Errors now fall back to the file's own name; the staged entry keeps '' so the destination path is unaffected. The streamer had the same shape, falling back to the hidden .orca-upload-<nonce> temp destination, a path the user never chose. The byte loop used to live in the renderer and died with it. Moving it into main meant closing or reloading the window left the rest of a multi-GB transfer running, with the renderer's temp cleanup never reaching its finally. An AbortSignal now rides the caller's lifetime and every chunk, is re-checked per slice, and main sweeps the abandoned temp path itself when the renderer is no longer there to do it. Upload failures also reached the import result wrapped in Electron's "Error invoking remote method '...'" prefix, because the throw crossed IPC instead of happening in-renderer; extractIpcErrorMessage unwraps it. An existing staging test asserted the empty-name message, so it encoded the bug rather than catching it; it now asserts the file name. * test(runtime): cover the containment check and the per-chunk host guards The "escapes the dropped root" test only reached the lstat symlink guard, so assertEntryInsideRoot had no coverage at all. The shape that actually needs it is a regular file under a symlinked intermediate directory: lstat sees a plain file, and realpath containment is the only thing that refuses it. Disabling the guard now fails this test and nothing else. Nothing asserted that the SSH target, connection generation and execution host reach the writeBase64Chunk params either — the renderer tests stop at the IPC boundary, so the streamer's half of that contract was untested. * fix(runtime): survive a straggling append when sweeping an aborted upload Aborting rejects the in-flight chunk locally, but the host may still apply that append, and appends open with flag 'a' — which recreates the file the sweep just deleted. The delete and the straggler also race: they are separate calls on a queue that is not ordered between them. Slices are strictly sequential, so at most one append can be outstanding. A second pass after it has had time to land is therefore sufficient, not merely a heuristic. The sweep moves out of filesystem-mutations.ts into its own module so the behaviour is testable directly. Found by an independent review pass, which also pointed out that the "escapes the dropped root" test only reached the lstat symlink guard. * fix(runtime): abort uploads only when the document commits, and honour manual disconnect per chunk did-start-navigation fires before will-navigate blocks an external link or a stray file drop, and the renderer survives those (verified against Electron 43 with a hidden window). Aborting there killed a healthy upload with a misleading 'window went away' error. did-navigate fires only once a new document has replaced the caller. The renderer's per-chunk calls used to go through the IPC handler that refuses a manually disconnected environment; the loop in main made no such check, so a disconnect mid-upload kept pushing the rest of the file. The handler now resolves the selector to an environment id and the streamer checks it per slice. Adds slice-boundary coverage against the real chunk schema and host write flags, staging-to-stream on a real filesystem, and handler-level lifetime tests. --------- Co-authored-by: Neil <neil@stably.ai> |
||
|
|
8d93505958 |
fix(terminal): retain renames before renderer pane hydration (#20619)
* fix(terminal): retain renames before renderer pane hydration * test(terminal): keep late renames from recreating closed tabs |
||
|
|
ee1a0a4e2d |
fix(git): avoid Windows tree kills after the command has exited (#20606)
Validated and independently reviewed OMP integration fix. |
||
|
|
fc4519cda4 |
fix(omp): preserve zsh startup with global aliases (#20621)
Validated and independently reviewed OMP integration fix. |
||
|
|
3632311d0b |
fix(omp): preserve status after terminal title owner rewrite (#20610)
Validated and independently reviewed OMP integration fix. Co-authored-by: shahidbeig-a11y <258701601+shahidbeig-a11y@users.noreply.github.com> |
||
|
|
955051ded0 |
fix(codex): settle a structured send on admission, and stop minting a colliding identity (#20138)
* fix(codex): settle a structured send on admission, and stop minting a colliding identity Two sends could be written into the journal under one durable identity. Codex coalesces a mid-turn `turn/start` into the running turn rather than refusing it -- measured against real `codex app-server` builds 0.147.0, 0.150.1 and 0.153.4, none of which refuse and none of which fire a second `turn/started`. The dispatch path read the turn id from the turn/start response and stamped every accepted send `ordinal: 0`. Since a coalesced send gets the running turn's id back, two submissions persisted the same `providerItemId`. That string is durable, and it is the key a restore uses to match a submission against provider history, so the second message's real history row matched nothing and rendered as an extra bubble on replay. On 0.147.0 it is worse than a collision: the coalesced response returns a turn id that never starts and never completes, so the persisted key named a turn absent from history and NEITHER message could match. Identity is now minted from the echoed user message at `identityFor` -- the single point that mints the journal row's own identity -- so the settled key is by construction the one replay computes, rather than a parallel calculation that can drift. Dispatch returns `admitted` when the transport write completes; identity settles on the echo through a channel that did not previously exist for Codex. Waiters are keyed by client message id instead of being shifted off the front of an array by arrival order, and they are cleared on session close and child exit -- previously a timeout was the only thing that ever ended one. `TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured: `readCodexTurnId` returns non-null on all three, so the 10s wait never fired. The comment justifying it claimed older builds acknowledge before the id exists, which no tested build does. Three comments asserting Codex answers a mid-turn send with `turn already running` are corrected. Their only backing was a test fixture inventing that error string. The correction is factual only -- every changed line in `src/main/runtime/orchestration/` is a comment, and mid-turn delivery is still refused for both providers. Whether that policy is right is a separate question; it was resting on a false premise. Known gap, stated rather than implied: this prevents new collisions and does not repair journals already written with a colliding or phantom key. Those conversations keep duplicating on restore. Repairing them means re-matching persisted submissions against provider history and rewriting `providerItemId` -- which is what `journal-submission-reconciler.ts` is written for, and it still has no production caller. * test(codex): drop the synchronous-accept contract and the colliding `:0` from the integration fakes Three tests in the structured-session integration suites encoded the dispatch contract this branch replaces, and two of them pinned the defect it fixes. They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying `providerItemId: codex:<thread>:<turn>:0` at send time. That ordinal was never observed; it was stamped on every accepted send, which is exactly the collision this branch removes -- a send coalesced into a running turn is answered with the running turn's id, so two submissions persisted one durable key. The visible failure was a 30s timeout rather than a failed assertion. The fake client advertised no `agent-session.pending-send-result.v1`, and without it the host holds the reply until the send settles: a shim for clients too old to render a pending bubble. The fake provider then echoed the user message with no `clientId`, so nothing could correlate that echo back to the submission, and the wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and the fake now does too, which is what makes it a model of the provider rather than a sketch of one. The identity assertion is kept rather than dropped. Each send now asserts `pending` with no identity at admission, then asserts the submission settles `accepted` at `codex:<thread>:<turn>:0` once the echo lands. Same ordinal, but earned from `identityFor` on the echo -- the key a replay recomputes -- instead of guessed from the turn/start response. Ablated: removing `clientId` from the two echoes leaves both submissions `pending` and fails both assertions, so the assertion is load-bearing and not satisfied by something incidental. Both suites' client fixtures now advertise the capability set the desktop renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by a client. The older-client settlement wait keeps its own coverage in `src/main/runtime/rpc/methods/structured-agent-session.test.ts`. `structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same reason; it drives the host directly, so it never took the compatibility path, and what proves delivery there is still the turn the reacquired provider starts. The replay suite's "without dispatching it twice" property is untouched: one `turn/start` call, one replayed ledger row. * fix(codex): preserve unsettled dispatch correlations * test(codex): type the dispatch fixtures instead of asserting over them main's new casting gate (#20367 base) flags type assertions on changed lines. Replace them with checked types: the recording sink already satisfies its interface, both CodexSession fixtures are now annotated and carry real collaborators, the settlement assertion compares whole identities, and the integration helper reads submissions through the host's public journalSnapshot instead of its private session map. * fix(test): merge the duplicate doubt-reasons import the merge left behind Both sides added an import from journal-dispatch-doubt-reasons and the merge kept both statements, which the whole-repo native plugin gate refuses under --deny-warnings. * test(codex): a Fast mode turn is admitted, not accepted #20506 landed its Fast mode tests against the dispatch contract this branch replaces: a Codex send now returns admitted and settles its identity on the provider echo. The tier assertions the test exists for are untouched. --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
c3372aeadc | fix(ai-vault): bound streamed remote JSONL records (#20700) | ||
|
|
b4d435806f |
fix(native-chat): bound a dispatch reason before it reaches the journal row (#20654)
`AgentJournalSubmission.reason` was the only unbounded field written by Orca's own code. `dispatchSafely` sets it from the adapter's raw `error.message` and `journalDispatchRowBuilder` stored it verbatim, so a provider error carrying a multi-megabyte body -- a stringified HTTP error payload, say -- reached the row at whatever length the provider sent, and stayed on disk at that size for the life of the journal. It now goes through `boundInlineText` with the journal's existing inline limit, the same idiom already applied to arbitrary text on the Claude and Codex translation paths. The bound must stay head-preserving. `dispatchRejectionWasTransportWriteFailure` prefix-matches the value, and `dispatchRejectionReasonIsInternal` builds on it, so a bound that kept the tail instead would stop classifying a clipped transport failure and render raw provider text to the user as an ordinary rejection notice. A test pins that, and clipping stays marked rather than silent so a truncated reason is never presented as the provider's complete explanation. Rows written before this keep their full text, so readers can still meet an unbounded reason. |
||
|
|
c6a7216984 |
fix(native-chat): hide activity while awaiting input (#20496)
* fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
eba56f2f69 |
feat(ai-vault-search): construct the session search indexer in the scanner service behind a setting (#20516)
* feat(ai-vault-search): persist agent-session search consent and retention Two booleans and nothing else: `enabled` and `historyDays`, off by default because building the index reads every transcript on the machine. No `paused` -- the PR 3 indexer is immutable, so every change is close-and-construct. The settings IPC normalizes a write like every other field and hands the change to the index; there is no UI for it until PR 8. * feat(ai-vault-search): hold one indexer and engine pair per host The object that owns a host's live index and the three recipes that change it. The indexer is immutable, so a settings change is close-and-construct, disabling is close with no replacement, and clearing is close, remove the database, construct. The new instance's first sweep purges a narrowed window and admits a widened one, so neither needs a code path. The database sits beside the scanner's parse cache, one file per host. A runtime with no node:sqlite can hold no index at all, which the Node 18 floor on orcad and the relay makes a real case rather than a hypothetical one. * feat(ai-vault): let the scanner child own the session search index The transcript reader runs in that child, so the index consumer has to as well: one read serves both the session list and the index. Three request operations (search, status, reconcile) and one fire-and-forget settings message carry everything a parent needs; main never opens the database file. The init frame becomes a factory because it is read at every spawn, so a respawned child sees current consent rather than the first frame's. A child holding a running index is never idle from the parent's side, so idle retirement is suppressed while the index is on -- retiring it would stop the reconcile loop until some later scan happened to respawn one. Both files this lands in were already at the max-lines ceiling, so three collaborators move to where they belong rather than being disabled around: the invalidation deadline into the class that owns invalidations, call cancellation and the start requeue into the call-state module, and orcad's flag parsing into its own file. * feat(ai-vault-search): register a search service on every host that answers Without a registered service a host answers no-service, which means "this host does not have the feature" rather than "the index is off". All three hosts now answer the second thing. The desktop forwards to the scanner child. orcad and the SSH relay daemon have no such child -- orcad ships only the watcher and daemon entries, and the relay's AI Vault sidecar runs the remote scanner, which publishes nothing to the transcript channel -- so on those two the index lives in the process that would drive its reads, gated on a runtime that has node:sqlite at all. The relay registers with consent off and no way to turn it on: nothing carries a setting to a remote host yet. That is the honest state, and it is still worth registering, because it is what tells a client the difference between off and too old. * test(ai-vault-search): price a warm pass over five thousand transcripts The number the reconcile interval will be revisited against, measured rather than argued: a warm sweep stats every file under every root, a warm cycle stats the newest N per agent, and neither reads what the index already holds. It does not tune the interval. * fix(ai-vault-search): answer the casting gate without assertions main's new type-assertion rule reaches every file this branch touches. All nine sites drop the cast rather than carry a SAFETY: rationale: the operation guard narrows with `in`, the sqlite probe narrows the builtin it loads, the child test keeps the discriminated reply instead of widening it, and the settings resolver takes `unknown` -- which is what it really reads, since a persisted profile can hold a value no version of this code wrote. * fix(ai-vault-search): let a refreshed scan root reach the live index The parent re-resolves scan roots before every policy push, precisely so a WSL distro or extra Codex home that appeared since the child spawned enters the window. The child forwarded only the settings to a live instance and used the roots solely in its `??=` initializer, so those roots were dropped for the child's lifetime. The indexer stays immutable: a structurally different root set closes the pair and constructs a new one, the same way a changed databasePath already does. Compare via `sameSessionSearchRoots` rather than a plain JSON compare, because nothing fixes the key order two producers write; lists are sorted too, since the indexer walks every root and a re-enumeration that reorders is not a change. An unchanged set still never restarts a running index. The orcad and relay in-process hosts resolve roots once at install and never re-apply, so they have no such seam. * fix(ai-vault): restart the scanner child the index is holding Three review items. The hold keeps a child alive for the index, but only a queued call ever started one: `pump()` skipped a hold with an empty queue, so an idle indexing child that crashed, or an `ensureChild()` that failed at start, left indexing stopped until an unrelated request happened to arrive. `pump()` now starts the child the hold requires, which is also the restart callback the fault policy already schedules, so the existing delay and circuit bound the retry exactly as they bound a queued call's start. `updateSessionSearch` goes through the same seam instead of its own `ensureChild` call. A search registers no AbortController, so a cancel sent for a search id was added to the `cancelled` set and never consumed. Nothing can reach that today -- no caller passes a signal and the child answers in milliseconds -- so this is only a leak of ids: consume it when the search settles. The orcad argument doc claimed a `--`-prefixed value stays a flag. The parser takes the next token regardless, and orcad-launch-contract.test.ts pins that, so the doc is what was wrong. Behaviour is unchanged. * fix(ai-vault): recover search indexing and refresh scan roots * fix(ai-vault): defer search refresh policy reads * fix(session-search): stabilize paging and host enablement * fix(session-search): refresh host roots within full sweeps * docs(session-search): clarify initial root fallback |
||
|
|
8e26d516d8 | perf(browser): dispatch coordinate pointer input in process instead of one subprocess per event (#20593) | ||
|
|
01f8aa8d96 |
fix(grok): stop SessionStart orca-status hook hanging for 10s (#20090)
* fix(grok): stop SessionStart orca-status hook hanging for 10s Grok writes one JSON hook payload and waits for the process to exit without closing stdin. The POSIX hook used `cat`, which waits for EOF, so SessionStart deadlocked until Grok's 10s timeout: session_start hook (global/orca-status) failed, ignored: timed out after 10000ms Read one JSON object with raw_decode instead; return as soon as the object is complete. Fall back to cat when Python is missing. * fix(grok): decode the hook payload incrementally instead of per chunk The JSON stdin reader strict-decoded the whole accumulated buffer after every read and caught only json.JSONDecodeError. A multi-byte character split across two os.read calls therefore raised UnicodeDecodeError, which is a ValueError but not a JSONDecodeError, so the interpreter died — after consuming stdin. The `python3 || python || cat` chain then handed the next reader a truncated stream, and `cat` blocked on a pipe the caller never closes, reinstating the exact 10s SessionStart timeout this reader exists to avoid. Measured: a CJK+emoji payload written byte by byte hung until it was killed; a 200KB payload split mid-character arrived as 4 bytes. Hold the decoder across reads, treat any ValueError as "not complete yet", and guard the whole program so a non-zero exit implies stdin was never read — only then is the `||` fallback safe. Emit the object's own text rather than re-serialising it, which was rewriting non-ASCII as \uXXXX. Skip leading whitespace, which raw_decode does not. Separate the first-byte wait (5s) from the idle wait (1.5s) so a writer that is merely late is no longer dropped. Also unset a HOME that does not exist before spawning the interpreter: macOS resolves /usr/bin/python3 through an Xcode stub that re-runs its whole tool lookup without a reachable cache, costing 6.6s per spawn and overrunning Grok's budget on its own. This is what made the existing large-payload and empty-PATH lifecycle cases fail. The Python program now lives in a shell variable instead of being inlined twice, which halves the generated script and keeps it readable. --------- Co-authored-by: Neil <neil@stably.ai> |
||
|
|
170dbdb874 |
fix(ai-vault): ignore non-absolute env overrides for agent scan roots (#13118)
Six scan roots took a directory from an environment variable and used it verbatim. A relative value is resolved by whichever Orca process reads it — main sits at `/` when Finder-launched, the terminal daemon chdirs itself to the user data dir, the AI Vault service inherits main's cwd — so one value names a different directory in each, and walkSessionFiles walks it with no depth cap, no entry cap and no time budget, about once a minute per the session-list cache TTL. The agent CLIs do accept a relative home (verified against real Grok 1.0.30: `GROK_HOME=myhome grok du` creates `<grok-cwd>/myhome`), but they resolve it against their own per-terminal cwd, which no Orca reader shares. Falling back to the default home is therefore not a lost configuration — it replaces an unbounded walk of an arbitrary tree with a bounded read of a known one, and matches what readGrokHomeEnvelope, skill-provider normalizedRoot and absoluteConfiguredDir already do with the same values. Add resolveAbsoluteDirOverride and apply it to CODEX_HOME, COPILOT_HOME, OPENCLAW_STATE_DIR, DEVIN_HOME, KIMI_CODE_HOME and GROK_HOME. It takes an explicit platform so the Windows shapes are provable from a POSIX CI box: `C:\...`, `C:/...` and UNC roots are kept, while the drive-relative `C:foo` and bare `C:` fall back. Tilde expansion stays out of it — Grok creates a literal `~` directory rather than expanding one — so absoluteConfiguredDir keeps its own Pi/Prime-specific expansion and delegates the absolute check. isAbsolute is syntactic only, so `/..` still collapses to `/`. That is fine for read-only discovery; these roots never gate renderer-supplied paths. Tests assert at the call sites, not just on the helper: the four session-scanner-agent-sources roots are module-level consts evaluated at import time, so they are exercised through AI_VAULT_AGENT_SOURCES with vi.stubEnv plus vi.resetModules. Reverting any one of the six call sites fails them (11-33 cases each). Closes #13082 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
539d4d1f32 |
fix(native-chat): resume structured chats cleanly after restart (#20509)
* fix(native-chat): retire provider ownership on restart * fix(native-chat): stop showing a restart eviction as a provider death Restarting Orca turned a resumable structured chat into a user-visible `Provider exited: recorded pid absent on host`. Quit never released the durable lease, so restart probed the recorded pid, adjudicated the session evicted, and wrote a synthetic status row against a chat that was perfectly resumable. The fix is the missing teardown phase plus the missing fence check: quit now evicts every provider child this host owns — stopping it, settling its journal and handing the lease back — and the release compare-and-swaps on the fence it expected. Restart then finds a released lease and reopens the chat silently. What the user sees is decided by the typed death evidence rather than the shape of a settlement id: only an `exit-observed` death writes copy, and that copy now carries its cause so an auth failure and an OOM kill do not read alike. The reassuring wording stays. Historical synthetic rows are filtered out of the render projection, which needs no schema change and leaves every real provider-exit row alone. Also: - Bound the new eviction phase well below the quit deadline; a quit that dies mid-eviction leaves the lease unreleased, which is the original bug. - Scope the interruption verdict to work that was mid-response. A provider that died while waiting on an approval interrupted nothing. - Keep host bookkeeping in step with the adapter: the provider-child flag clears when the child is proven stopped, not seven steps later. - Drop the router's duplicate shutdown gate and acquisition drain — both adapters already own theirs — and latch the router closed so a late acquire cannot fan a session back out to closed adapters. - Attach the real cause to the settlement failure a quit reports, and remove a recovery-ticket field that was hardcoded at its only construction site. * fix(native-chat): scope the legacy status filter to the copy it retires The read-time filter hid every status row carrying a `restart-eviction:` identity. That identity is still minted, so a genuine provider death settled under it would have been dropped from every rendered page. Match the retired `Provider exited` copy as well, so only the legacy rows are hidden. Three smaller corrections alongside it: - The settlement retry path now applies the same unfinished-work check the live exit path uses, so a provider that died waiting on an approval no longer gets told a response was in progress. - Bound the exit reason before composing the outcome copy, so a stderr dump in the reason cannot push the "you can continue" sentence past the row's byte cap. - Correct the teardown comment: tail rows are protected by eviction's own per-session ordering, and `closeAll` is a backstop for children eviction never took, including one whose eviction was refused. * fix(native-chat): retire legacy status rows at the projection source The read-time filter that hides the retired `Provider exited …` rows ran on the way OUT of the page builder, after the paging math had already measured the unfiltered timeline. A backward window landing entirely on those rows returned an empty page that still reported `hasOlder: true` with a null `window.oldest`, so the renderer's backfill loop re-asked from the same anchor forever. Its only no-progress guard compares `window.oldest?.sequence` to the anchor, and `undefined === n` never breaks. The live subscription opens behind that loop, so the transcript never finished loading either. Filter where items ENTER the page pipeline instead: the reduced snapshot gets one renderable timeline, the forward path gets one renderable batch, and the window bound, effective limit, `hasOlder`, `window.oldest` and `nextCursor` are all computed over that single array. A window with nothing left behind it now reports end-of-history. Also restore the eviction retry contract. Clearing `hasProviderChild` as soon as the adapter proves the child gone is honest, but it is a different fact from the wind-down this host still owes. A retry after a step aborted between the two was reading "no child here" and skipping both the dead-generation settlement and the lease release the aborted attempt had promised to repeat. The obligation is now tracked separately and cleared only by a release that actually landed. And rename the filter to the copy it retires: it drops only rows carrying the retired `Provider exited` text, not restart-eviction status rows in general. * fix(native-chat): read the wind-down a close owes from the live child An eviction recorded "nothing owed" whenever it ran over a session with no provider child of its own, and the retry then read that record in preference to the child in front of it. A session suspended to an agent terminal is exactly that shape, and the trip back to native re-acquires into the SAME session object rather than replacing it, so the next close skipped both the dead-generation settlement and the lease release — leaving the record claiming a live owner this host had just stopped, and a pending send unsettled. The obligation is now derived the way the quit sweep already derived it, from one shared predicate: a live child always owes a wind-down, and a remembered `false` only carries the obligation forward, never cancels it. Also drops a memoization in the history page that could never hit. Its key was the snapshot's items array, which the reducer rebuilds on every `snapshot()` call, so each backward page allocated a fresh key; the one reader that does share a snapshot across pages reads forward and never calls it. The comment claimed a multi-page read filtered once, which was not true of either path. Tests: the handoff round trip that strands the lease, and the quit sweep picking up an eviction whose close retry never came. * chore(native-chat): scope three helpers to their file and pin the teardown order retryUnexpectedExitSettlement, hasUnfinishedStructuredAgentSessionWork and isRetiredProviderExitStatusItem each have no consumer outside the file that defines them, so they no longer advertise an external contract. The quit-path phase list documents its order as load-bearing, but nothing asserted it. Pin the phase names so evict-owned-sessions cannot drift out of its slot between drain-attaches and flush-event-sinks. * fix(native-chat): stop the router reporting a stop it never observed `closeAll` cleared the route table and set one boolean, after which that boolean was the only surviving evidence about any session. Two call sites then spent it: `releaseAcquisition` and the stop path each turned a route-lookup MISS into reported success. Eviction reads a `true` from the stop path as proof the provider child is gone and releases the durable lease on it, so a session the router never routed could have its lease handed back on the strength of "I have no record, but everything is closed." Loss of contact is not evidence of process death. The fix keeps the evidence instead of the inference: adapter shutdown only resolves once every child is proven stopped, so `closeAll` now marks each routed session `stopped` rather than forgetting it. A routed session still answers `true` from its own retained proof; a session with no route answers `false`, which leaves it indexed for a real retry. `releaseAcquisition` drops its short-circuit and asks the adapters, which answer from their own session maps. The acquire-side latch is unchanged: once closed, the router stays closed and refuses new work. Behaviour that changed: a post-`closeAll` stop for a session the router never routed, or one the host already acknowledged as released, now reports unproven instead of proven. That matches what the same call already answered before `closeAll`, and no real flow reaches it — quit evicts every owned session before `closeAll` runs, and eviction only asks the adapter for sessions whose provider child this host acquired through the router. * test(native-chat): ratchet the retired provider-exit copy out of production The retirement filter hides a status row on two facts: a restart-eviction item id and copy that opens with the retired prefix. The identity half is still minted today, so the filter cannot tell a new producer's row from the legacy row it exists to hide — any future writer of that copy would be dropped from every transcript with no trace. Until now that safety property lived only in a doc comment. Scan the shipped tree for a string literal that OPENS with the retired prefix, which is exactly what the filter's `startsWith` reads. Comments are stripped first, so prose about the retirement is not a producer, and the filter's own constant is exempt. Tests are excluded: writing the copy is how the filter is exercised. * revert(native-chat): drop the read-time retired provider-exit filter Fix forward instead. The lifecycle change in this branch stops any new `Provider exited: <reason>` row from being written; rows a previous build already persisted stay in those transcripts and age out with them. A permanent read-time filter for a cosmetic, shrinking set was not worth its maintenance cost, and its paging seam was the only place a backward window could land entirely on hidden rows. Removes the filter module and its test, restores agent-session-history-page.ts to its pre-branch form, and drops the tests that only existed to prove the filter did not over-match or wedge the backfill loop. The copy ratchet stays and now carries the whole guarantee: with no filter in front of it, any production writer that resurrects the retired prefix reaches the user's transcript directly. |
||
|
|
4634d2c03b |
fix(native-chat): let the provider reopen a Claude turn it resumed itself (#20518)
* fix(native-chat): let the provider reopen a Claude turn it resumed itself A Claude turn could only be opened by Orca's own send echo, while any `result` frame closed it. The provider resumes work on its own — a background task reports in and wakes the agent after `result` settled the turn — and nothing Orca sent ever arrives to reopen one, so the session projected `idle` for the rest of the work. The model's own output is the evidence a turn is running, the way Codex's `turn/start` is, so it opens one; whichever opened it, the next `result` settles it. Subagent frames still open nothing: children outlive the turn that spawned them, and their work is their parent turn's, never a turn of its own. * fix(native-chat): bracket a resumed turn around the output that opened it A resumed turn was published after the frame's own rows, so its first tool call sat above the turn record. Every reader that scans back to the turn record and stops — the active-tool reader behind the sidebar's tool line, and the turn-window activity selector — looked straight past it, and the row showed working with no tool until a second call landed. The turn now opens before its frame is journaled. A send's turn keeps its existing order: the user echo is that turn's anchor and is written first. Prompt journaling moves to its own module, verbatim, to keep the translator clear of the line cap. * refactor(native-chat): declare the turn open at each content site The resumed-turn rule was a predicate that re-derived whether a frame had produced anything, duplicating work the frame handler had already done. The content sites know: each one now calls an idempotent ensureTurnOpen before it journals, and the guard against reopening a live turn lives in that one place. Behaviour is unchanged; claude-turn-opening.ts is left owning only the send echo, which is the one opener that anchors its turn to a user row. * fix(native-chat): gate both turn edges on root-ness The reopen path already refused nested output; the guard now reads before the already-running check so both edges state root-ness first. The close path had no nesting check at all, so a child's result would have ended the turn that spawned it. The two edges read parent_tool_use_id differently on purpose, and both fail towards not over-claiming: opening needs proof of root-ness, so an absent field opens nothing; closing needs proof of nesting, so an absent field still closes. No real Claude stream has been observed carrying a nested result — the session that prompted this work has none in any subagent stream — so the close-side guard is symmetry, not a demonstrated fix. * fix(native-chat): stop provider output reopening a turn nothing can close Self-audit found two paths the reopen rule opened where no event could ever settle the turn it created, leaving the row working for the life of the session. Both now suppress reopening until an accepted send lifts it: - a frame arriving after the session ended, when no event will settle anything - a turn the provider failed, or the user stopped, where the next thing the provider says is not a resumption Each has a one-lever ablation: removing the suppression read alone fails exactly those two tests, and both fail as working-instead-of-idle. * fix(native-chat): open a resumed turn from its first streamed delta Streamed deltas short-circuit before the frame handler, so a resumed turn whose first output is streamed text — the common case, since partial messages are a pinned launch contract — kept reading idle while its partial text was already journaled and visible. The streamed path now opens the turn too. Also from the same audit: - a nested result no longer swallows its own failure diagnostic; the turn gate now guards only settlement, and the provider-fallback row is written either way - root-ness treats an absent parent_tool_use_id as root, so a build that omits the field cannot silently stop opening turns - the suppression latch only ever sets on a failed result; a later clean result cannot lift it, and only an accepted send does The opener moves into claude-turn-opening.ts so both entry points share one root-then-suppression-then-idempotency order. * fix(native-chat): preserve resumed-turn lifecycle semantics |
||
|
|
243f443155 |
fix(session-search): read oversized numeric file IDs on Windows (#20551)
Co-authored-by: Orca Worker <orca-worker@localhost> |
||
|
|
55b3392018 |
fix(terminal): drop the agent gutter from copied selections (#19770) (#20545)
* fix(terminal): drop the agent gutter from copied selections (#19770) xterm selections are screen cells, not logical text. Agent CLIs paint their messages behind a fixed left gutter, so every copied line carried that gutter into the clipboard and pasted replies came out indented. Terminal clipboard writes now drop the run of spaces that *every* selected line shares, so relative indentation (nested bullets, fenced code, YAML) survives and only the gutter is lost. A selection that starts mid-line, or that includes any column-0 line, has a shared run of zero and is copied verbatim. Applied at every terminal clipboard seam: the Cmd/Ctrl+C shortcut, the pane context menu's Copy, right-click-to-copy, the app menu's Copy, copy-on-select, the X11 primary selection, the dashboard popout's preview terminal, and mobile's selection Copy button. New "Trim Gutter on Copy" terminal setting (default on) restores the old verbatim-cell behaviour. * fix(terminal): honour the gutter-trim setting on mobile copy Mobile stripped the gutter unconditionally, so turning "Trim Gutter on Copy" off left one surface still rewriting the clipboard. Mobile now mirrors the desktop preference through the existing settings.get RPC — a host predating the setting sends no key, which reads as on, matching the desktop default. Also folds the single-use gutter helpers into their callers so the shared module exposes one function. * refactor(terminal): parse each selection line once in the gutter rule Also locks the Windows subtlety with a test: a blank CRLF row is '\r', which reads as a zero-indent content row and would cancel the gutter unless the CR is split off first. * fix(terminal): publish the gutter-trim setting to paired clients settings.get is an explicit allowlist projection, not the whole settings object, so terminalCopyTrimsGutter never reached mobile: the client read the key as absent, which means "older host", which means on. Mobile therefore always trimmed and the desktop opt-out was inert. Adds the field to the projection and a test that fails if it is ever dropped again — absence is indistinguishable on the client from an old host, so a silent regression here has no other signal. * chore: drop unrelated formatter drift from this branch A repo-wide `pnpm format` swept a quote-style change in pnpm-workspace.yaml and a blank line in source-tree-walk.test.ts into this branch; neither is related to the gutter fix. * fix(terminal): trim the gutter on native copy events too xterm binds its own DOM `copy` listener that writes raw screen cells (CoreBrowserTerminal `_initGlobal`). Orca's own chords never reach it — they preventDefault in keydown — but Ctrl+Insert is a Chromium copy accelerator on Windows/Linux and is not in `terminal.copySelection`'s bindings, so it still copied the gutter. Orca binds Shift+Insert for paste on those platforms, which makes the asymmetry worse. A capture-phase listener on the xterm element now writes the trimmed text, closing the class rather than the one chord: any native copy event — assistive tech, execCommand — lands on the same path. Installed for both terminal panes and the dashboard popout's preview terminal. |
||
|
|
3763103084 |
fix(hooks): report a timed-out hook as unverifiable and terminate its process tree (#20559)
## In plain terms Orca lets a project define scripts that run at certain moments — one when a workspace is set up, one just before it is deleted. Those scripts get a time limit. When the limit ran out, Orca asked the script to stop and then believed whatever the script said on its way out — so a script written to shut down politely could be cut off halfway through its work and still report that it had finished. Anything relying on that answer was relying on a guess. Now the verdict comes from the clock, not from the script: if it ran out of time, that is what is reported, whatever exit code it managed on the way out. Orca also stops the script's *children* rather than just the script, so a background process it started can no longer outlive it. Split out of #20153 so the gate that consumes this answer is reviewed separately. `Refs #19334` rather than `Fixes`, because it does not close the issue on its own. ## The bug `exec({ timeout })` sends SIGTERM and then reports what the child did. A hook that traps SIGTERM and exits 0 therefore comes back with a **null error** — success — despite having been cut off. ```js exec("trap 'exit 0' TERM; sleep 5", { timeout: 200 }, (err) => …) // err === null ``` That is not an `exited` vs `unverifiable` nicety: it is a failed hook reported as a passing one. Realistic triggers are ordinary — a Node wrapper with a graceful `process.on('SIGTERM')`, an rsync wrapper that cleans up on signal. ## What changed **`runHook` owns the deadline.** The verdict comes from running out of time rather than from the corpse's exit code, and it is settled *at* the deadline rather than whenever the child gets around to dying — a hook that traps the signal and keeps running must not hold its caller open. **A timeout withholds the exit code.** So does a spawn failure, where `exec` reports a *string* code (`ENOENT`); the `typeof code === 'number'` guard is what keeps a hook that never ran out of the "exited" verdict. Callers that distinguish "exited N" from "outcome never observed" can now trust that distinction: | failure mode | `error.code` | signal | verdict | | --- | --- | --- | --- | | non-zero exit | `23` | — | `exited 23` | | command not found | `127` | — | `exited 127` | | killed | `null` | SIGKILL | outcome not observed | | deadline expired | *(the deadline, not the exit)* | SIGTERM→SIGKILL | outcome not observed | | deadline expired, hook traps SIGTERM and exits 0 | `0` | — | outcome not observed | | spawn failure | `"ENOENT"` *(string)* | — | outcome not observed | **Termination reaches the process group.** The script is a shell and the work is its children, so signalling only the shell leaves a `sleep` or an `rsync` alive holding the pipes open. SIGTERM first, then SIGKILL after a grace. **One `classifyHookProcessResult`** now serves the native and WSL branches, which had been mapping a finished process to a hook verdict by hand, identically. That duplication predates this change. ## Terminating the tree, and a test that could not fail The escalation went wrong once in review, in a way worth recording. A first attempt skipped the SIGKILL when the *direct child* had already exited — a dead child needs no signal. That is correct about the child and wrong about the group: a hook that backgrounds a server typically loses its shell leader to the first SIGTERM while the server keeps running, so the skip fired in exactly the case the escalation exists for. The escalation now probes the **group** with signal 0: `ESRCH` means nothing is left to kill, anything else gets the signal. **The residual trade-off, stated rather than implied.** Signalling by negative pid names whatever group owns that pid *now*. Once the leader is reaped its pid can be recycled, and a probe cannot distinguish a surviving descendant from a stranger that inherited the number. Killing a runaway hook is both the likelier event and the one the deadline promises, so the group is signalled whenever it answers; the remaining window is pid wraparound inside the grace. **A test that cannot fail is worse than no test.** The first regression test drove `runHook` with `process.kill` intercepted — and passed against *both* the broken and the fixed version, because with signals intercepted nothing dies, so the child never reached the exited state the bad guard keyed on. It was false assurance, not coverage. `terminateHookTree` is therefore exported and the regression pinned directly against it: it fails on the old version (`expected [] to deeply equal [[-4242, 'SIGKILL']]`) and passes on this one. ## Behaviour change for `setup` hooks Both hook kinds share `runHook`, so this is not confined to archive hooks. **A setup hook that backgrounds a long-running server now has that server SIGTERM'd — then SIGKILL'd — with the rest of its process group when the deadline expires, where previously it was orphaned and survived.** Arguably the better behaviour, since an orphaned server is a leak, but it is a real change and should be a decision rather than a discovery. ## Evidence Against real shells and real signals, because this bug is invisible to a mock (`hook-archive-timeout-observation.test.ts`, through `runHook` itself rather than an extracted helper): ``` ✓ fails a hook that traps SIGTERM and exits zero, despite its zero exit ✓ settles at the deadline even when the hook refuses to die ✓ passes a hook that finishes inside its deadline ✓ reports an observed non-zero exit as the exit it is ``` Plus `hooks-archive-exit-observation.test.ts` for the wiring — including the string-`ENOENT` case — and `hook-archive-termination-safety.test.ts` for the escalation branching. ## Checks `pnpm tc` · `oxlint src` · `oxfmt --check` · 113 tests across `src/main/hooks*`. The classification table above is measured against real `exec`, not reasoned. |
||
|
|
d2d32691ef |
perf(persistence): skip redundant whole-state flushes on terminal reattach (#20137)
* perf(persistence): add pty-binding fast lane to skip redundant flushes Terminal pane reattachment currently clones the session and serializes the entire 9.2 MB app state even when the binding is already in place and durable. Add an early-return fast path that skips this work when all nine predicates hold: no split, binding matches in-memory and on-disk, incarnation matches, no tombstone, and generation counter proves durability. Includes one-line fix in `writeToDiskSync` to record hash-matched sync flushes as durable, so the fast path doesn't stay parked behind a stale generation. Adds `persistence.pty-binding` observability spans (local NDJSON, unsampled for mutations, budgeted for fast-lane hits) to measure eligibility rates before and after. Includes ratchet test to ensure every binding writer bumps the generation. Diagnostic tools and full investigation notes from September 7, 2026 capture that identified the 59–100 ms no-op binds and measured a real terminal keystroke queued 117 ms behind one such call. * perf(persistence): add pty-binding fast lane to skip redundant flushes Rapid rebinds of already-durable PTY bindings (e.g., remounting panes) were unnecessarily expensive because they cloned and flushed the entire document state every time. Detect when a binding hasn't changed since the last durable write and skip to return immediately, eliminating main-thread cost on that path. * perf(persistence): record binding.origin on the pty-binding span Fresh spawns always flush, so a fast-lane rate over all calls is diluted by however many terminals the user opened. Each caller knows whether it is a spawn, a reattach, a split, or a relay reattach; pass that through as metadata and record it so the reattach hit rate can be read from the trace file. Never branched on. * fix(persistence): keep the tab row on its first pane when a sibling pane binds A tab row names one PTY, but a split tab holds several panes. The renderer keeps the row on the first pane and refuses to let later split-pane spawns steal it, since a remount reattaches the tab to whatever the row says. Main overwrote it with whichever pane was binding, and the renderer's next publish put it back, so every sibling reattach was a state change and could never take the fast lane. On the real profile that is 38% of panes. Rewrite the row only when it names nothing useful: null, the PTY this leaf is replacing, or a PTY no leaf holds. The fast-lane predicate compares against the same rule. * perf(persistence): record durable pty-binding flushes per pane The global write generation is held back by any unrelated dirty state, causing bindings unchanged for minutes to appear unpersisted despite being on disk. Track per-pane durability to skip redundant flushes. * docs(persistence): describe the per-pane durability record The durability section still described the global generation check as the whole story and claimed there was no binding durability cache. Record the measurement that motivated the per-pane record, and why retiring one needs no cooperation from other binding writers. * docs(perf): consolidate every measured Orca performance issue into one register Folds the findings from all related debug sessions into the live lag investigation: the persistence/main-thread work (P1-P11), host contention (H1-H5), git and subprocess load on main (G1-G8), renderer and terminal rendering (R1-R8), the terminal daemon session leak from the deleted debug-orca-perf-issue worktree (D1-D9), and the Cmd-J palette review (C1-C6). Keeps the measurement behind each claim, records what is fixed versus open, and restates what the 117 ms keystroke delay still does not explain. * fix: address performance review findings * fix: satisfy diagnostic probe lint * chore: keep investigation artifacts out of performance PR * fix: run lag probe regression tests with Vitest * perf(persistence): replace pane receipts with global durability check * refactor(persistence): remove redundant binding review machinery * test(persistence): satisfy current assertion-free quality gate --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> |
||
|
|
33149fcde5 | fix(claude): install SessionEnd for capable versions (#20530) | ||
|
|
c287a5d9b7 |
feat(native-chat): add provider-aware Fast mode (#20506)
* feat(native-chat): add provider-aware fast mode * chore: drop unrelated formatter churn from the merge pnpm format reflowed pnpm-workspace.yaml quoting and a source-scan test that this PR does not otherwise touch. * fix(native-chat): review fixes for provider-aware fast mode Review pass over the Fast mode work. Claude reads its model catalog once per option write. The admit check, the effort guard and the Fast guard each took their own `list_models`, so a model write with Fast on paid two round trips for one list and let two guards answer from two different catalogs. The guards are now pure over a single read. Claude no longer refuses a Fast enable when the catalog identified nothing at all. An empty list is not evidence against a model -- the same rule the model admit-check already applies -- so a CLI that cannot answer would otherwise have Fast refused on every model. A catalog that did list the model and stayed silent about Fast is still not positive evidence and keeps refusing. Codex refuses a direct `serviceTier` write instead of accepting one the next turn discards. The turn derives the tier from `fastMode`; the key still restores so a session persisted before Fast existed migrates. Both option surfaces return a cached snapshot again. `SessionOptionsSurface` is read through `useSyncExternalStore`, whose contract is a stable snapshot, and rebuilding it per call breaks that for any consumer wired that way. Also records two decisions that were emergent rather than stated: routing Standard when Fast is on but no tier is named yet, and what a readback disagreement does and does not prove. Quality gate: merges the duplicate imports static analysis flagged, adds SAFETY rationales for two pre-existing casts the changed-code gate now sees, and drops a new assertion in favour of a checked narrowing. * fix(native-chat): read Claude Fast state from the session frame A fresh Claude session reports `fastModeState` while the settings readback still has no `fastMode` boolean, so the two are not redundant -- the frame answers at a moment the boolean has none. The picker fell back to "value unknown" and asked the user to disambiguate what the provider had already reported, and the state it reported had no reader at all. Falls back to the frame only when neither a pick nor the settings readback answers. `cooldown` throttles routing rather than clearing the pick, so it reads as on; reading it as off would flip a control nobody touched. Display only. The launch seed is untouched: an unset Fast preference still seeds nothing, which its own guard continues to pin. * perf(native-chat): skip the model catalog read when turning Fast off Turning Fast off needs no support evidence, so the read only cost a round trip — and restore replays a stored `false` on every acquire. Also narrows the alias-matcher comment: the effort and admit guards match on alias and resolved id only, so calling it the sole matcher overstated it. * fix(native-chat): clear a Claude Fast block once the child stops reporting it The child omits fast_mode_disabled_reason entirely when nothing blocks Fast and never sends a null, so requiring the key back latched the first reason for the session's life: switching to a model that disallows Fast and back retired the control for good, leaving a session running Fast with no way to turn it off. A frame that reports state without a reason is the all-clear. * test(native-chat): cover the mobile structured option hook useMobileStructuredAgentOptions gained generation fencing, a pending-write guard and a post-write options refresh with no test file. Pins the concurrency contract and the fast mode round trip: - a superseded options read is dropped instead of overwriting newer state - an overlapping write is refused and the pending guard is released after - an accepted same-fence write reads options back and applies the result, and a different-fence write does not - a boolean fastMode pick reaches the wire encoded and is remembered decoded - no Fast row when session support, catalog support or the model capability is missing Each behaviour was ablated against the production logic to confirm it fails without it. No production code changed. * feat(native-chat): render a boolean session option as one toggle On and Off were two radio rows under a header repeating the option name, so a binary choice cost three lines and two clicks to read. It is now a single switch row that owns its label, on desktop and mobile. An unknown value keeps its caption: a switch cannot say "unset". * fix(native-chat): resolve a boolean option's display value at the producer A boolean session option reached the UI in three states while its control had only two, so the renderer apologised for the gap with a "Current value unknown" caption beside a switch that had already collapsed to off. For `thinking`, whose catalog default is on, that caption sat next to a switch asserting the opposite of what every composed dispatch assumes. One expression fed both the displayed value and the option's provenance. Split them: the boolean descriptor now always carries a value, resolved to the same `values[id] ?? defaultValue` that buildNativeChatSessionOptionCommand already composes, while `valueSource` is untouched and still records whether anything confirmed it. `kind.currentValue` is required on the boolean arm so the third state cannot come back. The launch path is unaffected: resolveAgentSessionOptionLaunch and buildNativeChatSessionOptionCommand build the composed `--model` argument from the caller's picks and the catalog, never from a descriptor. Both surfaces mark an unconfirmed value instead of captioning it, and the two reasons stay distinct — `default` says the catalog value is what a launch will send, `unreported` says nothing has told us anything. Only `unreported` is reachable in the structured lane, where the agent may be routing a tier we have never been told about, so the two never share a label. * fix(native-chat): let assistive tech read the option value marker The marker was aria-hidden next to an explicit aria-label, so the label already won the accessible name and hiding it only cost screen reader users the default-vs-unreported distinction that sighted users get. It is now referenced by aria-describedby, which keeps the name Fast mode. Mobile's summary row said "Not set" for a boolean while the sheet behind it showed the switch on, so the two screens disagreed. A boolean always has a value; the summary states it and the sheet's marker qualifies it. * chore(i18n): drop the On/Off option strings the switch row retired Replacing the On/Off radio pair removed the only call sites for these two keys. i18next cannot rebuild a key with no call-site default, so leaving them in the catalogs forced them into the boot bundle as dead weight. Removing them shrinks it by two entries instead. |
||
|
|
2ce252f471 |
fix(grok): announce a completion once, when Grok is actually finished (#20523)
* fix(grok): announce a completion once, when Grok is actually finished Orca pinged on every Grok turn-end. Grok runs turns the user never asked for: when a background task finishes it wakes itself, does a little work, and ends another turn. One request produced several pings. Grok already reports, on every turn-end, whether it still has work outstanding. Read that instead of trying to classify which turns are "real": backgroundTasks absent -> silent, this is the session-end tail StopFailure / StopCancelled -> announce, a failure is never hidden stopHookActive -> silent, a Stop hook is keeping it working a shell task or subagent running -> silent, the work is not done otherwise -> announce Nothing here knows what an auto-wake turn is. A turn that ends with work outstanding stays quiet; the later turn where that work is finally done is the one that announces. That is also why this survives the case where Grok completes a user's goal inside one of those turns — prefix-based suppression would have silenced it. Monitors and scheduled entries are deliberately not counted as outstanding work. They can run indefinitely, so counting them would suppress a user's completion permanently, and a lost ping is worse than an extra one. Also registers StopCancelled, which Grok fires instead of Stop on a user interrupt, a declined permission, --max-turns, or a no-progress bail-out. Orca never subscribed to it, so those turns were reported as successes. Also removes a stale notification matcher that searched for prose the shipping binary never sends; the typed notification kind is matched instead, and neither idle_prompt nor task_complete is treated as a completion. Needs-input behaviour (permission prompts and ask_user_question waits) is unchanged and stays ungated by background work. * fix(grok): never hide a failed or cancelled turn behind the background-work gate The announce predicate checked field-absence before terminal outcome. Grok's StopFailure and StopCancelled payloads carry no background inventory at all, so the absent-field branch — added so the session-end tail stays silent — fired first and silenced every failure and every cancellation. That inverted the rule it was meant to serve. Before this series a cancelled turn at least surfaced as a (wrong) success; gated this way it surfaced as nothing. Terminal outcome is now checked first, so a failure or cancellation announces regardless of what other fields the payload happens to carry. The existing tests passed straight through the bug because they built failure payloads with a backgroundTasks field Grok never sends for those events. They now model the real payload shapes, verified against the provider's payload definitions and the captured envelopes. * fix(grok): settle completion from provider lifecycle state * fix(grok): fence stale turn ends without prompt ids |
||
|
|
9cf0a6c37f |
perf(remote): avoid repeated capability probes during file imports (#14555)
* perf: avoid repeated remote import capability probes * test: cover cold remote import compatibility probe * fix(remote): fence imports across runtime reconnects * fix(remote): bind import proof to connection * fix(remote): fence import routing by runtime identity * test(remote): remove unsafe import fixture assertions - type remote RPC mocks at declaration so call arguments stay checked - narrow upload params before reusing generated temp paths --------- Co-authored-by: Neil <neil@stably.ai> |
||
|
|
96b450fae8 |
fix(ssh): bound relay incumbent lsof probe (#18304)
* fix ssh relay incumbent probe timeout
* test ssh relay unconfirmed probe termination
* fix(ssh): preserve connect evidence while bounding lsof
* fix: preserve uncertainty when relay holder enumeration fails
* fix(ssh): supervise lsof helpers and preserve partial holder evidence
* test(ssh): prevent GC racing unconfirmed probe cleanup
* refactor(process): keep POSIX lsof supervision in process owner
* fix(ssh): confirm census cleanup and handle probe startup signals
* fix(ssh): keep lsof holder evidence usable on hosts with unstat-able mounts
lsof warns to stderr about mounts it cannot stat, and any stderr byte forced
the holder enumeration to 'unavailable' — making the 'exited' verdict, and so
husk reaping, unreachable on those hosts. Pass -w to suppress the warnings.
* Revert "fix(ssh): keep lsof holder evidence usable on hosts with unstat-able mounts"
This reverts commit
|
||
|
|
3ab2a1b91c |
refactor(orchestration): derive delivery eligibility from messages (#19837)
* fix(orchestration): retire read deliveries and clarify mailbox recovery * fix(orchestration): simplify delivery recovery and update nudge contracts * test: align orchestration check help expectation * refactor(orchestration): derive delivery eligibility from messages * fix(orchestration): validate live consumers and simplify batch revocation * refactor(orchestration): keep deliveries.status and derive eligibility without a column drop The outstanding_deliveries view now reads status = 'outstanding' plus unread membership, so v41 only drops uniqueness from idx_deliveries_one_outstanding and adds the view and trigger. Older binaries can still open the database. Removes the column-drop migration, the v40 test fixture and hasColumn guards, the fenced skew probe, and the unrelated nudge-text change. * docs(orchestration): drop delivery storage reference The compatibility caveat it existed to explain no longer applies; the view and index comments carry the remaining rationale. * docs: revert unrelated formatter churn * test(orchestration): verify historical database downgrade round trip |
||
|
|
31db2774f8 |
fix(git): skip upstream remote probes when the remote is absent (#18455)
* fix(git): skip upstream remote probes when the remote is absent Issue and PR resolvers listed remotes by probing `git remote get-url upstream` on every poll, including origin-only clones where that remote cannot exist. List remotes once, cache against git config, and skip the probe unless `upstream` is present. * fix(git): avoid stale remote probe cache entries * fix(github): observe origin repository probe failures * fix(github): observe verified origin probe failures * fix(github): skip missing upstream probe for PR lists * test(github): scope the #9171 lazy-resolution guard to default-branch commands The guard asserted that no git command runs for an open PR, using "no git at all" as a proxy for "no default-branch resolution". Remote-name listing is a separate concern, so allow it and keep every other command forbidden; the symbolic-ref/rev-parse resolution this issue is about stays unreachable. --------- Co-authored-by: Neil <neil@stably.ai> |
||
|
|
16d1ab81d3 |
perf(skills): bound WSL installed skill discovery (#12314)
* perf(skills): bound WSL installed skill discovery * fix(skills): preserve bounded discovery correctness * fix(skills): bound WSL metadata prefilter reads * fix(skills): isolate absent discovery cwd cache keys * test(skills): adapt WSL discovery mocks to runner * fix(skills): preserve filtered discovery fallbacks * fix(skills): share filtered scans and preserve WSL inventory * fix(skills): share WSL scans without losing skill aliases * fix: preserve skill metadata and retire filtered peer caches --------- Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
c21c083224 |
fix(auth): report callback failures instead of cancellation (#20535)
* fix(auth): distinguish failed sign-ins from user cancellation * test(mobile): fix conditional registration lint and refresh recorder fingerprints |
||
|
|
4784fa0087 | fix(grok): defer managed hook pane guard expansion to shell (#20534) |