mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
ae426bfbeb024fc4d0f3c7ac0dafc84dfcdacc4e
4054
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
027acb4efa |
fix(native-chat): settle a structured send on admission, not on the provider echo (#19863)
* fix(native-chat): settle a structured send on admission, not on the provider echo Sending a message in structured native chat raised "Message delivery is unconfirmed." with a Retry button on a message that had in fact been delivered. Measured across 14 days of local journals: 44 of 173 delivered sends (25.4%) tripped it. The dispatch path wrote the message to the provider, then waited a fixed 10s for the provider to echo the message's uuid back. That echo is emitted when the provider STARTS the turn, so a message queued behind a running turn cannot be echoed until that turn ends. Echo latency is bounded by the previous turn's duration, which is unbounded -- one send took 105 minutes. The 10s constant sat at the p75 of real echo latency, with the slowest clean send at 9.76s, a margin of 0.24s. No constant can work: the wait was measuring the wrong event. The false banner was not cosmetic. It invited a Retry, and Retry bypassed the operation ledger to redeliver. One message reached the model five times through that path. Dispatch now returns as soon as the transport write completes and writes no dispatch row; the submission stays `pending`, a neutral state, and the provider's echo settles it `accepted` through the late-settlement channel whenever the turn ahead of it ends. Delivery doubt is reachable only from process facts -- a refused write, a dead child, a dead host -- never from elapsed time. Retry re-delivers only where the recorded reason proves the message never reached the provider. The list is deliberately fail-closed: refusing a legitimate retry costs the user a re-type, while allowing an illegitimate one sends the model a second copy of their message. A refused entry now leaves the outbox with an explicit notice instead of parking at the head, where it would have wedged every message queued behind it. The send-response classification moves to a pure module beside the existing outbox reconciler, so both writers of an entry's state now live together and the decision is unit-testable rather than reachable only through the hook. Scope and known gaps: - Codex carries the same 10s stopwatch. It has no late-settlement channel, matches waiters by queue order rather than identity, and has no waiter lifecycle at all, so there was no safe subset to land here. A marker constant records the debt and deletes itself when that lands. - A message refused re-delivery loses its standing delivery notice and leaves only a transient error line. A passive "waiting to be accepted" affordance is the follow-up. - The restart reconciler that would decide a dead child or a dead host on evidence rather than refusing them is fully written and has never had a production caller. Wiring it is the next change, and it removes the re-type cost above. * fix(native-chat): harden structured dispatch settlement * fix(native-chat): preserve dispatch recovery evidence * fix(native-chat): preserve pending send compatibility * fix(native-chat): satisfy native import audit * fix(native-chat): bound legacy send settlement --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
fb85f88d64 |
fix(browser): restore the Chrome-shaped browser identity (STA-7147) (#19927)
* fix(browser): restore the Chrome-shaped browser identity (STA-7147) #18749 replaced every browser partition's Chrome-shaped UA with Electron's stock one, so since v1.4.198 the embedded browser announces itself on every non-Google host as: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Orca/1.4.198 Chrome/150.0.7871.224 Electron/43.4.1 Safari/537.36 No browser sends that. Sites that re-check the identity holding a session reject it: users report being signed out of x.com, LinkedIn and "most websites," and at least one was signed out of LinkedIn in their own Chrome and met LinkedIn's "suspicious activity" SMS check -- server-side revocation, which reaches beyond our app. The repo already documented the mechanism in browser-google-auth-ua.ts: copied-in cookies "sent under a UA that doesn't match a real first-party browser get flagged by anti-fraud." That is why the Google auth-host switch exists; #18749 kept it for accounts.google.com and handed every other host an Electron identity. Restore the pre-#18749 session identity: strip the Electron and app tokens, and rewrite sec-ch-ua to match. Nothing in the cookie-import write path changed -- it never did; cookies were always written correctly and servers were refusing them. Deliberately KEPT from #18749, all independent of the UA: - anti-detection.ts stays deleted. Its premises were measured false on Electron 43 and its overrides are themselves published bot signatures. - No Runtime.enable into cross-origin iframes (the documented Cloudflare CDP tell). - No unconditional CDP debugger attach on every browsing guest. Known tradeoff, measured: this re-opens #13822. On the unmerged predecessor branch brennan/sta-3905-cloudflare-ua, commit 9f0a4772fe recorded the stock UA clearing dash.cloudflare.com 5/5 while every rewritten variant failed 12/12, and noted that adding client hints does not rescue it. So Cloudflare-gated sites will show verification failures again until a coherent-identity fix lands. That is a bounded, in-app annoyance; session revocation damages users' real accounts. A CDP Emulation.setUserAgentOverride with full userAgentMetadata -- which drives navigator.userAgentData as well as the headers, and was never tested -- is the candidate that could satisfy both, and is being measured separately. Tests: the real-Electron wire-identity test now asserts the stripped identity on ordinary hosts and Firefox on Google auth hosts. Ablation-verified: neutering cleanElectronUserAgent turns it red on the Electron-token assertion. Its fixture also gained an app name -- without one the raw UA carried no app token, so the Orca/x.y.z half of the cleaner was never exercised. * fix(browser): finish the identity revert in the files CI caught browser-session-registry.persistence.test.ts still asserted #18749's behaviour ("keeps the stock UA", "keeps the engine UA"), so the shipped code and its test disagreed. Caught by CI shard 4/8, not locally: I reverted four test files and went to typecheck without re-running the browser suite. Also restores the accurate wording that #18749 generalised away, now that the behaviour it described is back: - browser-google-auth-ua.ts: names the Electron/Chrome-shaped UA again as what anti-fraud flags, which is the reason the auth-host switch exists at all. - docs/browser/profiles.mdx: documents the cleaned Chrome UA default and the --no-ua-spoof escape hatch, which is real again. - tests/tools/google-signin-ua-probe.cjs: comments name the live handler. Deliberately left at #18749's version, because those changes stay correct with anti-detection.ts deleted: - browser-manager-viewport.ts: its comment no longer cites the retired addScriptToEvaluateOnNewDocument injection. - browser-webauthn-profile-delete.test.ts: its added webRequest mock is REQUIRED by the restored setupClientHintsOverride, so reverting it would break the test. * fix(browser): keep restored UA hints browser-owned --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
33436c30d8 |
refactor(native-chat): unify agent session launch and open drafts in structured chat (#19681)
* wip(native-chat): first-pass draft routing into structured chat (to be reworked)
* refactor(native-chat): gather agent launch route inputs in one builder
Every launch entrypoint assembled the route resolver's inputs by hand and
they disagreed: only three of seven passed the project runtime blocker, so
a WSL-pinned project was refused structured chat from the tab bar but
admitted from the create dialogs. buildAgentLaunchRouteInput is now the
one place that gathers host, capabilities, workspace kind, project runtime
and TUI customization, and works for workspaces that do not exist yet.
Also deletes the dead draft-prompt blocker from the shared resolver; the
renderer stopped passing it and the main process never did.
* refactor(native-chat): share one structured launch settle loop
Five entrypoints copied the same loop around startStructuredAgentLaunch:
start, claim a refusal fallback, await, branch on refusal or unknown. The
copies drifted: direct work-item and full create reported an unexpected
launch error as success, and resume handled neither refusal nor unknown.
settleStructuredAgentLaunch now owns that loop and returns one settlement
(structured, refused-then-legacy, cancelled, visibility-unknown, failed).
Direct work-item, full create, folder workspace, both onboarding folder
paths and vault resume consume it; each keeps only its own legacy fallback.
Resume deliberately has no fallback. Unknown outcomes release the caller
uniformly so a stale fallback closure cannot fire on a later reconcile.
* refactor(native-chat): route the new-tab launcher through the shared settle loop
The new-tab launcher fired its refusal fallback and forgot it: nobody
learned whether the terminal fallback ran, and a visibility-unknown outcome
was never surfaced. Its structured branch now runs through
settleStructuredAgentLaunch with the terminal launch as the legacy fallback.
launchAgentInNewTab stays synchronous; the result gains a structuredSettlement
promise, and promptDeliveryResult keeps following the terminal fallback's
delivery on refusal as it did through the callers bridge before.
* refactor(native-chat): one legacy prompt delivery path and one trust preflight
The direct work-item flow kept its own seed-and-paste copy of the legacy
prompt delivery; it now uses deliverLaunchPromptToAgentTab with its own
timeout notice supplied as a callback. Three private copies of the trust
preflight (session continuation, worktree creation, folder workspace) fold
onto preflightAgentTrust. The direct work-item pre-launch mark keeps its own
entry because it differs in timing, not mechanism.
* refactor(native-chat): run quick create through the shared settle loop
Quick create was the last entrypoint driving the launch handle itself,
because its cancel lifecycle is real: when the creation is abandoned the
structured launch must be cancelled immediately so a staged prompt never
reaches the provider. The shared loop now takes a cancellation hook with an
eager subscription plus a post-await check; it cancels the launch once,
unsubscribes on settle, and reports cancelled without running the fallback.
Quick create keeps its two-branch legacy fallback and retire-on-late-cancel.
Also updates the surface-caller census for the onboarding launch module
that step 2 introduced.
* fix(native-chat): open editable drafts in structured chat for eligible local Codex launches
Route order asked the default-view-mode question first, and that decider
applies the terminal mirror gate (a TUI cannot clear more than forty lines
of prefilled draft), so a PR body over forty lines reached the plain
terminal before structured eligibility was checked. Structured eligibility
now comes first; the mirror gate applies only on the legacy branch.
The structured draft seed writes the launch-draft store directly with no
mirror gate, since a structured session has no terminal copy to fall back
on. Closing a settled structured tab clears an unadopted seed. The
structured session treats idle and loading as unsettled so the adoption
hook takes its baseline from the loaded transcript. Each caller passes one
delivery-mode value to both the route builder and the settle loop.
The structured session component test is split with a shared harness so
it stays under the test file line cap.
* test(native-chat): make the structured session test harness type-portable
* fix(native-chat): close review gaps in the shared launch settle loop
- Claim a refusal fallback only when the caller supplies one, so vault
resume no longer reports a terminal fallback it never opened.
- A failed or cancelled direct work-item launch returns no tab id, so the
caller never pastes the prompt into a setup shell.
- Terminal fork activates with providesInitialSurface for structured
launches and gates its toast on the settlement; the draft blocker
deletion made fork route structured too.
- A failed launch clears its draft seed. The failure toast moves to its own
module to keep the launch-state file under the line cap.
- Ratchet for settle-loop callers; cancel-during-fallback documented.
- Restore the local agent label lookup that the pane-agent identity
inventory expects instead of the inventoried helper.
* fix(native-chat): resolve the agent label through one module
* fix(terminal-pane): keep the fork dialog from reopening a created worktree
A failed or unknown structured settlement returned false after the fork
worktree already existed, so the dialog stayed open and a second click
created another worktree. Unknown now closes the dialog (the launch badge
already reports it); failed copies the context the way a null launch does.
* chore: restore pnpm-lock.yaml to main (local pnpm rewrite slipped into a commit)
* test(native-chat): stop asserting the deleted draft feasibility input
The routing-authority test expected the shared predicate to receive
isDraftPrompt; delivery mode is prompt metadata and never reaches
feasibility now, so assert its absence instead.
* refactor(native-chat): decide every agent launch route in one planner
The route was still resolved at seven callers, each also calling the settle
loop; two census tests only stopped an eighth. planAgentSessionLaunch is now
the one production caller of the resolver and its launch() the one caller of
the settle loop, and both censuses pin exactly that file.
The funnel is two-phase because three sites need the route before the
workspace exists and quick create persists its request for recovery: a plan
exposes route before creation and launches with the created worktree id;
a persisted quick-create request carries the verdict as data and re-enters
through adoptAgentSessionLaunchVerdict without re-resolving. Delivery mode
is fixed on the request once, so route and launch cannot disagree.
* test(native-chat): pin the two adopters of a planned launch verdict
* fix(native-chat): answer route readability from the repo when the worktree row is absent
The planner's transcript-readability input dropped the repo-level connection
fallback the direct work-item path still computes for its startup payload, so a
route planned in the window right after workspace creation saw `undefined` —
which reads as "not locally readable" — and downgraded grok/omp launches from
native chat to a raw terminal. Only `undefined` ("cannot determine the host")
now defers to the repo; a resolved `null` stays the local answer.
* refactor(native-chat): answer structured feasibility with a query, not a launch plan
Every rendered AI Vault row built a whole launch plan — execution-host lookup,
project-runtime resolution, capability read, plus a plan object and a launch
closure it threw away — to read one boolean off it. Feasibility and a launch
decision are different operations, so the planner now exports the predicate for
the first and keeps the plan for the second, and the census pins the query's
callers separately. Settings arrive by argument, which makes the AI Vault
callback's dependency on them real rather than a comment the linter contradicts.
The plan's `explicitStructured` branch had that gate as its only caller and goes
with it; the vault's launch already re-enters on an adopted verdict.
* refactor(terminal-pane): fold the fork's trust preflight onto the canonical one
`preflightForkAgentTrust` was a behavioural duplicate of `preflightAgentTrust`,
whose signature now accepts a nullable agent and workspace path and so is a
drop-in replacement. Its file is left holding only the launch-platform resolver
— which is not a duplicate, since it returns an override rather than a default —
so the file is renamed for what it now contains.
* refactor(native-chat): cancel a structured launch through an AbortSignal
The settle loop's launch cancellation re-derived the standard poll-plus-eager-
event primitive that `AbortSignal` already is, so it now takes one. The eager
semantics are unchanged: the loop still cancels on the abort event rather than
only polling after awaits, so a staged prompt is discarded before it reaches the
provider, and it drops its listener on settle instead of leaving the signal
holding the closure. Quick create owns the controller and bridges its store
subscription to it.
A cancel that lands after the refusal fallback already opened a terminal now
carries that surface on the settlement. It is the fallback's tab that exists, so
reporting the pre-launch one handed the caller a workspace with no agent in it.
* fix(native-chat): tighten quick create's structured launch settle path
Four things the launch path got wrong once the settle loop owned the flow:
- The abandoned-creation check now runs before the first-message rename flag is
written, so a creation being torn down is no longer marked for a rename that
will never happen (the order the pre-planner code had).
- A cancel that arrives after the refusal fallback opened its terminal reports
that terminal rather than the pre-launch tab.
- `plan.launch` is called outside the caller's try, and nothing awaits that
caller, so a throw there would strand the creation panel. It is now caught and
reported the way a failed launch already is.
- The launch route is a required argument instead of defaulting to
`terminal-tui`, which would have silently reported success with no surface
opened. Both callers already gate on the structured route.
* fix(native-chat): give one launch identity one prompt delivery mode
A caller joining a pending launch computed its outbox text from its own delivery
mode, so an auto-submit caller landing on a draft launch enqueued text the first
caller's seed was already showing in the composer: the user saw it and it was
sent. The mode is now fixed by the caller that opened the launch, and a joiner
delivers its text that way.
Seeding also moved to where the coalesce decision is made, so a launch whose
callers already settled as refused is not given a fresh draft — the refusal path
early-returns, so nothing would ever clear it and it would outlive every tab.
* fix(work-item): report a failed structured launch as a failed direct launch
`launchWorkItemDirect` returned true unconditionally, so a structured launch
that opened no surface still read as a started workspace. Callers hang
irreversible follow-up work off that boolean — the fix-checks dialog fires
`onLaunched` on it, which is documented as the home for host writes — so a
launch with no agent tab now reports false, matching what full create does.
The settle result says so explicitly rather than leaving callers to infer it
from a null tab id, which `notLaunched` also produces.
* test(session-tabs): pin the id a first structured publication is minted under
The launch draft seed is keyed on `structuredAgentSessionTabId(sessionId)`
before the tab exists, while the mirror mints ids with collision avoidance that
can append a `:history-N` suffix. The two agree today only because a fresh
session's base id is unique. Pin that where the id is actually minted, with the
collision arm alongside it so the divergence the seed depends on staying away is
visible rather than assumed.
* test(native-chat): pin the route connection fallback on the un-mocked resolver
The suite that covers the builder stages `getConnectionIdFromState`, so it can
characterize the fallback but cannot catch a defect that lives in owner
resolution itself. This one runs the real resolution over real store rows: two
repos publishing the same worktree id on different hosts, which is the
documented case where the owner cannot be named and `undefined` is returned.
Red with both fix files at the previous head, green with them.
Reverts the two caller pins added to the route census — the feasibility
predicate is exported from the planner, which the census already permits, so it
passes unedited and needs no permit clause.
* fix(native-chat): keep the structured launch's own agent eligibility check
Quick create's structured launch narrowed its guard to a bare `agent` presence
check, so a creation carrying an agent that cannot hold a structured session
reported itself cancelled once dismissed, where it previously reported that it
had done nothing. Unreachable through both callers today, but it is the last
local eligibility check in a module that otherwise trusts its callers for the
route, so it is restored rather than left to the required-route typing — which
says nothing about the agent.
Also corrects two comments that called the quick-create request "persisted".
It lives in renderer session memory and dies with the renderer; calling it
persisted made the plan/adopt split read as restart recovery, when what it
actually buys is a route decided before the worktree exists.
* fix(native-chat): keep the structured feasibility query typecheck-clean
The query threaded its narrow settings through the store, but the route
store's settings must satisfy the full GlobalSettings that two of its
resolvers require, so the narrow copy never fit. Ride the named settings
on the built input instead: the caller still names them, so a React memo
still depends on them, and no store-shaped object is needed.
Also give the launch state its delivery mode unconditionally; the key is
required, and a conditional spread makes it optional under
exactOptionalPropertyTypes.
* docs(native-chat): name the feasibility query's one remaining settings asymmetry
The builder reads launch customization off the store while the routing gate
reads the named settings, so one answer has two settings sources. It cannot
diverge with the single caller passing the object the store already holds, but a
PR about removing split sources should not leave that unstated.
* fix(native-chat): keep a coalesced joiner's draft unsent
joinLaunchDelivery stripped the joiner's delivery mode when the launch it
joined had established none, and an absent mode reads as submit. A joiner
that asked for a draft therefore had its text sent — the send-without-
consent this PR exists to prevent. Fall back to the joiner's own mode only
when nothing was established, so the first caller still wins otherwise.
* chore: re-trigger CI
GitHub created no workflow run for
|
||
|
|
2626e2eca4 |
Make the structured turn lifecycle row durable so completed durations survive (#19695)
* Make the structured turn lifecycle row durable so completed durations survive A structured-chat turn used to end by tombstoning its running lifecycle item, which threw away the only durable record of when the turn ended. Completed "Worked for" labels therefore depended on the renderer having observed the turn finish, and vanished on reopen. The lifecycle item is now revised in place, never tombstoned: - running, with startedAt, at the provider's turn start - completed or interrupted, with completedAt, at the provider's terminal frame, a user stop, or a child exit the host observed - unverifiable, with no end, when a cold acquire finds a running row from a generation whose exit nobody observed Both timestamps are the execution host's clock at receipt, captured before the deferred sink, so the completed value is identical on every client and needs no client clock. Codex history restore uses the provider's own second-granular endpoints for turns that predate this change. Desktop and mobile read settled durations off the journal through one shared selector, and anchor the live counter on the host start with the client's local receipt so a skewed client clock never leaks into the label. Locally observed durations remain the fallback for hosts that still tombstone. Timestamps live inside the existing turnLifecycle field, which old clients strip, and every working-state consumer keys on state === 'running', so no capability negotiation is needed. * native-chat: avoid stale working status on settled turns * test: align settled turn status expectations * Name settled lifecycle rows by their terminal state An interrupted or unverifiable turn must not read as completed for any consumer that renders status text raw. One shared helper builds the text for both providers from the lifecycle state. * test: deduplicate turn lifecycle suites Each behavior keeps one test; duplicated harnesses and restated cases go. * Key lifecycle rows to their user item and record the provider's measured duration A lifecycle row now names the user item that opened the turn by its provider key, so clients attribute timing explicitly and fall back to journal order only for rows from older hosts. A provider-initiated turn with no prompt can no longer claim the previous prompt's duration. When the provider measures the turn itself (Codex turn.durationMs, Claude result.duration_ms) the terminal row records it and clients prefer it over the host interval, so a turn shows the same number live and after a history restore. Host receipt times remain the live-counter anchor and the fallback. * Record a turn as a first-class journal item The turn record is now its own item kind rather than a status row carrying a lifecycle field: no text to misuse, and the fold matches the durable turn record other systems keep. Rows that carry it are stamped journal schema v3; every other row stays v2, so an older host keeps reading them and latches read-only at the first v3 row instead of truncating the epoch. Clients that predate the item would paint an unknown kind as a text bubble, so the host publishes the legacy status form to any client that does not advertise agent-session.turn-item.v1, through the same per-client seam background tasks use. The downgrade is transitional and goes once no supported release lacks the capability. The shared projection now renders unknown item kinds as nothing, so later kinds need no gate. One shared reader handles both forms for old journals and old hosts. * Preserve observed turn end across settlement retries * Retain turn attribution for loaded chat history * Preserve Codex exit receipt across close retries * Register completed turn duration reliability gate * Keep earlier turns through a Codex rewind and count a mid-turn attach from the real start Findings from an independent adversarial review of the typed turn record: - A Codex rewind adopted the provider's item list as the new epoch, and the provider never returns the host's own turn rows, so every duration before the rewind point vanished. The host's turn rows are now spliced back beside the item each followed, and recovery no longer expects the provider to prove rows it never owned. - The epoch row was stamped with the current schema version, so an older host latched read-only at row 1 of every new session, defeating the mixed version design. It carries no body and stays at v2; a stored-row test now reads SQLite directly, because the reader upcasts every row on read. - A send Codex folds into a running turn shares the opening prompt's provider key, and the alias map credited the duration to the later prompt. The earliest submission naming a key now wins. - The live counter anchored on first sight, so a client attaching mid-turn counted from zero. Published frames now carry the host's clock, the reducer keeps the last sample with its local receipt time, and both clients anchor on how long the host says the turn has run. * Correct turn duration gate assertion reference * Respect authoritative unknown native chat duration * Preserve unverifiable timing across older host upgrade * Record final completed turn duration reliability evidence * Fix the CI failures the merge left behind - A merged import list named the same module twice, which the native code quality plugin fails on. - A running turn is now reported by the host with no duration, so the settled map carries an explicit null for it; the hook test still expected the entry to be absent. - main gave the older-page action a cursor with a head-trim guard, so the retention test's epoch-only action no longer typechecks; it now passes an unbounded sequence, which is what the old shape meant. - The roster comparator moved into the extracted module, leaving its import unused in the reducer. * Split two files back under the line cap after the merge Merging main put both one effective line over 300, and the cap forbids a disable or a shave. The wire module's refusal vocabulary moves to its own file and is re-exported, so its consumers are untouched; the host's four thin mutation delegates move next to the functions they call. * Advertise the turn-item capability on every client transport Local IPC and mobile advertised it; the remote and web transports did not, so a desktop paired to a remote host, the CLI, and web silently ran on the legacy carrier forever and the canonical row was never exercised there. The renderer that paints it is the same build on every transport. * Update the web auth-frame expectation for the new capability --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
6e9de5fa58 |
fix(orchestration): revalidate an attempted Enter instead of resending it (#19911)
When a PTY retires mid-delivery, every staged message was marked undelivered, which made all of them redeliverable. That is right for a pointer whose Enter never fired, but an Enter that was already written may have landed: redelivering it types the same mail into the pane a second time. The Enter timer is cleared at the top of retirement, so a RESERVED or WRITE_ATTEMPTED pointer provably never submitted and is released. An ENTER_ATTEMPTED pointer is ambiguous and now stays at its phase for the resume path to revalidate, matching the policy mailbox-pointer-submit.ts already documents for an unverifiable settlement. Co-authored-by: Merge Sim <sim@local> |
||
|
|
f2af92b2fa |
feat(native-chat): show live background work and name each row by kind (#19705)
* fix(codex): reserve the label's share of a qualified command row
A child's label is raw provider text and was spliced into the command
row unbounded, then the pair clipped to the description cap. A label at
or past that cap clipped the command away entirely, leaving a row of
kind 'command' that named an agent and showed no command - the failure
qualification exists to remove, inverted. The same clip could also cut a
surrogate pair, which boundSubagentField already guards against on the
agent row two lines away.
Give the label a reserved share and clip it the way the agent row does.
* feat(native-chat): show live background work and name each row by kind
The strip suppressed itself in three places: the Claude tracker blanked
its roster for the whole of any turn, the Codex tracker returned nothing
while a primary turn was open, and the renderer view gated on
`turnId === null`. Between them, work in flight was never shown — and a
task backgrounded in an earlier turn vanished from the strip as soon as
the next prompt was sent. Claude additionally dropped every foreground
subagent, so a fan-out reported nothing at all.
Report work while it is live, in all three layers. Foreground Claude
work is turn-scoped, so `result` retires it — that is the provider's own
outcome for a task it marked foreground, not a roster sweep. Nothing
settles a Codex child on turn end: those keep reporting well past their
parent, so turn frames only prompt a republish.
Name each ROW by kind — Subagent, Shell command, Workflow, Monitor —
instead of a generic "Background <kind>", each drawing the glyph the
shared tool-icon table already uses for that category. A row that
carries a provider description still shows it unchanged. The collapsed
header summary is deliberately untouched; it is owned elsewhere.
The conversation-command gate is unchanged in effect: an open turn
already refuses first, and Claude foreground work never reaches the
backgrounded set the gate reads.
* fix(native-chat): withhold the row stop Claude foreground work cannot honour
The strip now publishes foreground rows, but `stoppableTaskIds` still filters
on `backgrounded`, so `stopClaudeBackgroundTasks` resolved an empty target list
and returned `{ cancelled: false }` that no renderer reads: the user clicked
"Stop Subagent" and nothing ever happened.
Carry stoppability per row instead of widening the stop to a target the SDK has
no way to reach. `AgentSessionBackgroundTask.stoppable` is absent-means-yes, so
hosts that predate it keep their working control, Claude emits `false` only on
foreground rows, and the strip hides that row's button the same way it already
hides the stop-all a provider cannot honour.
* fix(claude): scope aggregate-roster authority to the work it enumerates
`background_tasks_changed` lists BACKGROUNDED tasks, so a foreground subagent
can never appear in it. Treating it as the whole world meant any such frame
cleared every live foreground row mid-flight and then dropped every later
foreground `task_started` for the rest of the session, killing the in-turn
fan-out the strip exists to show in any session that ever backgrounds anything.
Decide `backgrounded` before the staleness guard and apply the guard only to a
backgrounded start, and retain live foreground entries across a roster replace.
Retained rows count against MAX_TRACKED_TASKS, so the map stays bounded, and a
stale backgrounded start the roster no longer lists is still dropped.
* test(native-chat): pin the strip's monitor amber to the constant that defines it
`MONITOR_GLYPH_COLOR`'s comment claimed a test held it and AgentStateDot's amber
together, but no test imported it — the assertions hardcoded 'text-yellow-500',
so the two could drift with every test still green. Read the colour from the
module, which is what the comment always said was happening. Drop the unused
`BackgroundTaskGlyph` export too: nothing outside the module names it.
* fix(native-chat): keep the task list open across a gap in live work
The strip is now mounted on live work, so a sequential fan-out unmounts it
between one subagent finishing and the next starting: local `useState` meant
the expanded list collapsed itself on every such gap, on top of the strip
flickering above the composer.
Hand the disclosure to the session, keyed by session id so it does not leak
across a session switch. The strip is now controlled and holds no state of its
own, which is what makes it survive its own mount churn.
* fix(codex): route every command-row cut through one surrogate-safe clip
`boundLabel` avoided splitting a pair, then `qualifiedDescription` re-cut the
COMPOSED string with a raw slice: label (<=96) plus separator plus description
(<=512) is up to 611 chars, so that second cut landed at an arbitrary index
inside the description and could publish a lone high surrogate — lossy through
any non-JSON UTF-8 hop. `parse` had the identical hazard on an unqualified
primary-thread command.
One `boundText` helper now owns all three cuts, so no path in the file can emit
a lone surrogate from well-formed input.
* fix(claude): keep terminal evidence for ids an aggregate roster never lists
Narrowing the admission guard to backgrounded starts left a finished FOREGROUND
id with no defence: `replaceAggregateRoster` wiped `terminalTaskIds` wholesale,
so after any `background_tasks_changed` a replayed `task_started` revived a task
whose completion had already been seen — and only a later `result` could settle
it again.
Scope the wipe the same way the guard was scoped: delete only the ids the
incoming roster actually enumerates. A roster still overrules terminal evidence
for the work it lists, which is what that behaviour was added for.
* fix(claude): keep retained rows in place and evict the stalest, not the newest
Re-adding retained foreground entries after the roster made a live row the user
is reading jump below the backgrounded rows on every `background_tasks_changed`,
and the cap `break` kept the STALEST retained rows while dropping the newest.
Merge in the tracked map's own order so a surviving row holds its position, and
count the overflow up front so eviction takes the oldest retained rows. Roster
entries are never starved and the map stays bounded either way.
* fix(claude): retire leftover foreground rows when the next turn starts
A foreground `task_started` arriving with no turn open has no `result` coming
to retire it, so it sat in the strip indefinitely — with no per-row stop, since
foreground rows are not stoppable — and refused conversation commands behind an
instruction nobody could follow.
Settle on turn start as well as on `result`. This is cleanup only: visibility
never consults `startsTurn`, so a missed one degrades to today's behaviour and
can never switch the feature off. It shortens the row's life to the next turn;
the case where no further turn is ever sent is filed separately.
* fix(agent-session): withhold unstoppable rows from readers that predate them
Rule 3 of remote-wire-compatibility: changing what the host publishes reaches
old clients with no wire change. The Claude host published no foreground rows
before this feature; it does now, and a client that cannot read `stoppable`
draws a per-row Stop on every one of them — Claude always sets
`supportsTaskStop` — which filters to the backgrounded ids, stops nothing, and
returns a result no renderer inspects. That is the dead button `stoppable` was
added to remove, reappearing across a version skew.
Negotiate it. A client can advertise the existing background-task-stop
capability and still predate `stoppable`, so this needs its own constant.
Readers that do not advertise it get unstoppable rows dropped, and a state whose
every row is dropped becomes no strip — exactly their pre-feature view.
RUNTIME_PROTOCOL_VERSION is not bumped: this adds an optional field and a new
negotiated capability, and changes no existing field's meaning, which is the
explicit do-not-bump case in protocol-version.ts.
* test(agent-session): name the projected rows so the fixture typechecks
An indexed lookup into the fixture's task list is possibly-undefined under
`pnpm tc`; the rows are more readable named anyway.
* test(web): advertise the row-stop capability in the e2ee auth expectation
The web e2ee handshake started sending
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, and this test asserts the
advertised list by deep equality, so it went red on CI while every targeted
test run stayed green. Add the capability in the position the router sends it.
* test(claude): pin why the roster empties mid-turn in a sequential fan-out
The strip unmounting between two sequential subagents is truthful, not a swept
row: A leaves on the provider's own terminal frame, B does not exist yet, and
backgrounded work spanning the same gap holds the roster open — so an empty
roster is never work the strip is hiding.
Also pins the previous-turn rule against the one the subagent roster already
applies on the same frame: a still-working FOREGROUND child becomes
`unverifiable` there and a backgrounded one is left alone, so the strip drops
the first and keeps the second rather than asserting `live` for either.
---------
Co-authored-by: Merge Sim <merge-sim@users.noreply.github.com>
Co-authored-by: Merge Sim <sim@local>
|
||
|
|
4b1b7178ad |
fix(orchestration): scope @ group addresses to the sender's Run (#19783)
* fix(orchestration): scope @ group addresses to the sender's Run `@all`, `@idle`, and the agent-name groups (`@claude`, `@codex`, ...) resolved against every terminal on the host. A coordinator meaning "my three reviewers" reached 126 agents across every open project, twice in one day, and every unrelated agent burned a turn discarding mail that was never for it. Every group except `@worktree:<id>` now means the live Dispatches of the sender's own Run, each addressed as `dispatch:<id>` so delivery is durable even when the worker terminal is not attached yet. A sender bound to no Run is refused with `invalid_argument` naming `run:<id>` / `dispatch:<id>`; there is no host-wide fallback and the host's terminals are never enumerated for it. `@idle` and the agent-name groups filter within that set by the same terminal status and host-resolved identity as before. `ask --to @group` returns the same code and points at the owning Run mailbox. Federated Dispatches read relayed control mail rather than a local mailbox, so a Run-scoped fan-out skips them with a `recipient_unreachable` warning naming the direct `dispatch:<id>` address. Group addresses are resolved host-side, so no RPC or stream shape changes; an older CLI sending `@all` to a new host gets the Run-scoped meaning. Claude-Session: run-scoped-group-addresses * fix(orchestration): revalidate legacy takeover before the recipient verdict A legacy coordinator taken over while `listTerminals` was in flight reported `runtime_error` instead of `legacy_read_only`: Run scoping made "no live Dispatch in this Run" the first thing the group send could fail on, and that threw before the takeover check ran. Takeover is a precondition, not a commit-time detail — the sender must be told it is read-only whatever else is wrong with its recipient set. Revalidation moves to immediately after the only `await` in the path. Everything below it is synchronous, so the commit-time window it used to guard is unchanged; only the error paths now see it. The legacy partition test gave `term_current_worker` no Dispatch, so under Run scoping it is correctly not a recipient. It now holds a real current-contract Dispatch in the same adopted Run, which is what the test is named for: one `legacy_direct` and one `current_delivery` recipient in one fan-out. Claude-Session: run-scoped-group-addresses * fix(orchestration): address the Run a nested coordinator created, not its parent A nested coordinator is both a worker of its parent Run and the coordinator of the Run it created. `resolveMessageRun` answers with the parent, correctly, because that is where its own `worker_done` belongs — but audience is a different question. Scoping `@all` to that Run sent a nested coordinator's "shared context" to the siblings it was started beside instead of the workers it started, and reported success, so it never learned its sub-workers heard nothing. Before Run scoping the host-wide fan-out reached the sub-workers by accident; this turned an over-broad delivery into a wrong-audience one, the exact failure class the change exists to remove. Group audience now resolves off the Run the sender coordinates, falling back to its Dispatch's Run. A leaf worker coordinates nothing and is unaffected. This is a separate question from `routing.run`, not a second answer to the same one, so `resolveMessageRun` keeps its meaning for point-to-point mail. Also: when every live Dispatch in a Run is federated, the fan-out skipped them all and threw a bare `Error` that discarded the warnings naming those remote workers and how to address each one. The sender was told "no recipients" while three remote workers existed. That throw now carries a code and the skip explanations. Claude-Session: run-scoped-group-addresses * docs(orchestration): say that no group address reaches a coordinator A coordinator is not a Dispatch, so Run-scoped groups never include one. That follows from the rule, but nothing said it, and the old host-wide meaning did include the coordinator — a worker sending `@all` to raise a blocker would be heard by its siblings and by nobody who can act. The guide, the CLI note, and the docs page now say to use `run:<id>` for that, and that a worker which created its own Run addresses that Run's workers. Also restores the `@cursor` case dropped when the group tests moved: a Claude pane titled "Fix the text cursor blink" must not receive Cursor's mail. That hazard was recorded from real titles and `@droid` alone did not cover it. Claude-Session: run-scoped-group-addresses * fix(orchestration): preserve group audience and mailbox identity * fix(orchestration): validate group scope before dispatch routing * fix(orchestration): preserve pane identity and exclude coordinator dispatches |
||
|
|
ebb1acfa37 |
refactor(agent-status): publish structured sessions into the hook server store (#19683)
* refactor(agent-status): publish structured sessions into the hook server store Structured (native chat) sessions have no PTY and no hook script, so their status never reached the hook server's store; #19217 gave `worktree ps` its own adapter over the structured feed instead. The feed now writes every projection into that store through a status sink the runtime wires, drops the row when the host closes the session, and `worktree ps` reads the one snapshot like every other agent. Rows carry a `structuredHost` marker and the journal clock; they are never persisted to last-status.json, and the main process does not forward them to the renderer yet, whose feed bridge still owns them until it is retired. Design and the two follow-ups: docs/reference/agent-status-store.md. * chore: drop stray @pnpm/exe lockfile entry An unrelated local pnpm run added @pnpm/exe as a packageManagerDependency with no package.json change, so CI's --frozen-lockfile install failed before any job ran. * docs(agent-status): describe the step that actually landed The design record claimed PR 1 deletes RuntimeAgentRowStore, drops the retained-versus-hook reconciliation, stamps terminalHandle on OSC rows, and tags rows with a source field of 'structured-host'. None of that is true of the shipped code: the retained store and its reconciliation are still in place, and the row field is structuredHost: 'held' | 'owned'. AGENTS.md points every future contributor here before they touch agent status, so split the roadmap into the 1a that landed and the 1b that has not, and name the fields the code actually writes. * fix(agent-status): pair session removal with the status-row forget A session dropped from the host's map without an explicit forget left its row in the store forever: `structuredHostOwned` bypasses the staleness check, so a failed re-attach (the Claude rewind path reaches one) stranded a permanently working agent in `worktree ps` and on mobile with no UI able to clear it. Deletion and forget are now one operation both callers route through. * fix(agent-status): give orcad the store worktree ps reads from `orcad` constructed its runtime with neither `getAgentStatusSnapshot` nor `structuredAgentStatusSink`, so once `worktree ps` sourced rows only from that snapshot the headless host published nowhere and listed nothing. The hook server's store is a module singleton whose import tree never reaches Electron, and its file paths come from `start()`, which orcad never calls. * fix(agent-status): drop a structured row without a renderer clear `dropStructuredStatus` went through `clearPaneState`, which fans a pane clear out to the renderer for a pane key the renderer's own feed bridge still writes - so 'exactly one writer per pane key' held for writes and not for deletes. `dropStatusEntry` routes through the status-drop tap instead, and skips the resume-identity remnant: a structured session has no pane to resume into, and every null-status publish would otherwise re-mint one. * test(agent-status): pin both half-migration structured-row filters Neither the `agentStatus:getSnapshot` filter nor the main-window listener's had a single assertion, so deleting either — the first step of PR 2 — was green everywhere. Also covers the perf skip and the drop's lack of a renderer clear. * docs(agent-status): correct three statements this PR made false The sink JSDoc claimed only tests construct a host without one; `orcad` did. The doc argued a structured row needs no tab mirror 'because headless serve has no renderer', reasoning about exactly the topology the wiring had not reached. The deleted runtime adapter's warning that the pane key must be the DERIVED one - never a bearer handle or minted worker key - was lost with it. * test(agent-status): declare orcad in the hook-row producer census Wiring the hook store into the orcad runtime added a production site that hands hook rows to a consumer, which the census ratchet pins deliberately. --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
2bf298d1dc |
feat(native-chat): the background-tasks strip says what is running (#19311)
* feat(native-chat): name, group, and state the background-tasks strip
The strip above the composer described five different kinds of background
work as "Monitoring background tasks", with identical flat-dot rows. Now:
- Wire: additive optional `name`, `state`, `startedAt` on
AgentSessionBackgroundTask, plus `settledTasks` on the state object so
terminal siblings of a live fan-out stay visible without changing what
old clients render (they keep exactly the live `tasks` list).
- Reducer equality learns the new fields, so a publish whose only change
is a task's state is no longer judged equal and dropped.
- Header counts by kind and lists states within a kind; past three kind
segments (or on a narrow strip, measured by its own border-box against
the live root font size) it falls back to an honest total, never a
partial enumeration, and the strip stays expandable whenever the header
is lossy.
- Rows group by kind (Agents / Shell / Monitors / Workflows / Tasks),
stable-sorted first-seen-then-id, each with a kind icon, its own state
dot, a resolved name (description -> name -> kind label), and elapsed.
- Claude producer: task frames now carry name (agent_type/subagent_type),
a run state mapped from patch status, and first-seen startedAt. Terminal
statuses settle a task (completed->done, failed->blocked,
killed/stopped->idle) instead of deleting it; settled tasks render only
beside still-live work and flush when the last live task ends, so the
strip exits exactly when it does today. An unreadable patch leaves a
task open, never settled.
- Turn gating moves off the strip: the tracker no longer zeroes its
roster during a foreground turn, and the client renders the strip
whenever it has contents while the idle-only flag now gates just the
animated monitoring indicator and conversation commands.
* feat(sidebar): indent native-chat subagents under their session row
buildSubagentChildRows() has always rendered indented children from
parentEntry.subagents, and the structured-session status bridge has
always published an AgentStatusEntry for native chat — it just never
populated subagents. Connect them:
- Wire: additive optional `backgroundTasks` on AgentSessionStatusSummary
(live tasks only), projected by the host status feed from the
provider's backgroundTaskState hook and republished on task edges via
the background-task channel, with the shared task equality suppressing
no-op re-projections.
- Bridge: maps agent-kind tasks onto the sidebar's own
AgentSubagentState (working/waiting/blocked, terminal -> idle) — kinds
stay distinct, so a backgrounded shell never lands in a subagent
count — and extends its pre-write equality with the existing
agentSubagentsEqual.
- parentIsFresh for a bridge entry means "the host feed reported a
change inside the sidebar's ordinary evidence window": every publish
restamps evidenceObservedAt, and a dead feed stops restamping, so
children decay to idle on lost contact instead of pinning 'working'.
* fix(native-chat): settle tasks the aggregate roster evicted first; carry usage
Real-agent QA showed settledTasks never rendered. A frame capture from the
SDK (probe against claude 2.1.261) explains it: when a backgrounded child
finishes, the producer emits `background_tasks_changed` FIRST — with the
task already absent — and only then `task_updated`/`task_notification`
with the outcome, in the same tick. The tracker's settle path looked the
task up in the live roster the aggregate had just evicted, so retention
lost the race 100% of the time.
Fix: aggregate eviction of a live backgrounded task now parks its details
in a bounded recently-removed map (new claude-settled-background-tasks.ts,
which also owns the settled roster), and the trailing terminal edge
consumes it. A removal whose outcome frame never arrives still vanishes —
nothing is guessed into a finished state. A second terminal edge for the
same task re-derives the settled state and can add final usage. The
captured sequence is replayed verbatim as a tracker test, including the
kill-at-exit tail proving the strip still exits with the last live task.
The same capture disproved the PR's earlier claim that Claude task frames
carry no usage: task_progress and task_notification both carry
usage.total_tokens. Additive optional `totalTokens` on the wire task,
covered by the shared equality; the tracker takes usage (never the
transient "Running <tool>" description) from task_progress, and rows
render the mock's "18.1k · 2m" meta — settled rows keep final usage with
no still-growing clock.
* chore(i18n): sync runtime-required catalog for backgroundTasks.runningList
* fix(native-chat): preserve background task lifecycle and bound update work
* fix(native-chat): transfer resumed background tasks to one live owner
* fix(native-chat): bring structured session host under the line cap and restore subscribe fixture
* fix(native-chat): complete journal stubs and stop notifying on feed teardown
The status feed's projection cache calls journal.cursor(); the rename test's
stubs are cast through unknown, so the missing method only surfaced at runtime.
Teardown runs only once nothing is activated, so there is no mounted reader to
notify - clearing confirmed sessions is what prevents a stale live on reactivation.
* feat(native-chat): lead each strip header count with its kind icon
The header carried one aggregate state dot, so a fan-out of agents and a
monitor looked alike. Each count segment now leads with its own kind glyph;
a collapsed total spans kinds and takes none.
Monitor is the heartbeat AgentStateDot already draws for monitoring, so the
strip and the agent sidebar speak one vocabulary.
* feat(native-chat): give the strip's monitor heartbeat the sidebar amber
The glyph matched AgentStateDot but the colour did not, so a monitor in the
strip did not read as the monitor in the agent sidebar. One shared tone helper
now serves the header segment and the expanded row, so they cannot diverge.
Monitoring is a state the app already colours; the other four kinds are plain
markers and stay neutral. A running turn still dims the whole set.
* fix(native-chat): draw the strip header separator in a visible tone
The separator used `text-border`, a divider-line token that is 7% white in
dark mode - an order of magnitude fainter than the counts on either side, so
the dot between them read as absent. main.css already records that token as
too faint for a visible mark.
* fix(native-chat): give the worktree-ps journal stub a cursor
The status feed's projection cache calls journal.cursor(); this stub is cast
through unknown, so the missing method only surfaced at runtime. Its journal
never changes, so a real one would hold the cursor steady.
* refactor(native-chat): split the sidebar subagent rows out of this PR
The strip stands alone: the sidebar mapping, its observation plumbing and the
AgentStatusEntry.subagents wiring move to a stacked follow-up. No wire field
here is sidebar-only - the strip's rows read name, state, elapsed and tokens.
* perf(native-chat): keep task usage out of the session status summary
A `task_progress` frame ticks a background task's `totalTokens`, which
failed the status feed's equality check and re-broadcast a full summary to
every `agentSession.subscribeStatus` subscriber — paired-web and SSH/relay
clients included — for a number no session list renders. The projection now
drops usage; tokens keep flowing on the background-task channel the strip
reads.
* fix(native-chat): correct token unit rounding and drop the unused dot state
`formatBackgroundTaskTokens` rounded before choosing the unit, so 999_950
rendered as "1000k" instead of "1m"; pick the unit from the rounded value.
`backgroundTasksDotState` has no caller on this branch or the stacked
sidebar PR, and its multi-kind branch would report 'monitoring' over an
attention state. Delete it rather than leave it to be wired up.
* fix(i18n): drop the orphaned backgroundTasks.runningList key
The strip rewrite removed its only call site, and an unreferenced key gets
promoted into the eagerly parsed boot catalog. Delete it from en.json and
regenerate en-runtime-required.json.
* fix(native-chat): show the reason on every attention row
The row guarded the reason line on 'waiting', so an 'unverifiable' child
("no contact") and a 'blocked' one ("failed") rendered bare while the
collapsed header named exactly those reasons. `backgroundTaskStateReason`
already returns null for the non-attention states, so the guard was only
lossy — the SSH boundary requires the unverifiable verdict stay legible.
Also keys the header segments off their kind discriminant instead of the
translated display text.
* fix(native-chat): make the strip header agree with its own count
The headline counts live AND settled rows, but the state breakdown omitted
'done', so one working agent beside four settled ones read "5 agents — 1
working": the count said five, the breakdown accounted for one. Done now
appears in the muted detail (never as an emphasised segment) so the two
agree.
The single-command header also drew an elapsed clock on a settled task,
which the row already refuses as a lie about finished work.
* perf(native-chat): memoize the background-task roster grouping
The 1 Hz elapsed tick re-rendered the strip, and the render body regrouped,
re-sorted and re-translated every task each time only `now` had changed.
The header still derives from `now` on purpose.
* test(native-chat): cover settled rows and the mid-turn mounted strip
Neither headline behaviour had component coverage: every strip render passed
`settledTasks={[]}`, and the `showBackgroundTasks` seam was never set true,
so the strip staying mounted through a running turn was exercised nowhere.
Adds a settled-beside-live row test (final usage kept, no clock, no stop) and
a mid-turn mount test (strip present, turn owns the voice). The background-task
tests share one session-element helper so the file stays under its line cap.
* refactor(claude): keep MAX_TASK_ID_LENGTH module-private
Nothing outside claude-background-task-frames.ts references it; the export
was residue from this PR's split.
* test(native-chat): give the mid-turn strip test a real turn
main now gates the composer's stop button on a provider-minted turnId rather
than the send-time working signal, so a test claiming a running turn has to
supply one. The controller mock hardcoded turnId null.
---------
Co-authored-by: Merge Sim <sim@local>
|
||
|
|
34790ce084 |
perf(terminal): share one ESC dispatch table between the two scanners (#19842)
The partial-tail state machine and the preview normalizer's per-sequence parser each carried their own copy of the byte-after-ESC table, so the DCS/SOS/PM/APC set was stated twice and could drift. Move it to `terminal-escape-introducer.ts` and have both read it. The normalizer now classifies from `charCodeAt` instead of `value[i]`, which drops the one-char string it minted on every escape it parsed -- the same allocation the ground scan already avoids. No behaviour change: `parseAnsiControlSequence` matches its previous implementation on every 2- and 3-byte sequence after ESC and on 20k random control-dense streams (differential check, not committed). |
||
|
|
2f828e4462 |
fix(native-chat): show Claude working from the send, not the provider echo (#19822)
* fix(native-chat): show Claude working from the send, not the provider echo A structured session read as working only once a turnLifecycle row existed. Codex writes that row ~150ms after the send; Claude cannot write it until the SDK echoes the user message back, measured at a 3.4s median and 18s at p90, so the chat and every session list read idle for the whole wait. The journalled submission is the host's own evidence a turn is owed, so the shared projection reads it too. `unknown` still counts -- the ack budget elapsing answers delivery, not whether work is owed -- while a recovered `unknown` does not, which needed the existing row flag carried onto the projected submission. Claude's activity line now stays the generic fallback. Its only turn-wide frame carries a bare token, and its task_* prose describes a spawned task rather than this turn; compaction is kept because it explains an otherwise silent wait. * Fix structured chat pending-work lifecycle and mobile cancellation --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
946b1fc078 |
perf(terminal): own retained tail rows so they stop pinning their chunks (#19528)
Every row in the 2000-row retained tail is sliced from the PTY chunk it arrived in, so a spinner workload — CR-redraw frames, exactly what Claude Code and Codex emit — makes a ~5 KB tail pin one whole chunk per row. Measured on 400 x 64 Ki-char chunks: a 5,090-char tail retained 37.5 MB. At 16 Ki x 1600 it was 46.9 MB. Own the two values the plain tail path actually retains, newlyCompletedLines and the partial line, at the point they enter the tail: 37.5 MB -> 0.18 MB and 46.9 MB -> 0.09 MB, with per-chunk cost unchanged within noise. The transcript keeps the same row objects, so it is covered transitively. The multiline redraw builder never slices normalizedChunk — it writes rows character by character — so only its partial line, which is re-sliced from its own row on every frame, needs owning. Owning its rows as well measured no retention gain and cost CPU on fullscreen TUI floods. Also own the wait-blocked keywordCarry: 31 characters that pinned a full lowercased copy of the chunk, per PTY. Follow-up to #19396, which introduced ownRetainedString for the much smaller pending-control fragments. |
||
|
|
373a670aba |
perf(terminal): skip ordinary output in preview control scans (#19400)
* perf(terminal): skip ordinary output in preview control scans * test(terminal): cover scan resumption after parsed controls |
||
|
|
1e2ed20b35 |
perf(terminal): release oversized backing strings behind pending controls (#19396)
* perf(terminal): release oversized backing strings behind pending controls * test(terminal): record reproducible pending-storage gate evidence * perf(terminal): own retained control fragments with a fast copy primitive The pending-control ownership landed with a charCodeAt block copier (10 us at 4 Ki, 170 us at 64 Ki), so it needed a "copy only when discarded output dominates the tail" gate to stay affordable. That gate was the whole cost problem: on adversarial streams it fires every chunk and pays the slow copy (+45% on 16 Ki ANSI chunks, +81..111% on 194 Ki status chunks), and it also skipped ownership on fragments too small to be sliced strings anyway. ownRetainedString replaces it with a Buffer utf16le round trip (0.57 us at 4 Ki, 21.9 us at 64 Ki) and returns anything below V8's SlicedString kMinLength unchanged. Buffer is absent in the renderer and on mobile, so the copier is resolved once behind a lone-surrogate round-trip self-check and falls back to the block copier. With a ~1 us copy the gate is unnecessary: ownership is now unconditional at all three retention sites and the adversarial cases land within noise of the un-owned parsers. The three forced-GC threshold fixtures are replaced by one forced-GC test for the primitive plus deterministic spy assertions that each site routes its retained value through ownRetainedString. All fidelity and differential coverage is kept. * fix(terminal): escape the NUL in the round-trip probe A raw NUL byte in the source made git treat the file as binary, so its diffs and blame were unreadable. Escapes are equivalent at runtime. |
||
|
|
91f00b32ef |
perf: stop merging OS-opened files at the pending queue cap (#19508)
* perf: stop merging OS-opened files at the pending queue cap * fix(os-open): report markdown opens dropped at the pending queue cap --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
8c18e42be2 |
test(ci): replace fixed teardown waits with bounded polls (#19720)
The two flakiest tests on main both guessed at a duration instead of waiting for the condition. - windows-pty-job.win32.test.ts assumed job teardown finished in 1.5s; under load on a Windows runner it does not. Poll isAlive up to 30s instead -- the assertion is unchanged, so a real leak still fails. - structured-agent-session-claude-options-round-trip.test.ts relied on vi.waitFor's 1s default for a two-hop handoff; give it 10s. Both are test-only and strictly widen an existing wait. |
||
|
|
069bdae283 |
perf: materialize only the requested recent plugin audit lines (#19498)
* perf: materialize only the requested recent plugin audit lines * test(plugins): prove the recent audit window matches the full-split selection --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
c875941d7c |
perf: stop queued metadata work after watcher cancellation (#19446)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
65631e449a |
refactor(ai-vault): split the session scanner into a transcript reader and consumers (#19666)
* refactor(ai-vault): split the session scanner into a transcript reader and consumers The scanner's only output was the Session History summary; a second reader of the same transcripts (a search index) had nowhere to plug in without hooking the parse itself. Extract a reader that owns each file read, keeps the resumable cursor and publishes every decoded message to registered consumers. The session list stays a fold inside the parser and is the only consumer here. Parsers take an optional message sink instead of a scope. Also: read Cursor chats/<md5>/<uuid>/meta.json for cwd, title and timestamps (Cursor transcripts carry only role and message); share the lazily spawned worker-thread host between the OpenCode SQLite reader and the port-scan probe; probe OpenCode's schema before querying; keep the newest-N discovery set with a bounded insert instead of sort+slice. Session list output is byte-identical to main across all 18 providers cold and append-resumed; the one Cursor session gains cwd/timestamps from meta.json. * fix(ai-vault): serialize per-path parses and report unpublished reads Overlapping parses of one transcript share the cached resume point's message channel, so the second beginRead dropped the first read's consumers and the first finishRead handed them the wrong outcome. Two callers really do overlap: a forced refresh restarts a scan while the aborted scan's parse is still in flight, and the title reader parses outside any scan. Restore the per-path lane around the whole lookup-read-store sequence. OpenCode's SQLite sessions are decoded on a worker thread the channel cannot reach, so their reads published no messages while reporting a complete span. Finish those reads as incomplete instead, so a consumer never records a cursor for a stream it did not receive. * fix(ai-vault): degrade a refused cursor chats read instead of dropping sessions A refused WSL read of Cursor's chats tree rethrew, and the per-file catch in discovery then recorded an issue and skipped the transcript. Before the meta.json join Cursor had no content dependency, so a stalled distro could not hide a Cursor session at all. Degrade to no metadata for the scan and report the chats root once. The parse cache stays honest without the throw: discovery stats no meta.json on a refused scan, so the entry's recorded size omits it and the next healthy scan re-reads the transcript. The per-scan index scope covered discovery only, so every Cursor finalize re-read the chats root to validate the module cache. Move the scope to scanAiVaultSessions, which spans discovery and parse. Also drop the unused signal parameters the sink threading added to the Devin and Hermes content parsers, by giving each file parser a private record parser instead. * fix(ai-vault): do not cache a cursor parse whose meta.json read was refused Discovery stats meta.json into the candidate's cache key, so when only the meta.json read is refused the un-enriched session was stored under a key that looks unchanged and reuseCachedSession never re-ran the enrich hook. The session stayed without cwd until Cursor rewrote the file. The enrich hook now reports 'refused', the resumable state exposes isCacheable, and the parse cache drops the entry instead of storing it, so the next healthy scan re-parses. The index-read branch is unaffected: it never stats meta.json, so its key is honest already. * fix(ai-vault): separate the transcript's size from its cache key sizeBytes folds a content dependency's size in, so it is a cache key rather than a file length. The reader compared a transcript byte offset against it and reported it as a whole-file read offset, which for Cline handed consumers an offset past the end of the file it read. Carry the dependency's own size on FileWithMtime and subtract it in the reader. A refused sibling stat rethrew, so discovery recorded an issue and skipped the transcript, the same drop removed for the readdir and read paths. Degrade to no dependency, note the tree once, and mark the key untrustworthy. An untrustworthy key no longer costs the resume cursor: the entry is stored under an mtime no stat can produce, so unchanged is false while the resume point survives and the next scan resumes instead of re-reading the whole transcript. * test(ai-vault): pin the untrustworthy-key mechanism, not just its effect Both refusal tests asserted that a later healthy scan re-enriches, which a plain store would also satisfy once the resume cursor was preserved. Assert the cache entry directly: its mtime is the unmatchable sentinel and its resume point survives. The sentinel is exported so the tests name the contract instead of repeating -1. * refactor(ai-vault): track a session's sidecar file apart from its transcript Folding Cursor's meta.json stat into the transcript's mtime/size made one key mean two things, and every round of review found another consequence: a byte offset could not be compared against it, a refused sibling read took the transcript down with it, and an un-enriched parse cached under it looked current forever. Main already had the answer for a file the transcript key cannot see: Codex titles are refreshed at reuse time over the cached session, not folded into the key. Discovery now records the sibling as its own observation, unknown when it could not be read. A cache hit needs both the transcript key and the sidecar to match. When only the sidecar moved, Cursor re-merges it over the stored un-enriched fold result and never re-reads the transcript; Cline, which reads its sibling as part of the parse, re-parses. Merging over the fold result rather than the accumulator makes enrichment pure, so a meta.json rewritten with a new cwd replaces the old one instead of losing to it. That was unreachable while the merge used ??= on a session it had already enriched. Cline and the remote scanner move to the same field, so the fold is gone from both discovery paths. * fix(ai-vault): tell an absent sidecar from an unreadable one Three places collapsed the two. sidecarUnchanged returned true for any observed 'none' without reading the entry, so a sidecar that was deleted, or one that was unreadable last scan, both read as cache hits. Native discovery mapped every non-WSL stat failure to 'none', so an EACCES on meta.json left a session enriched from a file nobody can see, with no scan issue. Remote discovery could not tell a missing sibling from a failed stat, because statRemoteSessionFile returns null for both. 'none' is now a claim: absent-now is a hit only when it was absent before or the agent never had a sidecar, and only ENOENT/ENOTDIR reads as absent. statRemoteSessionFile grows an opt-in rethrow so its caller can distinguish the two failures it already reports. Also rewrites three comments in the cursor chat-meta reader that still described the deleted fold. |
||
|
|
5868fdc9e3 |
feat(native-chat): report Codex background tasks in the chat strip (#19346)
* feat(native-chat): report Codex background tasks in the chat strip The background-tasks strip works for Claude only; a structured Codex session shows nothing in it. Feed it from the Codex app-server stream. The strip stands for work that OUTLIVED a turn, which is what the monitoring header, Claude's foreground suppression, and the conversation command gate all already assume. Codex has no `is_backgrounded` flag, so that fact is derived from the turn boundary: a `subAgentActivity` child or a primary-thread `commandExecution` becomes visible once the turn it belongs to completes and it is still unsettled. `turn/completed` only reveals a task here, never settles one — measured on `codex app-server` 0.153.4, a spawn_agent child reported `completed` 95.8s after its parent turn ended. Only a child's own activity kind settles it. Codex exposes no honest stop: `turn/interrupt` on a child ends its turn without emitting a terminal activity item and leaves its shell running. So the state carries a new optional `supportsStopAll: false`, the strip hides a control that could not act, and the blocked-command message asks the user to wait rather than to press a button that does not exist. * refactor(codex): move session teardown out of the structured adapter Merging main crossed the 300-line cap on `codex-structured-session-adapter.ts`: the rewind backend (#19235) and this branch's close-time strip clear both landed in it. The four close paths move verbatim into `codex-structured-session-teardown.ts`, where they funnel through one `settled` helper instead of repeating the notification-retry and background-task cleanup at each call site. No ratchet bump. Also normalize a background task's description once at receipt rather than on every projection; the roster is re-projected on each observed frame. * fix(codex): drop the shell row the journal already settles A `commandExecution` still `inProgress` when its turn ends was reported as a `command` task. But `settleCodexJournalTurn` writes exactly those items to the journal as `state: 'failed'` on `turn/completed` and forgets them, so the strip row would have claimed a shell was still running at the same instant Orca recorded that it was not — two surfaces contradicting each other about the same process. A subagent is the opposite case and stays: the roster pointedly does not sweep at a turn boundary, because children measurably outlive it. That leaves the producer making exactly one claim — these spawn_agent children are still live after their turn — which the durable roster row corroborates. * fix(native-chat): track Codex background execution lifetimes * fix(native-chat): keep running tool groups from claiming completion * Fix runtime catalog and capability expectation * fix(codex): keep a child's name on the command row that outlives it A child agent's commands stay hidden behind its agent row while the child works. Once the child's turn settles with a command still running, that command surfaces as its own row labelled from the raw command string, so 'long_probe' became "/bin/zsh -lc 'ping -c 300 127.0.0.1 > /dev/null'" at the moment that row was the only remaining signal for the work. Qualify a child's command row with the child's label. Resolved on read, so a label registered after the command still lands, and bounded by the existing description cap so admission accounting stays valid. Primary- thread commands are left unqualified: they have no child to name. --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
0fe132ea29 |
fix(orchestration): file mail from terminals in no Run under an unbound Run (#19696)
* fix(orchestration): file mail from terminals in no Run under an unbound Run #19542 deleted the fallback that filed such mail under the legacy Run, because a live row there makes the schema-skew probe read the database as pre-Runs and replay adoption on the next open. That refusal also broke the first command in the guide: `orca orchestration send --to <handle>` between two plain terminals, which worked in v1.4.198. Restore delivery by filing under `run_unbound`, a Run the probe never matches, created on first use so `run list` shows it only to a user who has such mail. Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9 * fix(orchestration): create the unbound Run only for a null Run id Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9 |
||
|
|
d15a6df224 |
Render task checklists with update diffs and a composer progress panel (#19230)
* Render native chat task lists with incremental checklist updates * Keep native chat task-list review plan out of repository root * Render live Codex plan notifications through task checklists * fix(native-chat): keep checklist test fixture within shared boundary * Keep agent task progress in one composer panel * Restore inline task checklists and historical update diffs --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
addd9f3da7 |
fix(orchestration): accept a missing Run id from older federation coordinators (#19689)
* fix(orchestration): accept v1.4.198 coordinators on federationAttachStart #19542 made runId required on the attach RPC and backfilled existing attachments with '', on the premise that federation was unreleased. It shipped in v1.4.198, so a v1.4.198 coordinator got 'Missing Run ID' from an upgraded worker host, and every pre-upgrade attachment lost its mailbox because home_run_id='' matches no Run. - runId is optional on the wire; an absent id mints a per-attachment stub Run (run_federated_<dispatch>) through the same INSERT OR IGNORE path. - migrate-v40 backfills existing attachments with the stub and inserts the stub Runs, so in-flight workers keep reporting back. - create SQL gives home_run_id a DEFAULT '' so a rolled-back v1.4.198 host can still insert into a v1.4.199-created table. Stub Runs never carry run_legacy_local, and #19542's attachment-mailbox exclusion in the skew probe is untouched, so no adoption replay path is reintroduced (probe test added). * fix(orchestration): repair empty federated home Run ids on every open A host on v1.4.199 that rolls back to v1.4.198, attaches workers (rows land with home_run_id='' via the DEFAULT), then upgrades again never re-runs the v40 backfill because user_version is already 40, so those attachments stay without a Run and their control mail is refused. Lift the two idempotent set-based statements out of migrate-v40 into backfillFederatedStubHomeRuns and run it from both the v40 migration and the OrchestrationDb constructor after migrate(), matching the existing on-open rememberCurrentRunCoordinatorHandles repair. Tests: reopen a v40 file DB holding a v1.4.198-shaped '' row through the constructor and assert the stub Run and mailbox; pin the wire schema accepting a v1.4.198 request with no runId ('' -> undefined, whitespace passes the schema and is refused at the DB layer). --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
3a801d213d |
fix(orchestration): worker-start settles readiness on observed turn start, not write acceptance (#19423)
* fix(orchestration): worker-start settles readiness on observed turn start, not write acceptance A dispatched PTY worker whose agent wedged at startup (six codex workers on 2026-09-07) was reported 'ok: true, state: ready, stage: input_accepted': the preamble write was acknowledged with observationTimeoutMs: 0 and nothing ever verified a turn began. The corpse and the healthy worker produced identical receipts. worker-start now runs the existing second-stage prompt observer (observeTerminalAgentPrompt) after acceptance, inside the 30s window the client RPC grace already budgets for (orchestration-worker-start-prompt-budget): - turn observed (or provider ack for structured sessions) -> ready - permission prompt -> ready; positive liveness, surfaced in the receipt - provider without a turn-start signal -> ready; observation: unsupported - observation supported and nothing started -> worker state start_unknown, response state outcome_unknown with nextCommands. Honest 'unverifiable', never a death claim: the capability and terminal are kept, and worker-report settlement already reconnects a start_unknown worker that recovers and reports. Also fixes the effect-verb lie that misdirected the first diagnosis of this incident: agent-first worktree creation labeled its own brand-new agent terminal 'reused_agent_terminal' (a role test picking a lifecycle verb) on both the local and federation paths. It now says 'created'; readers keep accepting the retired verb for rows persisted before the rename. * fix: preserve worker authority through start observation --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
210ac1f77c |
fix(native-chat): expand Claude slash commands sent with an attachment (#19654)
* fix(native-chat): keep the text block last in Claude dispatch content Claude reads a streamed user message as a slash-command invocation only when the last content block is text. The composer builds the send body as text-then-images, and the Claude dispatch mapper preserved that order, so any slash command sent with an attachment arrived as literal prose instead of running -- /review, /init and the rest, not just one command. Emit images ahead of text in the mapper. Images-before-text is also the order Claude prefers for vision, so this is unconditional rather than slash-only. Fixed in the Claude mapper rather than the shared send body: that body also defines rendered journal block order for every provider. * fix(native-chat): derive Claude slash-command recognition from the sent prompt Claude encodes a user turn as attachment blocks followed by the typed text and recovers the prompt by reading only the trailing text block, so a body ending in an image has no recoverable prompt and its `/command` reaches the model as prose. The composer builds text-then-images, so that was every send with an attachment. Emit one trailing text block rather than appending each block: a partitioned `[...images, ...texts]` still strands a command ahead of trailing prose, because only the last block is read. Derive `acceptsResult` from that same sent content so the mapper and the dispatch waiter cannot disagree about whether a command ran; the previous `blocks.some(trimStart)` was strictly more permissive than Claude, which does not trim before matching `/`. * test(native-chat): pin the sent Claude content order and the joined prompt verdict The attachment scenario passed unchanged against main: it only exercised acceptsResult, which was already true there. Assert the send payload, and pin that joining narrows a command hiding behind leading prose. Reuse claudeRecord for the content-key narrowing the same file now imports. --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
1c1cb7115a |
fix(orchestration): unify structured-session status across worktree ps and sidebar (#19217)
* fix(orchestration): stop worktree ps reporting a busy structured session as idle A worktree running a structured Claude or Codex chat read as idle to `orca worktree ps`, while the desktop sidebar showed the same session working. The sidebar was right: the host already projects a status summary for every structured session and publishes it, and the renderer maps it into an agent row. `worktree ps` simply never consumed it, so the agent-facing surface was the blind one. Structured sessions have no PTY, so they reach neither the hook snapshots nor the retained ones that every other row is built from. This reads the summaries the host has already published and applies the same projection the sidebar does — working, attention as blocked, otherwise done — so the CLI and the GUI cannot disagree about one session. Two things worth knowing: The connected-PTY evidence gate had to be skipped for these rows. It exists to drop a row whose PTY is gone, which is the wrong question for a session that never had one; a structured row's liveness evidence is the status feed that produced it. The exemption is keyed on the row being structured, so every PTY-backed row keeps today's behaviour. `RuntimeWorktreeAgentRow` needed no change. It was already a non-PTY shape — paneKey, state, agentType, and no ptyId, connected or writable — so a structured row fits without inventing a fake terminal coordinate. The pane key is the DERIVED one the renderer already publishes, never the orchestration bearer handle or the minted worker pane key: both are credentials, since `orchestration check` is identity-gated and accepts a caller-supplied pane key. `orca terminal list` is deliberately untouched, and a test pins that. Adding rows there breaks real consumers: mobile mounts a terminal WebView per row that can never receive a frame, a `connected`-keyed refresh check goes permanently true and pins shipped clients to a fast cadence with no exit, and the plugin projection has no field that can carry `writable: false`. Every safe consumer of a terminal summary checks `ptyId`; the breaking ones key off `connected` or mere row presence, which no added field can qualify. An honest partial-listing count there is a separate change. * fix(runtime): report only live structured sessions in worktree ps The status feed's `published` map is a broadcast cache, not a roster. It deliberately never retracts — an evicted idle session is still idle, and a reloading renderer must not lose every settled row — so enumerating it lists every session the host has ever opened, and eviction's `forget-session` step deletes the session from the live map while touching nothing else. Reading it as a roster made `worktree ps` report a closed chat forever. The sharp edge is a chat closed while an approval was pending: a deliberate close does not settle a pending prompt, so the retained summary stays `attention`, maps to a `blocked` row, and merges the worktree to `permission` for the whole 30-minute freshness window — on the CLI and on the mobile sidebar it backs. The poller now answers from the sessions the host still holds, intersecting the live map with the retained projections. `subscribe()` and its snapshot are untouched: retention there is the point. Gating on the live session set rather than the visible tabs keeps a headless orchestration worker listed, which is what the agent-facing surface is for. Also folds out two things the enumerator left behind: the working/attention/idle to working/blocked/done mapping now lives once in the shared projection module instead of once per process, which is what actually enforces "the CLI and the GUI cannot disagree"; and the structured row source no longer builds a write-only `payload` behind an `as` cast that compensated for nothing. The structured source construction moves to its own module to keep runtime-worktree-agent-rows.ts clear of the 300-line cap. * test(runtime): execute the structured-host call site in worktree ps Both structured-row suites called attachRuntimeWorktreeAgentRows directly with summaries they built themselves, so nothing ever ran getWorktreePs's own `getStructuredAgentSessionHost()?.liveSessionStatusSummaries()`. That file carries `@ts-nocheck`, so renaming the accessor was green in typecheck and in the suite, while `orca worktree ps` and mobile's 3s poll would throw a TypeError for every user — the `?.` optional-chains the host, not the method. Swapping the call back to a whole-cache read was equally invisible: the liveness suite injects feed.liveSessionSummaries() itself, and the string-match guard only needs the identifier to appear somewhere in the file. Drives the real runtime with a stub host over a real status feed that has published two sessions and forgotten one, asserting the live session's row reaches ps output, that the live accessor is the one called, and that the `?? []` fallback still returns a page with no host installed. The stub is typed against the real host, so a class-side rename reddens tc here. * refactor(runtime): admit agent sources before worktree row projection * fix(runtime): preserve host-authoritative structured status * Fix structured host session activity lifecycle --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
72befaf360 |
feat(native-chat): show Claude subagent activity on the shared carrier (#18806)
* feat(native-chat): show Claude subagent activity on the shared carrier Claude's `message:system:task_*` frames are classified `status-chrome` and reach the transcript as nothing at all, so a turn that spawns subagents renders as an idle turn. The journal translator now reads them into the shared subagent-group carrier — no new UI, and the frames stay `status-chrome` so nothing prints a raw opcode row. `local_agent`, `local_workflow` and `local_bash` tasks share that channel and all carry a `tool_use_id`, so `task_type` is the discriminator and a backgrounded `sleep 20` stays out of the roster; `subagent_type` covers releases that predate `task_type`. `skip_transcript` tasks never render, `is_backgrounded` children survive the turn-end sweep, and a resumed task re-announced under a fresh tool id is aliased onto its `task_id` rather than duplicated. A child still reported as working when the turn — or the session — ends becomes `unverifiable`: contact was lost, which is not evidence it exited. * fix(native-chat): stop the Claude subagent roster dropping its own rows The roster published under the same coalescing key it appends the row with, and the sink queue replaces any queued operation sharing a key regardless of kind: once a write was in flight, each new append evicted the pending publish and the next publish evicted that append, so the body never reached the journal and `lastSerialized` had already moved past it. Publish now takes the sink's own slot, as the Codex streams do. A tombstoned row could never come back: the non-batch item-row builder derived its revision from `items` alone, so a re-add was built at revision 1 against a tombstone at 2 and the reducer discarded it forever. It now takes the same `max(items, tombstones)` the batch builder already used — reachable here because an announcement that reveals a `local_bash` task empties and tombstones the group row that a genuine subagent later in the turn reuses. `settleTurn` swept whatever group the key named at the time it ran, so children rostered before any turn key existed were never swept, and a turn whose result never arrives was left working forever. The ending turn's key is now an argument, a superseding turn start settles the turn it replaces, and every turn end also sweeps the outside-turn group. Teardown without an `ended` event, and eviction past the group bound, both lose contact instead of stranding a row at `working`. Label ordinals are a high-water mark now: releasing one on a re-label handed the next child an ordinal that was already on screen. * fix(native-chat): bound subagent-group blocks on every wire that carries one Adding a fifth arm to `NativeChatBlock` made every consumer that assumed four wrong. Two of them ended in `return block`, so they compiled while handing a roster straight through: the mobile RPC sanitizer shipped it unclipped past both mobile char caps, and the legacy transcript import stored an untrusted roster unbounded. Both now clip each label and cap the entry count the way they bound their other blocks. The remaining three sites did not compile at all. The worker transcript payload and the live-session benchmark get real arms rather than casts — a cast would have turned the transcript one into a third silent passthrough inside the wire byte budget — and the CLI worker output renders a roster with its shared summary instead of `[image omitted]`. The mobile sanitizer moves to a sibling module beside the image-block one: the file sat exactly on the max-lines bound, and the block bounds are a self-contained concern with their own caps. Also caps the roster's `subagent_type` label fallback, which reached the journal uncapped, and covers the new block type in the schema audit. * test(native-chat): cover the capped subagent_type label The roster stores the frame's label verbatim, so the cap on the `subagent_type` fallback is the only thing bounding it. * fix(native-chat): type the roster fixture so the suite typechecks The mobile-cap test built its entries with an inferred `state: string`, which is not a `NativeChatSubagentState` — the only typecheck failure on the branch. * fix(native-chat): stop child traffic rostering an id Claude never announced `observeChildActivity` minted a provisional row for any `parent_tool_use_id` outside the excluded set. An id that was never announced is never excluded, so a nested Task, a workflow child, or a grandchild parented to a tool id inside the sidechain each produced a permanently unlabelled `subagent` row that could only ever end `unverifiable`. The bounded exclusion set cannot cover an id no frame ever declared, and in a long session it can forget a genuine exclusion. Track instead whether this CLI announces tasks at all — set by ANY `task_started`, including one the subagent filter rejects. Once it has, an undeclared child is provably not a new subagent, so no row is created. The provisional path now serves only releases that announce no task frames, which is what its comment already said it was for. The label-ordinal test moves to an announcement-driven removal, the scenario that path now actually reaches; it still fails if `remove` releases the ordinal. * fix(native-chat): outrank the tombstone when building one too `buildJournalTombstoneRow` still built its revision from `items` alone, leaving it asymmetric with the item builder. It is correct today only because `upsertItem` clears the tombstone whenever a re-add wins — an invariant that lives in the reducer and was not pinned. Apply the same `Math.max`, and pin the invariant so the reducer cannot drop it silently. * fix(native-chat): stop an unrelated turn end settling an outside-turn child `settleTurn` swept the `outside-turn` group on every turn end, so a child Claude announced while no turn was live — a frame trailing the previous turn's result, or one that arrives before the first turn starts — was marked `unverifiable` by the next, unrelated turn ending. That state is terminal and latches, so the `task_updated: completed` that followed was discarded: loss of contact was recorded as the child's outcome on evidence that was never about that child. A turn end now sweeps exactly the group its key names. `outside-turn` belongs to no turn, so only an end with no key of its own reaches it, and what no turn end reaches `settleSession` does — reliably, since teardown without an `ended` event also routes through it. The cost is a child outside every turn showing `working` a little longer; the alternative prints a wrong outcome that nothing can revise. Also pins that a subagent announced after a task the filter rejected still rosters: the announcement path was never what the child-traffic gate closes. * fix(native-chat): bound a subagent entry's id, not just its label Every site that bounds a `subagent-group` block clipped the label and handed the id through whole. From the Claude producer the id is bounded upstream, but the legacy transcript import reads an untrusted file, so an oversized id survived into the journal and then out to every wire that replays it — 64 entries of it, since only the entry count was capped. Each site now clips the id with the helper it already uses for its other bounded fields: the journal's inline-text bound on import, the transcript payload's metadata clip, and the mobile char cap (renamed, since it is no longer a label-only cap). * fix(native-chat): surface an adverse subagent outcome in the fallback sentence The roster row's plain-text stand-in counted only `working`, so a fan-out whose children all latched `unverifiable` (or `failed`, or `stopped`) rendered as "Ran 3 subagents" — a completion claim. Mobile and paired web have no roster renderer, so that write-time-frozen sentence is the entire row there, and collapsing `unverifiable` into something that reads like success is exactly what the SSH execution boundary forbids. It now appends the worst adverse count, worst-first across failed/stopped/ unverifiable, and shows it even while siblings still work — matching the Codex lane's shared `subagentGroupFallbackText` verbatim so collapsing the two copies later is a deletion, not a behaviour change. Also bounds the provisional entry id. `observeChildActivity` wrote the `parent_tool_use_id` straight into the entry's durable id with no length cap, while the announced path already rejects an over-long id via `claudeTaskId`. Both now share `isBoundedClaudeTaskId`, and the provisional path rejects rather than truncates, as the announced one does. * fix(native-chat): stop a subagent label ordinal and a clipped roster key colliding - claimLabel probes the labels the group actually rendered instead of a per-base counter, so a generated `Audit 2` cannot duplicate a provider's own `Audit 2`. - Bound `NativeChatSubagentEntry.id` with a head plus a digest of the whole id at every site that bounds it. The id is the roster key: a prefix clip merged two distinct children onto one entry. - Correct a stale journal-reducer test comment: tombstone cleanup is a map-state invariant, no longer load-bearing for revision ordering. * fix(claude): merge duplicate unhandled-provider-frame imports * fix(claude): preserve subagent lifecycle and bounded invocation identity --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
852495d35c |
fix(native-chat): unify launch routing and support structured worker placement (#19431)
* fix(orchestration): let worker-start actually produce a structured chat `orchestration.workerStart` reads the user's "open agent tabs in chat" default, but two placement checks downgraded a structured-preferring worker to a PTY terminal agent for the two flags a routine dispatch always passes: --worktree new-child / new-top-level -> worktree_creation --model / --effort -> launch_preferences so in practice a structured worker never happened. launch_preferences was stale. PR #19040 gave AgentSessionAttachParams `options` and added resolveStructuredLaunchSeedOptions, which narrows a saved selection to exactly `model` and `effort` — the two ids both structured providers accept as strings. --model/--effort now go through that same narrowing (extracted as narrowStructuredLaunchSeedOptions) and seed the worker's session instead of forcing a terminal. An option set that narrows to nothing resolves to undefined, never `{}`, which would fail the record's bounded-string guard under a code that is not a wire refusal and strand the launch with no fallback. worktree_creation was a consequence of createWorkerWorktree creating agent-first: its startup terminal WAS the worker, so the structured branch below it was unreachable for any new worktree. A structured worker now creates the worktree with no startup agent and creates its session for the worktree afterwards — the order the renderer's own structured worktree create already uses. Because the executing host can only answer agentSession.createSupport for a workspace that exists, that verdict moved after creation: a refusal (WSL, and the rest) becomes a terminal agent in the worktree just created, never a failed start. --on and --terminal still downgrade, with their reasons intact, and every remaining downgrade still states itself in the mode receipt. The wait-for-setup gate is preserved explicitly. A PTY worker got it for free — agent-first creation sequences the agent's startup command behind the setup runner, so tui-idle could not arrive until setup exited. A structured session has no startup command to sequence, so the gate is now awaited directly, bounded by the start's own timeout. Split out worker-worktree-creation.ts and worker-start-agent-placement.ts rather than growing two files that were both pinned at the max-lines cap. * refactor(native-chat): make shared feasibility authoritative for launch routing * Type the structured setup gate's absent blocked reason so the wait union stays property-typed The type-aware audit rejected the blocked-reason template literal: narrowing the wait union with an 'in' check left the field typed unknown. Declaring that a structured setup gate never carries a blocked reason restores the direct read. --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
eedaf2bdfc |
perf: check backfill date cardinality before expanding ranges (#19456)
* perf: check backfill date cardinality before expanding ranges * test(codex): pin the backfill cardinality gate to the enumerated range Differential coverage at maxDates === length and length - 1 across leap days, century rules, year rollover and DST switch dates. * test(codex): type the backfill cardinality table as date tuples Untyped it.each rows widen to string[], which tsc rejects when cast to the 3-tuple CodexSessionBackfillDate. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
6d2d63104a |
perf: reuse naturally ordered unique Codex trust ranges (#19492)
* perf: reuse naturally ordered unique Codex trust ranges * test(codex): pin the trust-range ordering the dedup removal relies on Removing the pairwise dedup+sort is only sound while the scanner emits strictly ascending, non-overlapping spans. Guard that precondition so a future scanner change cannot silently widen or drop a trust block. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
e373c89536 |
perf: cache update timestamps for Linear and Jira result sorting (#19473)
* perf: cache update timestamps for Linear and Jira result sorting * perf(issues): build updatedAt key map without an intermediate tuple array --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
0f4bca26d4 |
perf: index usage session breakdowns during aggregation and merge (#19487)
* perf: index usage session breakdowns during aggregation and merge * test(usage): cover key injectivity and merge-index freshness Also restore both module docstrings to the top of their files. Quote/backslash location and model keys prove the JSON tuple key stays injective, and a second source carrying a location/model the merge itself appended must fold into that row rather than duplicate it. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
8b7b670c40 |
perf: validate terminal adoption MRU membership with group sets (#19482)
* perf: validate terminal adoption MRU membership with group sets * test(runtime): pin orphan-adoption group membership cardinality Covers empty tab order, a tab claimed by two groups, duplicate group ids, an uncovered claimed tab, omitted/empty recentTabIds and both valid two-tab splits. The two-groups case is mutation-verified: swapping the global no-duplicate rule for the new per-group set fails it. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
35bbc5ce5c |
perf: skip folder-scope construction for separate repository imports (#19452)
* perf: skip folder-scope construction for separate repository imports * refactor(project-groups): share one mode flag between scope skip and root guard Also cover the separate-import path with real repo paths, which the throwing getter test no longer exercises. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
782c4618fd |
perf: index observable skill installations by locked name (#19451)
* perf: index observable skill installations by locked name * fix(skills): stop the convergence gate reading snapshots off Object.prototype Lock names come straight from a JSON file on disk, so a skill directory named 'constructor' or 'toString' made knownSnapshots[name] resolve to a prototype function and threw TypeError out of the whole freshness inventory. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
f242c99af4 |
perf: scope activation inventory to the owning host and workspace (#19447)
* perf: scope activation inventory to the owning host and workspace * fix(activation): keep an unscoped census fallback when the owning host is unnameable Scoping the activation inventory made resolveActivationPtyListScope throw for paired-runtime workspaces and made a detached relay reject the scoped list, and both collapse to a 'blocked' gate. 'blocked' skips the sleeping-agent resume and the caller's reseed, so an SSH target on the bounded offline floor lost its initial pane and peer workspaces stopped resuming. Fall back to the unscoped inventory that shipped in exactly those two cases; the scoped fast path still covers local, folder and attached-SSH workspaces. Also OR the host-reported worktreeId with the id-prefix match instead of preferring it, because a relay seeds worktreeId from the host's own ORCA_WORKTREE_ID and a session dropped from the census is one the gate forks a second writer onto. * test(activation): update forkbomb fakes to the scoped session.tabs.list shape The gate now asks the host for one workspace's snapshot instead of the whole session.tabs.listAll inventory and refuses an answer that does not name its scope, so the old snapshots-array fakes made it block instead of resume. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
1dcd792649 |
perf: index selected skills when reporting bundle failures (#19481)
* perf: index selected skills when reporting bundle failures * refactor(skills): reuse the shared renderer collator for delete-plan roots src/renderer/src/lib/locale-text-collators.ts already memoises a base-sensitivity collator for six renderer modules; building another one per call in skill-delete-copy duplicated it and paid ICU setup on every summary render. Also pin dedup/order parity for the Set-based selected-skill filter. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
126a69367c |
perf: drop oversized diagnostic records before retaining serialized text (#19445)
* perf: drop oversized diagnostic records before retaining serialized text * fix(observability): leave a marker where an oversized trace record was dropped Oversize records were discarded silently, leaving an unexplained gap in the trace. Emit a tiny timestamped placeholder naming the span instead. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
db5df2f6f6 |
perf: index selected-host SSH leases by PTY (#19467)
* perf: index selected-host SSH leases by PTY * perf(ssh): build lease index lazily and cover pty id normalization * style(ssh): restore repository oxfmt formatting --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
d872a5a8b8 |
perf: avoid repeated pending SOCKS payload copies (#19439)
* perf: avoid repeated pending SOCKS payload copies * test(browser): cover pipelined SOCKS request-tail ordering in pending buffer Also document takeBuffer's view-not-copy contract and restore node: import order. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
d7162ef570 |
perf: reuse collators when scanning Warp themes (#19441)
* perf: reuse collators when scanning Warp themes * perf(warp-themes): filter before collating and skip trivial sorts Warp discovery collated every entry in the user's home or %APPDATA%\\warp before discarding the non-Warp ones; filter first so ICU only sees candidate names (order is unchanged: filtering commutes with a stable total-order sort). Also skip the collator entirely for 0/1-entry directories and single-file dialog picks, and drop the sort that ran only to be thrown away when the preview budget expired. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
a0ea66a13d |
perf: reuse collators when sorting discovered skills (#19440)
* perf: reuse collators when sorting discovered skills * perf(skills): share the discovery-source label sorter Both native and WSL discovery built the same one-off source collator inline; hoist it next to sortDiscoveredSkills with the same <2 short-circuit, and pin ordering parity against the per-call comparator over a wide collation corpus. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
400f70b006 |
perf: index tab membership when rebasing authoritative sessions (#19510)
* perf: index tab membership when rebasing authoritative sessions * fix(runtime): drop host-removed tab ids from rebased group recents --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
b38c1313c4 |
perf: index discovered skill IDs and names for batch selection (#19507)
* perf: index discovered skill IDs and names for batch selection * perf(skills): index only the selectors a share request asked for Indexing every discovered skill made the common one-or-two-selector share slower than the linear scan it replaced (200 skills / 1 ID selector: 0.04us -> 38us). Scoping both indexes to the requested selector set keeps the O(selectors x skills) collapse and beats the unscoped index at every size measured, including 512x512 (5547us old, 176us unscoped, 64us scoped). --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
de771ae4af |
perf: concatenate only carried JSONL line prefixes (#19506)
* perf: concatenate only carried JSONL line prefixes * perf(ai-vault): avoid per-line carry reset and sweep every JSONL chunk boundary --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
5f2127ad12 |
perf: group chat history sequences lazily within result windows (#19501)
* perf: group chat history sequences lazily within result windows * test(agent-session): prove lazy sequence grouping matches eager at every window edge --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
814d3525d4 |
perf: lazily index workspace tabs during terminal binding replay (#19466)
* perf: lazily index workspace tabs during terminal binding replay * test(persistence): cover same-worktree duplicate tab ids in binding replay * style(persistence): restore repository oxfmt formatting --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
c69d4d4f8b |
perf: track pane alias singleton or ambiguity without copying buckets (#19479)
* perf: track pane alias singleton or ambiguity without copying buckets * test(persistence): pin pane-alias ambiguity cardinality parity Covers 0/1/2/3/4 rows per tab plus a mixed ordering case, so a regression from has() to a truthy check would resurrect an ambiguous tab and fail. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
8f78c28248 |
fix(orchestration): fence worker release on mobile keystrokes (#19337)
* fix(orchestration): fence worker release on mobile keystrokes A settled worker's terminal stayed ownership_state='owned' unless a takeover was recorded, and the only recorder was orchestration.workerTerminalUserInput, which only the desktop/web xterm input signal and the native-chat composer call. Mobile input arrives as terminal.send / stream input frames instead of a report, so a phone user typing in a settled worker's pane never fenced anything: worker-list kept recommending release and worker-release closed the PTY under them. Give the host one definition of "a human typed into this terminal" and route every lane through it. The mobile input floor claim is that definition and already exists on both byte lanes: it is taken only for deliberate phone input, never for the emulator's own query replies, and never for an agent's `orca terminal send`, which names itself a desktop client and so is indistinguishable from a keystroke at this layer. Settling that claim after an accepted write now records the takeover through the same code the RPC reporter uses, throttled to one write per pane per 30s so a keystroke does not pay for an immediate transaction. The record lands on the runtime that owns both the terminal and the orchestration database, so SSH-hosted and remote workers behave exactly like local ones. No mobile change: mobile already sends client.type (mobile/src/terminal/terminal-send-request.ts:24). * fix(orchestration): ask the database, do not remember, whether a pane is fenced The keystroke throttle armed on the attempt rather than on the outcome, so a zero-row or thrown record poisoned the pane for 30s. A phone keystroke during the worker-start readiness wait lands before prepareStartingWorkerAuthority creates the owned resource; a real keystroke seconds later was then suppressed, the worker settled, and workerRelease closed the terminal under the phone user. A SQLITE_BUSY on the first write did the same, with no retry. The cache was the defect, not its arming condition. Its precondition is the set of owned resources on the pane, which changes underneath it, and any cache keyed on ownership identity would have to read the database to learn that identity -- which is the whole question. So the input lane now asks: a read using the same predicate the write uses answers "is anything still fenceable here?" without taking BEGIN IMMEDIATE, and only then is the write attempted. Ordinary typing costs a lookup instead of a write lock, a failed write is retried by the next keystroke, and a takeover writes once per ownership epoch rather than once per window, because the flip to user_owned removes the pane from the candidate set. Sharing the predicate keeps the probe from drifting from the writer. Adds the two escape cases as permanent regressions, drives the mocked send through the real RuntimeTerminalWriter, and asserts a mobile takeover lifts the settled-worker resume fence, which no test covered. * refactor(orchestration): let the database dedupe the takeover, drop the read probe The probe was meant to keep keystrokes off BEGIN IMMEDIATE, so it had to earn that with a number. Measured against a real WAL database it costs more than the write it avoids: at 25 live workers the probe is 0.19ms and the no-op write is 0.10ms, because the probe runs the same candidate selection with each statement taking its own read snapshot instead of sharing the transaction's. It is a compensating mechanism with negative value, so it goes, along with the database method and the predicate extraction it needed. owned -> user_owned is one-way and scoped to a resource, so the database is already the dedupe: every deliberate human write attempts the transition, the second attempt matches no row, and the fence sweep runs only on changed > 0. Nothing is remembered between keystrokes, so no state can outlive the ownership it described -- a keystroke before the worker's authority attaches, a write the database refuses, and a re-dispatch onto the same pane all resolve against the rows as they are at that instant. An attempt costs about 0.1ms at typical fleet size and 0.34ms at 100 live workers, on mobile writes only. Replaces the write-count test, which asserted the old mechanism, with the invariant: many keystrokes settle into one takeover and one fence sweep. Adds the re-dispatch case, where a pane's next worker is fenced on its own merits. * refactor(terminal): name the provenance rule the takeover fence hangs off The fence rode the mobile input floor claim, with only a comment tying the two together. The floor is arbitration -- who may write next -- while the fence needs provenance -- who produced the bytes. They agree today, so anyone reweighing the floor would have moved the fence without noticing. isDeliberateHumanInput states the provenance rule on its own terms, and both byte lanes decide with it when they open a write: the claim carries the verdict beside the handle, and settlement records the takeover only when a human produced the bytes. No behavior change -- afterWrite is wired only where the predicate already answers true -- and the rule is now pinned by its own cases, so a future arbitration change has to answer this question again rather than inherit it. * test(orchestration): prove the unary lane classifies a metadata-less phone A phone build older than client.type is recognised only by its pane's mobile driver, which the unary lane passes as the provenance evidence. Nothing proved it did: replacing that argument with false left all 17 tests green while a shipped phone silently stopped fencing worker release. The new case drives a clientless send on a mobile-driven pane and fails under that mutation. The stream lane now passes false outright. Its isMobile is read off the same client object it carries, so the metadata-less phone cannot reach it, and passing the flag suggested a legacy path that does not exist there. Also states what the per-keystroke cost scales with. A pane owning no resource misses the pane_key index and falls through to a scan of owned resources, so the figure is tens of microseconds at realistic worker counts rather than a flat 0.1ms, and it grows with rows that are never released. * fix(terminal): let provenance alone decide the takeover, on every accepted write A phone older than client.type sends no client metadata, and both stream initializers derive isMobile from that metadata alone, so such a subscription reported false and took the stream lane's uninstrumented branch: provenance was computed and then never consumed. Bytes from a real person landed through both frame adapters and the resource stayed owned, so workerRelease closed the PTY under them. The unary lane already fenced that population off the pane's mobile driver, which is the host's standing reading of clientless input, so the two byte lanes disagreed at the destructive boundary. The predicate was still subordinate to floor plumbing: it could only be consulted where a floor client id existed. Now the accepted-write callback attaches on both lanes regardless of whether a floor was reserved, and humanInput alone decides recording; a write holding no claim commits nothing. Arbitration keeps its own condition around reserveWrite, where it belongs, and the unary lane's duplicate outer provenance filter is gone. The stream lane reads clientless provenance from the pane's driver, the same policy the unary lane uses. The claim holder is now TerminalInputWrite, carrying the verdict beside an optional floorClaim, so the structure says what the doc said: a write may fence without holding the floor. Regressions drive both real frame adapters, clientless direct delivery, and the paired-web desktop negative. Metadata-only provenance fails 3 on the stream lane and 1 on the unary lane; gating the callback on a reservation fails the same 3. * fix(runtime): resolve retained handles before mobile input provenance A renderer reload clears transient handles while retaining runtime-owned PTY identities. Legacy mobile provenance saw no leaf, then sendTerminal restored the same handle and delivered an unfenced key. Normalize through getLivePtyForHandle at the shared live-leaf resolver entry so classification and writes agree, preserving existing leaf generation/incarnation checks. Caller audit: - terminal-send-method: driver, query-reply authority, lock and floor checks now resolve the retained PTY before sending. - terminal-input-delivery: legacy mobile classification and exact-PTY binding now see the same target as the writer; equality checks remain. - terminal-multiplex-subscribe-resolution: retained PTYs resolve directly without a spurious missing-terminal wait. - terminal-lifecycle-methods resize and terminal-viewport-methods display mode, restore-fit and updateViewport retain their original PTY target. - inspectTerminalProcess: avoids false terminal_gone after reload while preserving provider inspection and incarnation fences. - getLivePaneKeyForTerminalHandle and getOrchestrationDispatchAuthority: unaffected because both already call getLivePtyForHandle first. No wire/schema changes, host fallback, process-death inference, or Git workspace assumptions; SSH providers keep ownership of execution evidence. Validation: - Unmodified round-3 reviewer probe: reproduced 2/2 failures, then 2/2 pass. - Unmodified round-2 reviewer probes: 13/13 pass. - Checked-in takeover suites: 24/24 pass. Removing only the resolver call fails both new reload cases; source restored afterward. - RPC orchestration + terminal, aggregate runtime handle registry, handle incarnation, mobile tab mount, stale geometry, and reload probe: 2027 passed, 1 skipped (89 files). - tc:node and check:code-quality:changed pass; background launch enabled. * test(rpc): require unconditional terminal afterWrite callbacks Update exact sendTerminal expectations for the round-2 accepted-write contract. Preserve beforeWrite expectations, absence of reserveWrite, byte payloads and call-count checks; require afterWrite to be a function. Reproduced the requested two-file run: 5 failed, 31 passed. The full RPC suite exposed the same stale shape in ACK budget/overflow, desktop resize (including its later retry), and agent-prompt fallback assertions. Update those too, for 11 assertions across six test files. No production changes. Validation: ORCA_BACKGROUND_LAUNCH=1 full src/main/runtime/rpc suite: 264 files passed; 2292 tests passed, 1 skipped. Changed-code quality and staged oxlint/React Doctor/oxfmt checks passed. Ran lint-staged --no-stash manually to honor checkout safety rather than its default backup hook. * fix(mobile): report worker takeover outside terminal byte delivery New phones announce accepted real user input through the existing worker report RPC, addressed by terminal handle. Share a per-client/per-handle 30-second gate with one bounded retry; report through the same RPC client as the input. Cover live commits and dictation via their shared sender, accessory keys, gestures, buffered submit, paste and accepted native chat. Query replies, attachment heals, triage and diff-review sends do not report. Phones predating this build do not fence release. Remove byte provenance and takeover callbacks from host delivery. Restore both lanes' pre-PR floor-claim plumbing and the original options assertions. Keep the host recorder uncached with its conditional resume-fence sweep. No DB schema or stream change; terminal is an optional report address. Retain the shared resolver recovery independently of takeover: the new SSH inspection test fails without it during renderer reload. Other callers still benefit for subscription, resize, viewport and exact-PTY binding; unary driver/lock checks see the retained PTY. Pane routing and dispatch authority already recover through getLivePtyForHandle and are unaffected. Existing leaf generation checks and first-PTY adoption remain unchanged. No other input-plumbing hunk is retained relative to the PR base. Replace byte-takeover tests with handle-addressed local/SSH report and unknown-handle tests, plus real unary/stream writes asserting zero SQL prepare/exec calls. Mobile send-site integration covers reports, exclusions, rejected writes and gate counts. Desktop report tests are unchanged. Register replacement coverage in the settled-worker release manifest. Validation (all background): host/RPC/runtime 3541 passed, 2 skipped; mobile session/terminal 2045 passed; node and mobile typechecks, changed quality, mobile oxlint, reliability manifest and max-lines ratchet passed. All five requested mutations fail assertions; resolver revert also fails independent inspection. Staged checks run manually with --no-stash. Final src diff against PR base: 5 files, +165/-13 (previously +839/-85). * fix(runtime): allow the takeover report from mobile-scoped tokens The mobile RPC allow-list gates every phone request before dispatch and the reporter swallows a refusal, so without this entry every phone shipped unfenced. Pin it beside the report tests, and pin the once-per-takeover fence sweep the replaced byte-lane suite used to assert. * fix(mobile): a no-op takeover report does not arm the gate; Stop reports too A key during worker startup reports before the resource is owned; caching that zero-change reply for 30 s suppressed the report that would have fenced the worker once it attached. Native-chat Stop is deliberate input and now reports on an accepted Escape. * fix(mobile): takeover gate ignores the host answer, like desktop Reopening the gate on a zero-change reply made every accepted key on an ordinary terminal an RPC plus a host write transaction (round 6: 100 for 100). The startup window it closed is unreachable: the agent has no prompt to accept input until after its resource row exists. Plain terminals now pay one report per 30 s window; the native-chat Stop report stays. Send-site fixture answers the report RPC with a changed count; the draft test filters to terminal.send calls. * docs(runtime): say why resolveLiveLeafForHandle re-links before lookup * chore(i18n): regenerate the runtime-required catalog for the contrast floor strings * test(orchestration): give the stopping-worker guard fixtures a Run * test(orchestration): drop fence-sweep assertions retired by the settled-worker policy * test(orchestration): pin the mid-boot phone takeover that #19608 makes possible A handle-addressed report during the worker's tui-idle wait now finds the custody row written at terminal creation, so it flips the pane to user_owned and worker-release retains it instead of closing it under the user. |
||
|
|
2ba2c90cb6 |
fix(orchestration): own a worker terminal from creation, not after the boot wait (#19608)
* fix(orchestration): own a worker terminal from creation, not after the boot wait A worker pane is visible on desktop and phone the moment it is created, but the worker_terminal_resources row saying orchestration owns it was written only after the agent TUI went idle (up to 60s). A keystroke into the booting pane found no owned row, markWorkerTerminalUserOwned returned 0, and the takeover was dropped - so a later worker-release closed the pane under the user. Record custody on the branches that create a terminal, right after creation and before the tui-idle wait. The Dispatch capability still waits for the agent to come up. An explicit --terminal reuse is untouched: it transfers at authority. With the row present from creation, the failed-start adoption is dead. What a failed start still needs is the Dispatch-context pane identity release re-proves through, which is now copied from the custody row. * chore(i18n): drop the orphan minimumContrast entries #19544 re-added to the runtime catalog |