mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
98fdbc4adee2b1d5d23cd24ea5d818eebef59691
1168
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
98fdbc4ade | fix(orchestration): file federated worker mail under the coordinator Run (#19542) | ||
|
|
da836faeef |
fix: preserve terminal retirement proof across renderer publications (#19002)
* fix: preserve terminal retirement proof across renderer publications * refactor: share the live-surface filter between retirement proof preservation and projection The publication projection already dropped proofs whose surface is live; reuse that as one helper instead of a second inline scan. * fix: emit stored retirement proofs from host-authored snapshot writes Three callers built a snapshot, stored it, then emitted the pre-store object. Storing grafts on the preserved proofs, so those frames carried the stored snapshotVersion without the proofs; subscribers dedupe on version and never saw them. * fix: send terminal retirement proofs once per stream and fence them by occupant Proofs are pinned per worktree for the host's lifetime, so every snapshot publication — including a 50ms title tick — re-shipped up to 64 proofs (~17 KB on realistic ids) to every paired client. Negotiate session-tabs.retirement-proof-delta.v1: the host projects each session-tabs stream to send a proof only the first time that stream carries it, and a capable renderer keeps the union in a ledger keyed by (environment, worktree) with the same 64-entry bound and the same live-surface drop rule as the host, reset on removed frames and on a new connection generation. Legacy clients keep receiving the full list; CLI and mobile do not advertise the capability. Also inherit worktreeInstanceId onto identity-less host writes so a host write between two renderer occupants can no longer launder one occupant's proofs into the next. * fix: keep an empty proof delta distinguishable from a proof-less host A negotiated stream now sends retiredTerminalSurfaces: [] when nothing is new instead of omitting the field. Absence is the host's "I hold no proofs" signal — which is also what a recreated worktree's fresh host entry publishes — so the client ledger forgets on absence and a successor occupant never inherits its predecessor's proofs, even when the removed frame was missed. * test: pin ledger visibility against a legacy full-list host An old host sends the full proof list whenever it holds any and omits the field when it holds none. Prove the new client ledger shows exactly what a legacy client would see across that sequence, so forgetting on absence is verified not to regress the mixed-version case. |
||
|
|
3b8128df04 |
fix(orchestration): index per-PTY mailbox reservation cleanup (#19390)
Co-authored-by: Merge Sim <sim@local> |
||
|
|
bcd4076dd1 |
fix(relay): never cache a region hint from a one-region catalog (#19349)
* fix(relay): never cache a region hint from a one-region catalog The director lists only regions with a serving cell, so a roll wave shortens the catalog to one entry. The resolver required only every *listed* region to be measured, so that lone region won against nothing and was cached for 24 h: a US desktop refreshing while US cells rolled published asia-east2 for a day, the incident #19233 was written to end. Now fewer listed regions than the fleet serves withholds the hint (1 h no-hint TTL), the same outcome as an unmeasurable peer. * test(relay): give the unstable-probe case a two-region catalog so it has one cause |
||
|
|
5cefb440bf |
fix(native-chat): stop an unanswered host from reading as one that refuses structured chat (#19321)
* fix(native-chat): stop an unanswered host from reading as one that refuses structured chat `readLocalRuntimeCapabilities()` returned `[]` both before the first status probe landed and after one failed, so "not asked yet" and "host says no" were the same value. Every structured-chat launch route consumed it, and an unprobed host was routed to legacy chat exactly as a refusing one is. Keep the two apart: the cache holds `null` until a probe succeeds, a failed probe leaves it `null` rather than emptying it, and the launch route names the case with its own blocker instead of borrowing `runtime-capability`. No routing outcome changes — both cases still decline structured chat. The point is that the reason is now truthful, which is what the routing work needs to build on: once a launch can target a runtime peer, capabilities come from that host, and an unanswered remote must not be indistinguishable from one that refuses. `hostCapabilities` on the launch route stays local-only at every call site; a per-target resolver replaces it when the route learns to reach a peer. * test: cover unknown runtime capability lifecycle and launch fallback --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
ce4a3a4186 |
feat(chat): add structured session rewind backend (#19235)
* feat(chat): add structured session rewind backend * fix(chat): make interrupted session rewinds recover safely * fix(native-chat): negotiate rewind runtime capability * fix(native-chat): consolidate remaining adapter imports --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
9fed61e5c2 |
Persist agents sidebar search visibility as pairing-local preference (#19313)
* Persist agents sidebar search field visibility as pairing-local preferen - Add `agentsShowSearch` to workspace UI state with default on - Include in pairing-local fields so preference syncs across clients - Convert search from menu action to checkbox menu item for explicit toggle - Update activity thread options menu to reflect checkbox state - Add localization strings across all supported languages - Update RPC schemas and preference persistence layer - Includes readiness validation reports confirming feature is clean * rm review * fix documentation |
||
|
|
5857357fcf |
feat(relay): log the region probe and name the assigned cell (#19307)
* feat(relay): log the region probe and name the assigned cell A desktop silently pinned itself to a far relay region for a day and every phone connect paid the round trip. Nothing in the desktop logs said which regions were probed, what they measured, why one was rejected, or which cell the host landed on, so the only way to diagnose it was a bench harness. The resolver now emits one line per outcome. A refresh carries every region's probe origins, the discarded warm-up, the kept samples, the minimum, the spread, and a verdict, then the chosen region or no-hint with the reason it withheld one. Cache hits, diagnostic overrides, and a director that cannot list its regions each get their own line so a quiet run is never ambiguous. Self-heal logs the cached region, the best measured region, the assigned cell's round trip, and whether it kept or deleted the cache. Only a refresh reports a catalog failure; a self-heal never chose a region, so a line saying it withheld a hint would be a lie. Relay status now carries the assigned cell so the pairing panel can name it. The field is optional because an offline host holds no assignment and the web client answers from a stub that never has one. Splitting catalog fetching out of the preference module keeps both files inside the line budget without a lint disable. * fix(relay): drop the assigned cell from statuses not served on it The origin pool publishes offline while it still holds the assignment it is about to rotate, so the panel kept naming a cell nothing was served from. The same class of bug hid a second instance: the coordinator republishes registered right after the broker announces its cell, and that republish carried no cell, blanking the value moments after it was set. The cell would never have reached the panel in the real flow. Deriving the cell from the status at each publisher removes both. The rule lives beside the status type because it defines when the optional field is populated, and the coordinator reads the owned broker's endpoint rather than trusting a call site to remember to pass it. * i18n: add the relay cell label to the English catalog * test(relay): audit the relocated region catalog fetch call site * fix(relay): report a self-heal whose catalog request failed instead of staying silent |
||
|
|
e60d9aaac9 |
fix(relay): attach a phone whose accept straddles a control rebind (desktop side) (#19238)
* fix(relay): attach a phone whose accept straddles a control rebind (desktop side) The cell announces a connection with a single conn-open. When the desktop's control socket dies mid-accept the phone waited out the 10s attach deadline and was closed HOST_OFFLINE, even though the desktop was online. host-hello-ack already restates those connections in pendingConns; the desktop parsed the field and threw it away. Desktop replays pendingConns on activation. It needs kind and relayDeviceId to dial: they decide the pairing authority a connection carries and the E2EE device binding, so neither may be guessed. Two sources, in order: - the ack entry, when the cell states them. This is the case where the desktop never received the conn-open at all, i.e. the headline scenario, and it needs the cell change in #19266. - the conn-open this process already saw, when the frame arrived but the data socket died with the control. Works against every deployed cell. An entry described by neither is skipped with a warning rather than dialed. Entries already spliced or already holding a data socket are skipped, so a rebind never double-dials. The desktop advertises x-orca-host-capabilities: pending-conn-details on the control upgrade so a cell knows the ack entries will be read. The header name and token are duplicated by hand because the desktop cannot import the relay contract; both sides assert the literals so drift fails a test rather than silently disabling the feature. Also stop dropping a conn-open that lands while the control is draining. A drain-only cell refuses new phones, so such a frame predates the drain and only that cell holds the waiting phone. * test(relay): pin the attach deadline the desktop mirrors by hand RELAY_HOST_ATTACH_DEADLINE_MS duplicates RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs, which the contract suite already pins to 10_000. Now that the cell and desktop halves land as separate PRs the two can drift independently, and drift would silently shorten both the observed-open eviction window and the deadline a replayed dial states, with nothing failing. |
||
|
|
33af0af4ea |
fix(relay): stop rejecting the near region on a cold first probe (#19233)
* fix(relay): stop rejecting the near region on a cold first probe The region probe counted the process's first /health request, which pays TCP and TLS setup, as a latency sample. The resulting spread rejected the near region on essentially every cold run, leaving the far region as the sole survivor and pinning US desktops to Asia cells for 24 hours. Discard a warm-up probe per origin, compare regions by minimum latency, and keep the spread check only for a genuinely flapping path. A region now wins only against a measured competitor; a rejected or unmeasurable peer sends no hint, which is remembered for an hour so a reconnect does not re-probe. An origin that fails its warm-up is dropped before the sampling rounds, so an unreachable region costs one probe timeout instead of four. After a control socket registers, probe the cell we landed on once per process, and delete the cache only when it names a region other than the best measured one and that cell is more than 3x slower -- a far cell under a correct cache is the director declining the hint, and re-measuring would return the same answer. * test(relay): audit the region probe's global fetch call site |
||
|
|
0252fe5c36 |
feat(native-chat): show Codex subagent activity instead of opcode rows (#18773)
* feat(native-chat): show Codex subagent activity instead of opcode rows
Codex spawns subagents and reports their lifecycle, but Orca rendered only
gray `codex · item:subAgentActivity` opcode rows. Build the real display: one
summary row per spawn group with a live working count and token usage.
State is accumulated from `subAgentActivity.kind` alone. A live probe against
app-server 0.152.1 showed `agentsStates` arrives empty even in a real subagent
run, and that every activity item is delivered twice (item/started and
item/completed), so every transition is idempotent and terminal states latch.
Children never receive `thread/started`, so there is no nickname, role, or
depth to read; the row labels from the trailing segment of `agentPath`.
Two sweeps keep a row from claiming work forever: the parent turn's terminal
event settles still-running children, and session start marks a pre-restart
roster unverifiable rather than exited, since Codex resume replays no
non-message items and no event can ever settle them.
The roster rides a new NativeChatBlock variant paired with a plain-text twin.
A journal item kind could not be used: that union is closed, and an unknown
kind parses as malformed, which is the corrupt-journal class that can hide the
chat tab. Block types are explicitly admissible when unknown, so an older
client drops the block and renders the sentence.
MessageRow moves out of NativeChatMessageList to keep both files under the
max-lines budget without a disable.
* feat(native-chat): give the subagent summary row its bot glyph
The row led with a glyph that swapped on state — a check once every child
completed, a group icon otherwise — so a group appeared to change identity
the moment it settled. Per the approved mock, the glyph names the category
and never moves: state is carried by the status dot and the tone of the
words beside it.
Use lucide `bot`, the same glyph the individual `subAgentActivity` rows take
in the eight-category vocabulary, so the summary reads as their parent. Slot
and glyph are the mock's 16px/14px, muted by default, and the svg is
`aria-hidden` — the headline is what a screen reader announces, so the icon
never stands alone.
* fix(native-chat): correct the Codex subagent roster's build, journal write, and failure reporting
* Restore the exhaustive block handling that adding `subagent-group` to
`NativeChatBlock` broke. `formatWorkerTranscriptMessage` and `boundBlock`
both fell through to `image-ref` field access, so `tsc -p` failed for the
CLI and node projects and `build:cli` could not emit. Both now guard on
`image-ref` explicitly and give the roster block its own branch.
* Stop the roster's publish from evicting its own append. The sink queue
coalesces by `coalescingKey` alone with no op-kind check, so passing the
append's key to `tryPublish` spliced the queued append out and the row
never reached the journal — permanently, since `lastSerialized` was
already set. `tryPublish()` now takes no argument, matching every other
call site. The regression test's fake sink honours the key, which the
previous fake did not.
* Keep `collabAgentToolCall` substantive. Only the MultiAgentV2 path emits
`subAgentActivity`, so a V1 turn has no roster row; suppressing its collab
tool calls too would have left a V1 fan-out showing nothing at all.
* Surface a settled failure while siblings still work. The summary now
reports the worst adverse outcome independently of the group verdict, so
the row shows `3 working +1 failed` with a failed-coloured dot instead of
a neutral pulsing dot. The plain-text twin names it too.
* Treat `/morpheus` as a child. Only `/root` is the turn itself; the old
segment-count test silently dropped a valid single-segment agent.
* Refresh token-usage recency on update so an active thread is not evicted
as the oldest entry, and scope the `agentsStates` comment to the V2 path.
* fix(native-chat): stop the subagent roster announcing a new duration every second
The roster row is an `aria-live="polite"` region and it contains the elapsed
clock, which reticks once a second for as long as the fan-out runs. A screen
reader therefore reads out a fresh duration every second, burying the state
changes the live region exists to report — the headline, the verdict, and the
`+1 failed` alert.
No other live region in the transcript does this. `NativeChatToolRun`'s live
button holds only the active tool label, and in `NativeChatWorkingStatus` the
variant that shows a duration is precisely the one with no `aria-live`.
Hide the clock from the accessibility tree only while it is moving. Once the
group settles the duration is fixed, so it stays readable and costs no
announcements.
* fix(native-chat): retry a refused roster publish, and stop two wrong readings
Four defects from a third review pass over the Codex subagent roster.
`write()` set `lastSerialized` before the append and rolled it back only when
the APPEND was refused. A refused PUBLISH left it set, so an identical replay
short-circuited and the revision was never published again. The repo's own
pattern is the opposite: `codex-structured-item-streams.ts` advances
`checkpointLengths` only once the append AND the publish are both accepted.
Roll back on either half.
That alone did not cover the sweep, which is the LAST event a group ever gets:
its `changed` guard skips the write on a retry because every child has already
latched, stranding the settled roster's final revision. Write when the previous
attempt was refused part-way, too.
`formatWorkerTranscriptMessage` read `block.agents` as its exhaustive fallback.
The journal schema deliberately admits block types this build does not know and
`client.call` casts the RPC result instead of validating it, so a newer remote
host's block reached that line and threw `agents is not iterable`, taking down
the whole `worker read`. It printed a harmless `[image omitted]` before. Match
`subagent-group` explicitly and degrade the unknown case.
The elapsed clock measured to `now` whenever no child carried a terminal
timestamp. That is exactly the roster restored from the journal after the host
died: the reconciler latches `unverifiable` without a `settledAt`, so a child
that ran four seconds reported the time since the crash as its run length, on a
row that is not even counting. Show no duration when none is known.
Also restores package.json to origin/main: the merge had deleted one of main's
two duplicate `bench:terminal-partial-escape-tail` keys. Behaviour-preserving
(JSON is last-wins and the deleted line was the dead one), but unrelated to this
PR and better left to its own change. No gate rejects duplicate JSON keys.
The new refusal tests also cover the append-side rollback, which had none.
* fix(native-chat): stop the subagent roster vanishing from every settled turn
`NativeChatToolRun` bailed out for a completed turn whose activity disclosure
is collapsed before it reached the branch that draws a roster-only run. That
guard exists to push TOOL activity behind the turn-status disclosure, and it
fires on exactly the shape a spawn group has: a roster message carries no tool
blocks, so `selectActiveToolCall` returns null and `isSettled` is true, while
the list passes `expandOverride={expandedTurnIds.has(turnKey)}` — false until
the reader opens that turn — and `activeTurnIsWorking={false}`.
That is the default state of every finished turn in the transcript, so the one
compact row this feature exists to leave behind ("Ran 3 subagents") disappeared
the moment its turn ended. Worse, `MessageRow` counts a spawn group as
renderable specifically so the row survives, then rendered a wrapper around a
component that returned null — the empty ghost bubble its own guard is written
to prevent.
Order the roster branch before the disclosure guard. A roster has no tool
activity to hide, and the guard's reasoning ("a failed child command looked
like the whole response was still running") does not reach it. Runs that do
carry tool blocks still fall through to the guard unchanged, and in practice a
roster never shares a message with them: it is its own `role: 'system'` journal
row and `isToolOnlyMessage` is false for it, so `foldToolMessages` never merges
tool blocks into it.
Also drop childless groups when building the rows, so `subagentRows.length`
stays an honest test of "something will draw" — the roster-only branch returns
a margin-bearing wrapper on the strength of it, and a group with no children
renders null.
Both tests fail with their fix reverted; the existing NativeChatToolRun suite
still passes, so the completed-turn disclosure behaviour is unchanged.
* test(native-chat): cover the subagent roster at the message-list level
Every defect this feature has shipped so far lived in the assembly between
rows, and the row-level suites kept passing through all of them. Loop 4's
regression — a settled roster swallowed by the completed-turn disclosure —
was found by reading the code, not by a test, and an independent visual-proof
run observed the same symptom in the real UI and routed around it rather than
reporting it. `NativeChatToolRun` rendered alone is handed `expandOverride`
and `activeTurnIsWorking` by the test author, so it agrees with whatever the
caller was assumed to pass.
Drive the real component instead. The roster is its own `role: 'system'`
journal row carrying the producer's two blocks (structured + plain-text twin),
so what reaches the DOM depends on `foldToolMessages`, the turn-key mapping
and the disclosure state `NativeChatMessageList` owns — none of which a row
test exercises.
Three cases, on one assembled transcript that holds tool calls AND a roster:
- a settled turn with activity collapsed, the resting state of the whole
transcript, still shows the row (fails with loop 4's reorder reverted);
- tool activity stays behind that disclosure and appears only on expand,
and expanding draws no second roster (fails with the guard removed);
- a working turn reads as a live spawn.
The first also pins that the plain-text twin is dropped rather than printed
beside the row it stands in for.
Timestamps are explicit and ascending: the list re-sorts by (timestamp, id),
so rows sharing a millisecond tie-break alphabetically and the user turn can
sort last, stranding the roster outside its own turn and reconciling live
children to `unverifiable`.
No production code changed.
* fix(native-chat): make "counts as renderable" and "actually draws" agree for a spawn group
`MessageRow` counts any `subagent-group` block as renderable, but
`NativeChatSubagentRun` renders null for a childless roster. A group with
`agents: []` therefore mounted a row that drew nothing — an empty div that still
costs the transcript one `gap-5` slot. The Codex producer never writes one (every
`write()` call site operates on a group that already holds an entry), but the
block schema admits `agents: []` with no `.min(1)`, and the wire is where such a
shape would arrive.
Narrow `subagentGroupBlocks` — whose only production caller IS that renderable
check — to the groups that will draw, behind a named `isRenderableSubagentGroup`
that `NativeChatToolRun` now shares in place of its own copy of the predicate, so
the two guards cannot drift apart again. A childless group carrying its
plain-text twin now prints the twin, which is what the twin is for; a bare one
skips the row entirely.
Also correct four comments that had stopped describing the code:
- the roster header called `agentsStates` "always empty", contradicting the
probe note in `codex-subagent-activity.ts` — it is empty on the MultiAgentV2
path that emits these items, and the V1 path does populate it;
- `tokensByThread` was documented "retained UNCONDITIONALLY" while
`handleTokenUsage` LRU-caps it 65 lines below;
- the sweep is not "the LAST event a group ever gets": neither `settleTurn` nor
`settleSession` removes the group, so a later `thread/tokenUsage/updated`
naming a swept child still writes it. The retry condition is right; only its
stated reason was wrong;
- the `subAgentActivity` classification is not reached "for every event — and
every one of them arrives twice". `handleSubagentItem` intercepts those items
before `items.handle`, so the live path never consults the catalog;
`restoreThread` replays them straight through, and is the real consumer.
Comment-only apart from the childless-group guard.
* fix(cli): stop `worker read` printing the subagent roster sentence twice
The producer ALWAYS writes a roster block beside a plain-text twin carrying the
same sentence, for clients that cannot draw the block. The renderer honours that
contract from one side — it draws the block and drops the twin. The CLI honoured
neither side: it printed the twin as prose AND rendered the block as
`[subagents] <same sentence>`, so a real roster message read
[system] Ran 2 subagents (1 failed)
[subagents] Ran 2 subagents (1 failed)
Take the mirror of the renderer's rule, which is the cleaner half for a text
client: the twin IS the sentence, so print it and drop the block it stands in
for. A block that arrives WITHOUT its twin — a shape the wire admits and no
producer writes — still stands in for itself, because dropping it
unconditionally would lose the roster entirely. Either way the sentence prints
exactly once, off the same `subagentGroupFallbackText` helper both sides use.
Unreachable through `readWorkerTranscript` today, whose provider rollout decoder
never emits a `subagent-group` block — but the formatter is the CLI's contract
for any transcript source, and the shape is already producible.
The test pinned a TWIN-LESS group, a body `codexSubagentGroupBody` never writes:
it asserted the exact double-print this fixes was correct output, and would have
blessed either behaviour. Rebuild the fixture as the producer's real two-block
row, with the sentence taken from the shared helper rather than hardcoded so it
cannot drift, and assert the sentence appears exactly once. The twin-less shape
keeps a test of its own, labelled as the wire-only fallback it is.
Also record why `settleTurn` keys on the RAW `turnId` while `groupFor` remaps
off-primary activity onto the primary's active turn. The asymmetry is
load-bearing, not an oversight: were `settleTurn` to remap, a child thread
ending its own turn would sweep the parent group and settle every still-working
sibling to `unverifiable`. The lookup missing is the intended no-op.
* fix(native-chat): add the subagent roster's localization keys and narrow its twin filters
The roster row called 16 `components.native-chat.subagents.*` keys that were
never added to the catalog, failing the localization gate. Synced en.json; the
English strings are the component's own inline fallbacks, so nothing renders
differently.
Also tightens the twin/block handoff on both readers. The renderer dropped
every text block once a roster was present, which is safe only because Codex
writes a roster as its own message — the block is provider-agnostic, so a lane
folding prose in beside one would have lost it on desktop while mobile kept it.
And both readers decided "the twin is already printing" by recomputing the
sentence and comparing bytes, which a roster from a newer build never matches:
its unknown state normalizes to `unverifiable` here, so the CLI printed the
roster twice with two different verdicts. Both now recognize a twin by shape.
* test(native-chat): pin the roster twin recognizer against prose
Both readers use it to decide the twin is already printing, so a false positive
eats a message's real prose and a false negative prints the roster twice.
* docs(codex): restore the roster's evictionated trigger to its KNOWN LIMITATION
The previous rewrite dropped both triggers the old comment named and kept only
the restart one, but eviction is the reachable half: `groupFor` caps `groups` at
MAX_CODEX_SUBAGENT_GROUPS and drops the oldest-INSERTED entry (it returns an
existing group without re-inserting, so this is not LRU), which can evict a
still-live group in-process. The row identity is keyed on the group id alone, so
the next activity item rebuilds that row from one child — the same N-to-1
rewrite, with no restart, and with the sweep skipped so the children never latch
`unverifiable`. Also softens "every real turn id is freshly minted" to the
provider assumption it is: turn ids are read verbatim off provider frames and
nothing in this repo mints or asserts them.
* docs(codex): justify the subagent wire notes from the live probe alone
The roster and disposition comments explained themselves in terms of a
provider-internal path taxonomy rather than anything this repo can observe.
Restate them from the evidence Orca actually has: the live app-server probe
saw `agentsStates` arrive empty, so nothing reads it; and `collabAgentToolCall`
stays substantive because nothing guarantees a session reports subagent work as
`subAgentActivity` at all — one that only emits the collab tool call gets no
roster row, and suppressing that too would leave its fan-out blank.
Same behaviour, same tests; comments and one test name only.
* fix(native-chat): stop the roster's durable twin from claiming live subagents
The spawn-group row is written once and revised in place, but the row itself
is durable and replayed on every reconnect. Its plain-text twin — the only
thing a client that cannot draw the block ever sees — froze a live count into
that row: `Kicked off 4 subagents — 2 working`. The desktop renderer never
shows it, and reconciles the block's `working` to `unverifiable` outside the
live turn. A text-only reader does neither. When the writing process dies
mid-flight the turn-end sweep never runs, so the sentence keeps asserting two
running children forever, with nothing left that could re-check them. That is
the collapse `docs/reference/ssh-execution-boundary.md` forbids: loss of
contact reported as a live state.
Fix it at the source rather than per client: the durable sentence now states
only what survives its process — that the group was spawned, plus whatever
outcome had latched. `Kicked off` vs `Ran` stays, because it reports whether an
outcome was recorded at write time; saying `Ran` while children were in flight
would assert they exited, the same error inverted. The adverse count stays so a
failing fan-out still reads as failing. Reconciliation stays in the renderer,
where the block still needs it.
The twin recognizer keeps matching the legacy `— N working` shape: journals
already hold those sentences and their rows replay forever, so dropping the
branch would print every one of them twice, once as the block and once as prose
the reader meant to drop.
Also align the two functions that read `agentPath`. The root check compared the
raw string while the label normalized separators, so `/root/` was both the turn
itself and a child of it — a phantom row labelled `root` inflating the group by
one. Compare normalized segments instead, keeping `/morpheus` a child. And a
trailing segment with nothing visible in it survives the empty-segment filter
and would draw a nameless row, so it now reads as no label and falls back to the
placeholder.
* fix(codex): key the subagent label collision ordinal on what the row draws
`codexSubagentLabel` tested the trailing segment trimmed but returned it
untrimmed, and `claimLabel` keys its collision ordinal on that string. Two
children at `/root/read` and `/root/ read ` therefore both drew as `read`
with no ordinal — the one thing the ordinal exists to prevent. Return the
trimmed segment so labels that render identically collide.
Also correct the legacy-clause note on the twin recognizer. It claimed shipped
journals hold the old `— N working` sentence; the feature is unreleased, so the
only journals holding one are dev worktrees of this branch. The branch still
earns its place — those rows replay too, and it adds no false-positive surface
the bare shape does not already carry — but the stated reason was wrong.
* test(native-chat): retire the subagent-visibility guards now the roster renders
Two tests from the sibling item-coverage PR asserted that subagent items stay
on the generic gray row, explicitly gated on "until a real renderer exists".
This branch is that renderer, so both guards fire on merge — the handoff they
were written to mark rather than a regression.
They now pin the other side of it: subAgentActivity is suppressed because the
spawn-group roster renders it, and collabAgentToolCall deliberately stays
visible, since nothing guarantees a session reports subagent work as
subAgentActivity at all.
Git merged both files without conflict; only running the suite surfaced this.
* fix(native-chat): let a subagent swept at turn end still report what it did
The turn-end sweep marks still-running children `unverifiable`, and the
producer latched on any state that was not `working` — so `unverifiable`
latched too. A subagent that outlived its turn then reported `completed`, the
latch refused it, and a child that finished successfully read as one we never
saw finish, permanently.
One predicate was doing two jobs. `isTerminalSubagentState` is right for
counting — `unverifiable` is not working — and wrong for latching, because
`unverifiable` records that we stopped being able to see the child, not what
it did. Split them: a child's own verdict latches, the sweep's guess does not.
The reverse stays refused. Nothing returns to `working` once we have given up
on it, so a straggler progress tick cannot re-light a settled row.
Neither the latch nor the sweep was wrong alone, and both were tested; the
defect lived only in their interaction, and only when a subagent outlives its
turn — which the probe that drove this design never produced, because the
parent it captured waited on its child.
* fix: drop the @pnpm/exe lockfile drift a merge staged
`git add -A` swept up the pnpm-lock.yaml mutation that every pnpm invocation
leaves in this repo. Nineteen lines, thirteen of them @pnpm/exe, and it fails
sixteen unrelated CI checks — native smoke, typecheck, packaging, xterm patch
sync — none of which name the lockfile.
* fix(native-chat): restore the item fall-through an inline dropped
Inlining the subagent routing helper lost its null check: the roster returning
null means it did not claim the item, and the translator must keep looking.
Returning unconditionally once any thread item parsed swallowed every ordinary
item — twelve settlement tests, none of them about subagents.
* fix(orchestration): rebind the subagent block arm to the renamed bound state
Main renamed clipMetadata's second parameter from a warnings set to a
TranscriptBoundState. The subagent-group arm still passed `warnings`, and git
merged both sides without a conflict because the lines never overlapped — the
rename and the new arm are in different hunks. Typecheck was the only thing
that could catch it, and did.
* fix(codex): publish the turn tail for a subagent item the roster claims
Main's #19055 added a `subAgentActivity` arm to the provider activity table,
which is reached only through `publishActivity`. The roster's admission returned
above that call, so every `subAgentActivity` item bypassed it and a fan-out that
reports nothing else left the turn tail stuck on the previous frame's text.
`publishActivity` already no-ops on a refused admission and on a non-primary
thread, so routing the roster's admission through it is safe.
Also corrects a docstring the frames extraction copy-pasted onto
`settleOversizedNotification`.
* fix(native-chat): bound the subagent roster on every boundary that carries it
The spawn-group arm was the one collection in the worker-transcript payload with
no cap, and the one block type mobile's `sanitizeBlock` forwarded verbatim. The
producer's `MAX_CODEX_SUBAGENTS_PER_GROUP` does not reach either boundary: the
journal schema declares no maximum on `agents`, and a remote host may run a build
with a different cap. Both transports now cap the roster and bound `id`, `label`
and the open `state` string; `label` and `id` also take the standard inline bound
on the journal write path, where every other provider string already does.
A token count is now persisted onto its entry at write time. `write` rebuilt
`tokens` from the LRU-capped thread map on every write, so an eviction silently
retracted a count the durable row had already shown.
Adds the first coverage of the three roster caps, including the group eviction
that rewrites a row from N children down to one.
* fix(native-chat): keep the roster drawn beside tool calls and its clock honest
The roster-only escape is keyed on `blocks.length === 0`, so a spawn group
sharing its message with tool-call blocks fell through to the settled-turn guard,
which returned bare null and took the roster with it — the exact regression the
escape above was written to avoid, after the message row had already counted the
group as renderable. Unreachable for Codex today; the block type is deliberately
provider-agnostic, so it is live for the Claude lane.
The elapsed clock also froze at a sibling's timestamp on a partial sweep: in a
group where one child completed and another is unaccounted for, the ended turn
left `working === 0` with the completed child's `settledAt`, and the row showed
that child's duration as the group's run length. No clock is drawn while any
child is `unverifiable` with no terminal timestamp.
* perf(native-chat): bound the roster's provider strings without digesting them
`boundInlineText` computes a sha256 and a Buffer BEFORE it checks the length,
so the roster paid two digests per child on every write even when nothing was
truncated — and `write()` runs on every claimed activity item (each delivered
twice) and again from `handleTokenUsage`, which streams. A same-process A/B over
a 64-child group: 76.5 us/write before, 2.0 us/write after (plain, unbounded row
is 1.2 us).
The cap changes with the mechanism. 16 KB is the tool-output bound; both readers
of this row already clip the same fields to 512, so the producer was admitting
~2 MB per durable roster row for consumers to throw ~97% of away. One
`MAX_SUBAGENT_FIELD_CHARS` now serves the producer and both readers, and the
marker is an ellipsis rather than the tool-output truncation sentence — `id` is
the roster key and the renderer's React key.
Also raises the orchestration arm's per-group bound from 20 to the producer's
64, matching the mobile arm: a 21-64 child group is routinely producible here,
so that arm clipped children and warned while its sibling clipped none. The
slice and warning stay as the transport's own defence against a remote host with
a larger cap.
* fix(orchestration): suppress one roster block per twin, not all of them
`hasTwin` was a single boolean over the whole message, so a message carrying two
`subagent-group` blocks and one plain-text twin printed one sentence and dropped
the second roster with no marker. Count the twins and claim one per group
instead. Not reachable from this branch's producer, which writes one group per
journal item, but the surrounding reasoning is explicitly about wire shapes the
producer never writes and this is the adjacent one it missed.
* fix(native-chat): loop-3 fixes to the Codex subagent worklog
Five defects loop 2's own fixes introduced.
Twin claiming was order-blind: the count-based claim silenced whichever
roster block came first, so a lone twin belonging to a LATER group erased
an earlier group's roster and printed the later sentence twice. Exact-text
claims are now settled for every group before any leftover twin is claimed
by position; the positional fallback stays for a newer build's frozen twin,
which can never equal a recomputed sentence.
`boundSubagentField` sliced UTF-16 units and could leave a lone high
surrogate in a durable row, and the clip removed exactly the tail that told
two children apart — `id` is the renderer's React key and `claimLabel`
writes its repeat ordinal at the end. It now backs off a split pair and
reserves the child index inside the bound, so both readers' re-clip cannot
cut the disambiguator off again.
`MAX_SUBAGENT_FIELD_CHARS`'s doc claimed a `groupId` bound the producer
never applies; the doc now says so and why. The worker-transcript metadata
cap is a separate literal again: it governs message ids, turn ids, tool-call
names and image urls, so a roster-motivated change must not move it.
* fix(native-chat): never infer a lost subagent from a turn boundary
QA drove a real Codex session with three live `spawn_agent` children and sent
a mid-turn correction. The roster row immediately read "Ran 3 subagents /
3 unverifiable" with no clock, while all three were still running — they
reported `completed` 57-87s after that turn ended.
Both sites rested on the same false premise: that a turn ending means no
event will ever settle a child. Children outlive their turn and keep
reporting into the same group.
- Renderer: drop `reconcileSubagentRoster`. Nothing plumbed to the component
distinguishes a row written by a dead host from a turn that merely ended —
journal render items carry no epoch, and a new epoch deletes the rows of the
one it supersedes — so the row now draws the state the journal recorded.
Under-claiming beats over-claiming.
- Main: stop sweeping on `turn/completed`. That sweep wrote `unverifiable`
into the DURABLE journal, which mobile reads with no reconciliation.
`turn/completed` is Codex's only turn-end notification, so an abort cannot
be told apart from a clean finish; the safe default is not to sweep.
`settleSession` — the provider actually being gone — is unchanged and is now
the only sweep. `unverifiable` stays non-latching so a late verdict still lands.
* test(native-chat): pin the roster at the seam the QA defect came from
The mid-turn correction opens a new turn, so the fan-out's row stops being
the current turn and the list hands the roster `activeTurnIsWorking={false}`.
Asserted through the list, not the component, because that prop is what
carried the wrong claim.
* fix(native-chat): settle a roster the dying host never got to sweep
`settleSession` only fires when the provider goes away while this process is
alive. If the host itself dies, nothing sweeps and nothing reconciles on
restore, so a `subagent-group` row persisted as `working` claimed live children
forever — the mirror of the defect the previous commit fixed, and the same
`ssh-execution-boundary.md` violation in the other direction.
Reconciled host-side, at journal open, not in the renderer: mobile shows only
the durable text twin and reconciles nothing, so a renderer-only fix would
leave it claiming live children indefinitely. Opening the journal is also the
one moment a host can honestly say the previous writer is gone.
- `staleSubagentRosterRevisions` rewrites every child still reading `working`
to `unverifiable` and regenerates the twin from the same summary, so the
block and the sentence cannot disagree.
- No terminal timestamp: the child stopped being observable at an unknown
moment, and stamping the reopen would report the downtime as its run length.
- Revises in place under the parsed identity, so a reopen upserts the row
rather than appending a duplicate, and a second reopen writes nothing.
- Skipped on a corrupt load: that journal is still owed a rebuild from provider
history, and content past the repair's free sequence retires the demand.
Reconciles journal ROWS, not roster state — the producer's in-process group map
is untouched, so the roster's known seeding limitation is unchanged, as is
`canReplaceSubagentState`: `unverifiable` still does not latch.
---------
Co-authored-by: Merge Sim <sim@local>
|
||
|
|
bffdad9f05 |
fix(native-chat): make structured chat tabs renameable (#19153)
* fix(native-chat): let a structured chat tab be renamed Renaming a native chat tab accepted the text and silently did nothing: setTabCustomTitle only scanned terminal tabs and only bridged to unified tabs whose contentType was 'terminal', so the agent-session tab it was keyed to never matched. Any label that did land was then re-nulled by the next host snapshot, which preserved color/createdAt/isPinned but not customLabel. Also routes both placeholder sites through one helper so a Claude chat stops falling back to 'Codex Chat'. * test(native-chat): cover structured chat tab rename and label fallback * chore: drop the local @pnpm/exe lockfile artifact Swept in accidentally; running pnpm here adds @pnpm/exe to the root lockfile, which fails CI's frozen-lockfile guard. * fix(native-chat): reach the rename shortcut and tab color too Review found the first fix covered only the context-menu path. The tab.rename shortcut gated on activeTabType === 'terminal', so on a structured chat tab it stayed the silent no-op this branch set out to fix. setTabColor carried the identical terminal-only lookup one function below the one that was fixed. Both lookups now share one resolver instead of two copies. * fix(native-chat): stop unknown agents reading as Codex, cover the terminal path Review found the placeholder helper encoded "unknown means Codex": its signature accepts null/undefined and Tab.agentSessionAgent is the open AgentType, so the first caller passing a Tab would label gemini or grok as "Codex Chat". Routed through the shared agent-name table instead. Also adds the missing regression test that a terminal rename still resolves through its entityId now that both rename and color share one resolver, and a guard on a test that passed with the fix reverted. * fix(native-chat): degrade instead of throwing on a null tab title A stacked branch can publish title: null when a conversation name is cleared. The wire type says string, so this consumer trusted it and threw inside the store patch that applies the snapshot. Fall back to the placeholder — the producer bug is fixed separately, but a consumer of wire data should not crash on a contract violation. * fix(native-chat): rename the focused structured tab, not a background terminal * fix(native-chat): cycle terminals from the structured tab, not a stale terminal --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
a899f92402 |
feat(windows): enable structured Codex chat on native Windows (#18519)
* feat(native-chat): enable Windows structured sessions
* fix(codex): prove native Windows process identity
* style(codex): format Windows session seam
* fix Windows structured Codex admission
* fix(windows): reprobe missing process identity capability
* fix(windows): decide folder-workspace WSL routing before the click
Review found pathUsesWslUnc exported but unused, and the folder composer
hardcoding worktreeUsesWslPath:false. Together those meant a folder picked
under a \\wsl.localhost\ parent routed to structured chat, then got refused
by the host and fell back AFTER the click -- which defeats the lane's own
design goal that create cannot fail after the click.
The group's parentPath is in scope at submit and the workspace is created
under it, so the parent decides WSL-ness pre-click. Wires pathUsesWslUnc
there and adds tests for the helper, including the unhydrated-store case
that previously threw.
* fix(windows): collapse the gate derivation to one call, restoring max-lines
CI static analysis failed: launch-agent-in-new-tab.ts crossed the 300-line
oxlint ceiling. Adding a max-lines disable is forbidden, so the two gate
derivations collapse into one readWindowsStructuredGateInputs() call --
a store-backed site now adds one line and one import name instead of two.
Better shape anyway: one derivation entry point rather than two reads a
call site must remember to pair.
* fix(windows): engage the legacy fallback when the host THROWS a refusal
Review found a P1 this merge composes: neither parent could reach it. At the
lane head the only structured entry was launch-agent-in-new-tab (full
store-backed WSL check); on main all win32 was refused. The merge enables
win32 in creation flows that pass no projectRuntime, so a WSL folder
workspace, a WSL-configured repo, or a repair-required runtime now routes
structured -- and the host refuses correctly, but by THROWING rather than
returning {ok:false, refusal}.
Callers engage their legacy-terminal fallback on the refusal CLASS, so an
unmapped throw arrives as a generic RPC rejection: no fallback, empty
workspace, error toast, prompt stranded in the launch outbox. Pre-merge the
same action opened a legacy terminal agent.
Map the host's thrown definitive refusals onto the refusal class at the
launch boundary, so every creation flow -- present and future -- degrades to
the legacy terminal instead of stranding. Narrow predicate: unrelated
failures (ECONNRESET, empty message, non-Error) still propagate untouched.
Ablation-proven: removing the mapping reddens the fallback test.
* fix(windows): teach the mobile RPC double the status probe the lane added
CI's first-ever run on this lane caught a pre-existing lane defect. The lane
changed status.get to resolve through
runtime.getStatusAfterWindowsProcessStartTimeProbe(), but never taught the
mobile-surface runtime double about it, so status.get failed for mobile
clients with "not a function". The lane's own test list did not include this
file and the lane had zero CI, so nothing ever ran it.
The real runtime always implements the method; the double omitted it.
* chore: merge current main and regenerate the localization runtime catalog
CI static analysis failed on a stale en-runtime-required.json: main added
onboarding integration-capability keys, and the generated catalog is checked
against the PR MERGE result, not the branch alone -- so it read clean locally
while failing in CI. Merging current main (
|
||
|
|
fede3eb2ff |
fix(test): give federation tests a real read-after-write sync barrier (#19262)
`syncOrchestrationFederation()` coalesces onto an already-in-flight relay-tick sync, which may have pulled from the peer before the caller's mutation existed. Tests used it as a barrier, so `keeps a timed-out remote question resumable` could reply against a home DB that had never imported the worker's question: the reply failed with `Message not found`, no `to_worker` relay was enqueued, and the resume ask surfaced it 5s later as a spurious timeout. Add `syncFederationBarrier()`, which chains each active dispatch past the current round via `syncOrchestrationFederatedDispatchAfterCurrent`, and use it at every barrier-purpose sync site. The two tests whose subject is the sync machinery itself keep the raw call. Also assert the reply response, so a failed reply fails at the reply instead of masquerading as a timeout. Production is unaffected: `syncOrchestrationFederation` has no production callers, real read-after-write paths already use the after-current sync, and relay ticks retry every second. |
||
|
|
a3e67365a3 |
fix(orchestration): recover Codex idle after completion title race (#19243)
* fix(orchestration): recover Codex idle after completion title race * test(native-chat): enable structured sessions in adoption replay fixture * test(orchestration): cover deferred pointer recovery after prolonged unknown status * fix(orchestration): fence completion recovery by process generation |
||
|
|
c3a70082c6 |
Fix MiniMax credential-expiry reporting, region sync, and refresh (#19250)
* Fix MiniMax credential-expiry reporting, region sync, and refresh Three defects from #14929: 1. The usage endpoint answers an expired cookie or key with HTTP 200 and base_resp.status_code 1004, never 401/403 (confirmed against both regional hosts). The stale-token branch was therefore unreachable, so expired credentials surfaced as 'usage-unavailable' with the raw upstream string, and stale policy kept showing old numbers as if the failure were transient. Classify 1004 as an expired credential. 2. minimaxEndpoint reached the SettingsUpdate schema and the web store but was never projected by RuntimeClientSettingsController.get(), so a paired client fell back to 'overseas' regardless of the host's region and rendered the wrong console link. Add it to the projection and the store contract. 3. Changing the region persisted without refreshing usage, leaving the previous host's snapshot in the status bar until the next poll. Invalidate and refetch when the endpoint, group id, or model list changes. The RPC-level tests mock the controller, so the projection had no real coverage; the new test fails against the pre-fix projection. * Localize the MiniMax credential-expiry copy Classifying 1004 as stale-token made the status bar show the raw English error verbatim: the new wording matches none of USAGE_AUTH_ERROR_PATTERNS, whereas the old upstream text ('...log in again') matched and was replaced with localized copy. That traded a localized-but-misleading message for an actionable English-only one, which is the wrong trade for the CN users this work targets. Tag the error with credentialSource so the renderer can pick the right localized string per credential kind, and add the three catalog entries. |
||
|
|
ffff6eaca2 |
fix(test): admit the adoption-replay create fixture through the structured gate (#19246)
Semantic conflict between two green PRs. #19176 added this replay test while `agentSession.*` still admitted a `runtime` client on its negotiated capability alone; #18700 then made `experimentalStructuredNativeChat` one rule for every caller. Neither branch saw the other, and main runs no post-merge test gate, so `agentSession.create` started refusing at the envelope level and the test's `ok: true` expectation broke. #18700's rule is the intended behaviour and `create` starts work, so it belongs behind the gate. The fixture is what is stale: it builds a real `OrcaRuntimeService` whose client settings are unset. Enable the setting the way #18700 already did for the sibling pre-commit fixture. The assertions about durable-identity replay are untouched and now actually run. |
||
|
|
2ccf35b135 | fix: avoid quadratic trimming during fullscreen terminal redraws (#19214) | ||
|
|
fa5ef99885 |
fix(native-chat): settle structured chat turns stranded by a restart (#19122)
* fix: settle structured chat turns after restart * fix: preserve unconfirmed turn cancellation state * test: preserve unconfirmed turn lifecycle * test: narrow unconfirmed cancellation coverage * fix: keep intentional TUI closes out of recovery * test: keep branch rename journal mock current * fix: settle dead TUI handoffs before reacquire * fix: preserve handoff stage after retry settlement --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
ba4e79c250 |
fix(runtime): apply the structured-chat setting to every RPC caller (#18700)
* fix(runtime): apply the structured-chat setting to every RPC caller
supportsStructuredAgentSessions only consulted experimentalStructuredNativeChat
when clientKind === 'mobile', so identical host settings admitted desktop and
in-process callers while refusing a phone. The server branched on client surface.
The setting is now one rule for every caller. The negotiated capability stays a
wire term asked of remote clients only, so a capability-less in-process caller is
still admitted on the setting alone.
Making the projection's structuredNativeChatEnabled argument required surfaced
eight call sites that passed `undefined` for non-mobile clients; they now read the
host setting, so tab projection follows the same single rule.
Announced behaviour change: with the flag off, session.tabs.list/listAll no longer
restore structured tabs for desktop. The desktop renderer already discards them in
that state, and startup record/lease reconciliation is unaffected.
* fix(runtime): keep structured session cleanup available
* test(runtime): enable structured chat in desktop projection fixture
* test(agent-session): settle merged fixtures against the all-clients structured policy
The merge with main left three fixtures written for the old mobile-only rule:
a duplicate getClientSettings key, a create fixture with no host settings at
all, and a projection call whose 'old client' is now the mobile fallback-title
case.
* fix(native-chat): let an admitted caller close a chat after the setting is off
Turning `experimentalStructuredNativeChat` off revoked admission for every
`agentSession.*` method, including `close`. A chat opened while the setting was
on stays mounted, so its owner was left with a live provider child and an X
button that answered `structured_agent_session_unsupported`.
Split the surface by what a method does to work in flight rather than by how it
sounds, and write that rule where the gate lives so the next method lands on the
right side: starting, extending, retaining or reading needs admission; stopping
or retiring work the caller already owns does not. Moves `close` and `cancel`
onto the cleanup gate alongside `unsubscribe` and `release`.
The tightening is unchanged - the cleanup gate still demands the negotiated wire
capability and never creates a host, so an incapable client still cannot see the
surface and no method that starts work is reachable with the setting off.
Extracts the dispatcher harness and the method-to-gate table into fixtures so
the new admission suite can share them without a max-lines disable.
* Drop a duplicate lastActivityAt key carried in from main
The main commit this branch merged (
|
||
|
|
1ae7aa8bb4 |
feat(native-chat): resume an Agent Session History row into a new structured chat (#19176)
* feat(native-chat): resume an Agent Session History row into a new structured chat A Claude or Codex row in Agent Session History gains "Resume in New Chat": it opens a new structured native-chat tab that continues that provider conversation, with the prior turns already in the journal. Until now those rows could only be resumed into a PTY terminal; the structured branch could reveal a chat Orca already owned but could not adopt one it had never held. Almost all of the machinery existed. Both lanes already resume from the record's provider handle chain, the journal already has a transcript importer, and the handle chain already models `adopted` as an origin. The gap was that a create always minted an empty chain, so the adapters started a fresh conversation. This seeds that chain. The client names only the conversation. `agentSession.create` is reachable by paired mobile clients, so the transcript path and the account home are derived by the executing host and validated against the account homes it recognises — a client-supplied path would choose which file the host imports and which credential directory the provider child launches against. Failure refuses rather than degrades. A transcript that cannot be found refuses before anything is created; one that fails or decodes empty *after* the provider has resumed fails the attach, tearing the child down and publishing no tab, because an empty journal beside a context-carrying agent claims a continuity the provider never gave. Codex can resume into any workspace since it is handed the rollout path; Claude resolves transcripts under a project key derived from the launch cwd, so it is offered only for the workspace the conversation was recorded in. * fix(native-chat): widen adopted-home discovery and keep ordinary launches untouched Three corrections from review of the first commit. The adoption's account-home candidates now include the extra Codex homes session discovery already scans. A row this host listed could otherwise refuse to resume, which reads as the feature being broken rather than as a scope. Ordinary launches call `createStructuredAgentSessionLaunchIntent` with two arguments again. Passing the resume source unconditionally appended a trailing `undefined` that four existing call-site assertions had to absorb; the churn was the caller's fault, not the tests'. The transactional adoption guard's comment claimed the self-exemption is what lets a committed create replay. It is not: replay is settled earlier by the operation ledger, and an adoption always arrives with a null expected fence, so a request naming an existing session id is refused a few lines below either way. The exemption is part of what "another record" means, and the comment now says that instead. * fix: preserve history adoption through create and retries * fix: replay committed history adoption from durable identity * fix: validate history before claiming adopted sessions * fix: extract AI vault resume domains * fix: recognize typed history resume refusals --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
d53cbed43f |
revert: hold mobile push feature for user testing (#19203)
Reverts
|
||
|
|
f1d8545024 |
feat(chat): support structured /clear and /compact commands (#19164)
* feat(chat): support structured clear and compact commands * fix(chat): authorize mobile commands and bound clear-chain projection * fix(chat): localize conversation command send errors * fix(chat): retain clear pane identity with reopened history * test: account for combined structured session RPC additions --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
bf4e270504 |
fix(native-chat): list the slash commands and skills a structured Claude session actually loaded (#19127)
* fix(native-chat): list the slash commands and skills a structured Claude session actually loaded The chat composer's `/` menu was built from a curated five-command catalog plus a host disk scan of skill roots. Neither is what the running session can do: the session reports its own `/` surface, which carries this repo's `.claude/commands`, the skills that only reach it through plugin roots, and a hide-list of commands that mean nothing outside a terminal UI. On one local session the menu offered 6 commands and 17 skills where the session reported 62 commands and 33 skills. Read that surface per session and let it drive the picker: - A per-session catalog seeded from the frame that proves the session and kept current by every later report, exposed over a new `agentSession.commands` read. - The report is the authority on WHICH skills exist; the disk scan stays the source of scope and description for the names both know about, so a skill the session never loaded is no longer offered and one it loaded from a root the scan cannot see now is. - A host that predates the read answers `method_not_found` and the composer keeps its curated catalog, so mixed versions and the PTY lane are unchanged. * test: register agentSession.commands on the three surface ratchets The structured method count, the mobile allowlist, and the cross-version call table each enumerate the agentSession surface on purpose, so an additive method has to be declared in all three rather than counted around. * fix: preserve session catalog authority and publish live updates * fix(native-chat): publish authoritative command catalogs on session updates * fix: seed Claude slash catalog before the first prompt * test: verify unclassified catalogs survive session publication * test: complete structured rename journal fixtures --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
546fd9b21f |
fix(native-chat): remember structured chat model and effort picks (#19147)
* fix(native-chat): remember structured chat model and effort picks Structured Claude and Codex sessions already read the saved launch options at create, but nothing ever wrote them back. The only writer of `nativeChatSessionOptions` was the PTY picker, and the composer swaps in the structured surface for structured panes, so a structured pick went nowhere: it was forgotten when the session ended and every new session started at the CLI default. Persist a settled pick from both the desktop and mobile structured surfaces. Model and effort are stored as a pair, because a launch resolves a stored effort only under a stored model — so an effort-only pick adopts the model it was chosen against, otherwise the remembered effort never reaches a launch at all. Two things the persist path deliberately avoids: it writes what the provider committed rather than what was requested, since Codex reconciles an effort the newly selected model cannot run; and it never writes the provider readback, which is the CLI's own default and would pin a `-m` the user never chose. * fix(native-chat): persist session option picks atomically --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
68dd3909c7 |
feat(orchestration): orchestrate native-born structured chat sessions (#18827)
* feat(orchestration): orchestrate native-born structured chat sessions Orchestration resolves every worker through a terminal handle and a pane key backed by a live PTY. A session created directly as structured has neither, so it was not refused by orchestration — it was invisible. A coordinator could not start one, address one, or receive `worker_done` from one. Add a second authority source rather than a parameter channel. A registry maps a session id to the same three facts the PTY path supplies — a bearer handle, a pane key and a host scope — and the four runtime getters consult it before giving up on `ptysById`. `orchestration.send` and `verifyDispatchCapability` are untouched: authority stays host-derived and the CLI still cannot assert who it is. PTY handles short-circuit on the handle prefix, so the terminal path is unchanged. Mail travels as a session turn instead of as bytes, on a sibling lane that keeps the PTY lane's outstanding-run, waiter, reserved-type and batch rules. Orchestration's database stays the source of truth; the send is best-effort, exactly as the byte write is, and mail is consumed only on a proven-accepted dispatch. Delivery waits for the session to be between turns, because one provider refuses a mid-turn start outright and the other cannot acknowledge one inside the ack window. Security properties, each pinned by test: the pane key's leaf is random and persisted rather than derived, since `check` is identity-gated and accepts a caller-supplied pane key; the handle is a random bearer token; the child env carries no pane key, which would otherwise flow into hook pipelines that assume a PTY leaf; hook attestation stays closed for structured handles; and process continuity comes from record lineage, never the runtime fence, which the host bumps during its own crash recovery. Also remove the "Orchestration paused" notice, which gated only on dispatch status and rendered over bridge chat where orchestration always worked; refuse the implicit-sender fallback when a worktree has more than one candidate leaf instead of guessing; and collapse the archive kinds to one named type with a compile-time assertion that the capture set cannot drift ahead of the storable set. * fix(orchestration): answer the structured idle gate from the reduced timeline The structured pointer gate read a bounded 40-item tail page. A settled turn is tombstoned rather than rewritten, so an idle worker with any real history carries no turnLifecycle item at all and the "full page, no lifecycle item" guard read it as busy forever: every nudge after the worker's first substantial turn parked on a settle edge that had already passed, and the preamble tells workers not to poll. The attention gate had the mirror bug — a prompt older than the tail window was missed and the nudge was delivered into a session blocked on a human. Both facts now come from `journal.snapshot()`, the fully reduced timeline, via a new narrow `readGateFacts` host read; the policy module stays pure and still projects through the shared helpers the chat view reads. Also: - Park `session-not-attached` on the journal edge, so mail that arrives during a transient detach is redriven by the re-attach reset instead of sitting unread. - Resolve a structured worker's provider from the durable agent-session record when the registry entry was rehydrated, so a restarted Codex worker is no longer reported and archived as Claude. - Clear `structured_pointer_operations` in every `orchestration reset` scope. - Drop the per-chat-pane dispatch-status store subscription left behind by the removed paused notice, and re-pin the two terminal-pane ratchets it moves. - Hoist the identical pointer batch selection out of both delivery lanes into `selectOrchestrationPointerBatch`. - Refuse the pre-graph-ready focus-based guess for `requireUnambiguous` callers, matching the ready path. - Move the host teardown phase list into the teardown module it belongs to, which is what keeps the host inside its max-lines budget. * fix(orchestration): discard a structured worker session whose create settled unknown `commitStructuredAgentSessionCreate` answers `agent_session_operation_unknown` when `attach` SUCCEEDED and only the tab publish failed, so `created.ok === false` is not proof that nothing exists. The worker start read it that way and skipped `discardCreatedSession`, leaving a live provider child that took no hold, has no `bindingsByDispatchId` entry and no published tab — the outer `releaseStructuredWorkerSession` no-ops without a binding, and a session that never had a holder never starts the eviction clock, so nothing in the runtime ever retires it. A throw out of the commit half is past `attach` for the same reason; the pre-commit half refuses rather than throwing. Cleanup now asks whether the create MAY have committed, via the existing `isDefinitiveAgentSessionCreateRefusal` predicate. Also: - Strengthen the pre-ready `requireUnambiguous` test so it actually pins the guard: the snapshot now carries a focused terminal, so deleting the `? [] :` ternary turns the test red instead of leaving the refusal to the ambiguous `listTerminals` fallback. - Correct the guard's justification comment, which cited `orchestration check` as covered. `check` resolves through the `--terminal` scope and still guesses; the guard covers the implicit `--from` sender, and a structured worker is covered by the `ORCA_TERMINAL_HANDLE` baked into its child. * docs(orchestration): stop two structured-worker comments claiming guarantees the code does not give The send-time owner re-check reads `target.refusal`, the snapshot the resolver already admitted, so `decideStructuredPointerDelivery` can only agree with the resolve-time answer and `owner-not-settled-native` is unreachable from that call site. What actually fences an owner that moved is `expectedRuntimeFence`, which a handoff bumps. Say that, so nobody later drops the fence trusting a re-check that is structurally a tautology. `discardCreatedSession` was credited with retiring "a published background tab that no dispatch owns". It hides the DURABLE tab reference and closes the session; the live tab snapshot keeps the row, so the background tab this start published stays on screen until the app restarts. Same for stop and release. The comment now describes what the two calls do — including that both are no-ops on a session that was never attached, which is what makes the non-definitive-refusal path safe to reach unconditionally. * fix(orchestration): retire a structured worker's chat tab when the worker settles Starting a structured worker always publishes a real `agent-session:<id>` tab, but every settlement path only called `setSessionTabVisibility(sessionId, false)` plus `host.close(sessionId)`. That clears the DURABLE restore index and leaves the LIVE snapshot untouched, so stop, release and the half-started discard all left a dead "Claude Chat" / "Codex Chat" tab in the worktree's tab bar for the rest of the app session — five dispatches, five dead tabs — and opening one re-attached the released session, respawning a provider child outside orchestration's hold accounting. The snapshot-pruning half of `closeStructuredAgentSessionTab` is extracted into `structured-agent-session-tab-retirement.ts` and exposed on the runtime as `retireStructuredAgentSessionTabFromSnapshot`, so the user-initiated tab close and the three settlements share one implementation instead of a second copy. The settlement side is best-effort BY CONSTRUCTION: it runs only after the close is already proven, calls the runtime method optionally, and swallows any throw. It talks to no renderer, so the startup release reconciler can call it too. Nothing here can turn a proven stop into `release_unknown`. * fix(orchestration): stop a structured worker's nudges, archive and liveness from lying Five defects in the structured-worker lanes, each with the same shape: a check that answered from something other than what it claimed to measure. - The pointer lane gated a WORKER's `dispatch:` mailbox on its RUN's outstanding delivery. Delivery rows exist only for a `run:` address, so that row belongs to the coordinator — and a coordinator holds one for exactly as long as it is acting on received mail, which is when it replies to its workers. The gate is gone; there is no coordinator mailbox in this lane to protect. - `dispatch-rejected` now parks on the journal edge. A rejection consumes no mail and nothing else redrives the mailbox, so an unparked pointer left the worker idle on durable mail until unrelated mail happened to arrive. - The released journal archive bounded forward — keeping the HEAD — before capping newest-first, so a long worker's archive ended at its early exploration and dropped the answer it was released for, under a warning that said the oldest messages had gone. One newest-first pass now, and the warning is true. - The durable pointer operation id was reused on a matching BODY fingerprint, and the body names only the unread count. Two unrelated same-size batches collided, the host replayed its ledger answer as `accepted` with no turn sent, and the lane marked the new mail delivered. Reuse is keyed on the batch's message ids. - `worker-read` on a structured worker hardcoded `terminal: 'running'` and emitted no `liveness`, so a runtime that could not see the session reported the worker as alive. It now carries the observed verdict, as the PTY branch does. Also: the live journal cursor is an index into a re-derived tail window, so the page's oldest item joins its source identity — a slid window now answers `source_changed` instead of silently resuming past the items it skipped. And a stop that reached no host reports `processAction: 'none'`, after installing the host the way release already does. * fix(orchestration): stop a released structured archive claiming a close that never landed `worker-read` on a released structured worker hardcoded `liveness: 'exited'`. The archive is frozen BEFORE the close, so it proves nothing about the provider child, and the read is served for `release_state` in `releasing` / `unknown` too — the two states that exist precisely to record a close that did NOT land. A coordinator that read `exited` from a `release_unknown` worker would start a replacement over the same worktree while the original child was still attached, which is the outcome docs/reference/ssh-execution-boundary.md rule 2 exists to prevent, and it contradicts the release receipt's own "the structured session close was not proven" text. The verdict now comes from the resource row the read already holds: only a settled `released` row is `exited`, everything else is `unverifiable` — which the existing mapping renders as `terminal: 'unknown'`, the same way the live branch does. * fix(orchestration): stop a structured worker-start reporting a preamble it never delivered Two ways a structured `worker-start` handed the coordinator a receipt that did not describe the worker it got. `sendStructuredWorkerPreamble` threw only on a refusal and on `rejected`, so a submission that settled `unknown` fell through as success: the start pushed `dispatch_input: accepted` and marked the dispatch ready. `unknown` is not rare — `dispatchSafely` converts ANY thrown adapter call (provider child gone, transport dropped, ack window missed) into it, and `performSend` still returns ok. The worker then has no task spec while its coordinator blocks in `check --wait --types worker_done` until timeout. This PR's own mail lane already states the rule — "`pending` is not yet an acknowledgement; only `accepted` may consume mail" — so the preamble now applies it too, and raises `operation_unknown` for the states that prove neither delivery nor failure, which is the code `failWorkerStartWithReceipt` turns into the `outcome_unknown` receipt whose nextCommands send the coordinator to look. `rejected` stays a proven failure. `--structured` also accepted `--model` / `--effort` and dropped them: structured session creation takes no launch preferences, while `launch.receipt.effective` echoes whatever was requested either way, so `--model opus` ran on the workspace default and the receipt still said `opus`. Refused now, for the same reason `--terminal` refuses them, and the spec note records that refusal along with the new-child/new-top-level one it never mentioned. Tests: the refusal guard had no coverage at all, and `structured-mailbox-pointer-host` — where the full-timeline gate read lives — had none either; reinstating the bounded tail there left the whole repo green. Both are covered now, and the vacuous "never selects an exact provider session" case is re-pointed at the absent `ORCA_PANE_KEY` that actually keeps that selector shut. * fix(orchestration): let a structured worker actually reach the Orca CLI, and stop four settlements lying A structured worker's provider child runs `orca orchestration ...` exactly like a PTY worker's agent does, but it was handed the ambient PATH. On packaged Linux the CLI installs as `orca-ide` so it never claims GNOME Orca's /usr/bin/orca (#7904), so bare `orca` execs the screen reader and the worker can never read mail, reply or send worker_done; on packaged macOS/Windows the bundled launcher is only reachable from the app's own resources dir. The PTY lane already solves this inside `buildPtyHostEnv`; that block is now its own module and both lanes call it. Also: - a worker start that fails AFTER its session exists now discards the session, so a failed start stops stranding a dead chat tab that the durable restore index republishes on every launch; - a structured worker's resource reconciles to `released` after settlement forgot its identity, instead of answering `unverifiable` for the life of the DB; - `closeAttempted` is set only once a close is issued, so a tab-visibility failure can no longer report `closed_agent_terminal` for a running child; - `forgetSession` prunes only what the settled worker parked, not every sibling whose target momentarily fails to resolve; - release settles with an explicitly empty, warned archive when the journal is unreadable AND the session is proven exited — closing the chat tab is routine, and `archive_failed` there wedged release on evidence that could never arrive; - the new migration test uses mkdtemp and cleans up, so it stops failing Windows CI and leaking. * fix(orchestration): merge the duplicated release-receipts import The release-completion module imported ./orchestration-worker-release-receipts twice, which trips import/no-duplicates in audit:code-quality:native. The changed-file gate does not load that config, so only whole-tree CI saw it. * docs(runtime): note that a background structured tab re-publish is a no-op The activate:false branch for an already-published session returns without writing the snapshot or emitting, so it cannot re-surface a client whose mirror lost the tab. Orchestration is safe from this only incidentally. * feat(orchestration): make the worker mode the user's own default, not a flag `worker-start --structured` was an explicit opt-in that REFUSED --on, --terminal, --model/--effort and worktree-creating placements. The flag, its spec entry and the `structured` RPC param are gone: the mode now follows the user's setting for new agent tabs, so a local claude/codex worker is a structured chat session whenever the user's own default says agent tabs open as one. A setting is a preference, not a demand, so none of those combinations refuses any more. A dispatch that cannot be structured starts an ordinary PTY terminal worker and the receipt names the mode that ran and why, so the fallback is never silent: - a remote --on, an existing --terminal, a new-child/new-top-level worktree and --model/--effort are decided from the request; - the agent, TUI launch customization, Codex-on-Windows and the runtime capability are decided by the shared launch route; - WSL, remoteness and the Windows start-time gate are settled by the executing host's own agentSession.createSupport, asked once the worktree resolves and before anything is created, so a refusal is a terminal worker rather than a failed start. The decision is the renderer's, lifted rather than copied: `resolveAgentLaunchRoute`'s structured half and the settings predicate now live in shared/structured-native-chat-launch-route, which both surfaces call, and the TUI launch customization test moves to shared beside it. `getClientSettings` gains the two native-chat default booleans it was missing. No security invariant moves: the structured worker registry, bearer handle, persisted pane key, the absence of ORCA_PANE_KEY from the child env, hook attestation and lineage-derived process incarnation are untouched. * fix(orchestration): stop the worker mode leaking into the agent contract The mode a worker runs in is a runtime implementation detail. An agent should be taught the same verbs, run the same commands and read the same receipts whether it is a structured chat session or a PTY terminal — otherwise a settings-driven fallback silently changes what the agent can do. The real leak was `canDispatchSubWorkers`, which was forced false for a structured worker. That was not a wording choice: `worker-start` resolved `--from` through `showTerminal`, which needs a live PTY or renderer leaf, so a `structworker_` coordinator genuinely could not dispatch. Rather than withhold the capability, the one fact the command needs from `--from` — its worktree id — now comes from `getOrchestrationDispatchAuthority`, the same authority the pane-key and process-incarnation getters already answer structured handles from. Sub-dispatch is gated on depth alone, identically for both modes. `showTerminal` itself is deliberately NOT taught structured handles: it returns a ptyId, a leaf id and a pane runtime id, and synthesising those for a session with no PTY would hand every caller of a public terminal verb something that looks writable and is not. `inspectWorkerTerminal` already returns `terminal: null` for exactly that reason. Also neutralised three agent-visible refusals that named the worker's kind: a `worker-read --source terminal` on a worker with no terminal now names the sources that do work, and both archive refusals say "transcript output" rather than "structured chat output" (the PTY `transcript_pin` branch said "structured" too). New tests pin both properties: the two preambles are byte-identical once the handle and per-dispatch ids are normalised, and a structured coordinator starts a worker with `showTerminal` rejecting. * fix(orchestration): stop claiming a structured worker was checked for a prompt worker-show reported observation.agentWait: null for every structured worker. The field's own contract says null means Orca looked and found no wait, and absent means it never looked — and nothing looks here: a structured worker parks on a journal question item, which no terminal prompt scan can see. So null was a false negative on the one field a coordinator is explicitly told to read, and it was mode-dependent: the same worker as a PTY would have reported the wait. Absent is both the honest value and a state a PTY worker already reaches (an older host, an unreadable pane, a probe that did not answer), so it discloses nothing about which mode ran. * docs(cli): stop the worker-start spec pointing a caller at the worker kind The note said "the receipt mode field names the mode used and why", which is an instruction to read a field no verb behaves differently for — the one thing the mode was not supposed to become. It now says what a caller actually needs: the dispatch always starts, the options passed are the ones honoured, and every worker is driven the same way. The receipt still carries the mode for operators and telemetry; nothing tells an agent to look at it. * perf(orchestration): coalesce the structured redrive edge Every journal batch is a redrive candidate, because a settled turn is tombstoned rather than rewritten — there is no completed row to watch for. That is free while nothing is parked on the session, but once mail IS parked each batch re-resolved the dispatch, queried unread mail and read the host's gate facts, only to re-park because the turn was still running. A turn streaming tool calls paid that per batch. The edge now coalesces on a 300ms quiet window with a 2s starvation cap, so a streaming turn costs a handful of evaluations instead of one per batch and a settled turn still nudges promptly. Delivery semantics are untouched: the gate, the accepted/rejected/unknown handling and the retain rules all still run exactly as before, just fewer times. Nor is this the path fresh mail takes to an idle worker — that is `deliverForHandle` at enqueue time, which this does not touch — so the common case gains no latency. The mechanism is the session.tabs notify coalescer, generalised into `keyed-trailing-edge-coalescer` and called by both rather than duplicated; the session.tabs windows stay where they were, since 50ms is right for a spinner title and far too tight for a journal stream. Disposal drops the pending timer rather than flushing it, on the existing subscription disposer that every settlement already reaches, so a redrive can never fire for a session no dispatch owns. * fix(orchestration): deliver direct peer mail to a structured worker, and let a peer read it Two agent-to-agent verbs had no answer for a worker that IS a structured agent session, and both failed quietly. Mail addressed to a worker's own bearer handle — how agents mail each other outside a dispatch — fell between the lanes. The send stored durably and reported success, `getLiveTerminalPaneKey` resolved the recipient, and then neither lane claimed the mailbox: the structured resolver answered only `dispatch:` addresses, and the PTY lane refuses a structured handle outright. Nothing errored and nothing logged, so the worker never reacted and the peer waiting on a reply hung. The resolver now also answers a bare worker handle, preferring that worker's active dispatch so peer and coordinator nudges share one operation-ledger budget. A worker BETWEEN dispatches is still nudged, under a session-scoped key: a dispatch says nothing about whether delivery is safe — the idle gate and the lease fence do — and its own `check` reads exactly the direct mailbox the mail is sitting in. The dispatch caller key is left byte-identical, because the ledger is keyed on (callerKey, operationId) and reshaping it would re-mint nudges already in flight as second turns. `terminal read` had no structured branch, so the only peer-accessible read verb answered `terminal_handle_stale` for a live worker; `worker-read` is closed to a peer, which holds neither coordinator standing nor a dispatch id. It now serves the session's journal, projected to LINES and paged by the same reader the PTY tail uses, so the result stays a plain RuntimeTerminalRead and nothing an agent reads discloses which kind of worker answered. Bounding and dispatch-capability redaction are the archive path's, reused rather than rebuilt. A session that is not attached refuses with the existing not-attached code rather than returning an empty tail, which would read as "this worker has said nothing". `terminal.show` still refuses a structured handle. This is read-only on purpose: synthesising a ptyId/leafId/paneRuntimeId would hand every public terminal verb something that looks writable and is not. * fix(orchestration): stop three PTY-only probes answering for structured sessions Three defects, one shape: a probe that enumerates PTYs or resolves a pane was standing in for a question that is not about panes at all. `worktree rm` destroyed a live structured worker. `killAllProcessesForWorktree` sweeps the renderer graph, the provider session list and the local pty-registry, and a structured session is registered on none of them — so all three counted zero, nothing errored, and removal deleted the checkout out from under a running provider child, which kept running with its `cwd` gone while the dispatch still reported the worker live and exact. A fourth sweep now asks what the other three cannot: membership by `location.workspaceId`, which covers a plain chat session as well as a dispatched worker, and liveness by the same `live`/`unverifiable`/`exited` observation the rest of the structured surface uses. It REFUSES a destructive removal rather than auto-closing, on the same bargain and the same `--force` escape hatch as the unstopped-PTY gate — this is the verb that deletes a user's work, and a running agent is exactly what they would want to be told about. Force closes the sessions properly instead of orphaning a child. Best-effort reconciliation callers are excluded: they repair state, delete nothing, and must never be failed closed. Twelve coordinator verbs failed for a structured worker running as itself. `isLiveTerminalHandle` validated `ORCA_TERMINAL_HANDLE` with `terminal.show`, a PTY verb whose leaf lookup misses for a session that never had a pane; the pane remint that would have recovered it needs `ORCA_PANE_KEY`, which a structured child deliberately does not carry, so every one of them died on `no_active_sender_terminal` — including the ones the worker's own dispatch preamble tells it to run. The identity question gets its own probe, `terminal.resolveIdentity`: a handle and a boolean and nothing writable. `terminal.show` still refuses a structured handle, because synthesising ptyId/leafId/paneRuntimeId would hand every public terminal verb something that looks writable and is not. The PTY half is byte-for-byte today's check, `getLiveLeafForHandle` included, so its `rendererGraphEpoch` re-check still runs — that check is the whole reason the sender is validated at all, and a cheaper probe would have quietly started passing stale post-reload handles. A host that predates the method answers `method_not_found` and the client falls back to `terminal.show`, which is correct for that host: one without the identity probe has no structured workers to miss. `dispatch --inject` reported `no_agent_detected` for a structured worker, because `isTerminalRunningAgent` reaches `getLiveLeaf`, throws, and the catch returns false. A structured session IS the agent; there is no foreground process to recognise, so it answers before the PTY probes rather than through them. Also: a Run whose coordinator is structured now gets its `run:` mail. Both lanes declined and neither logged — the PTY lane because the owner is structured, the structured lane because the mailbox was not `dispatch:` — so each half believed the other owned it. The PTY lane's reasoning (a coordinator blocks in `check --wait`, where a waiter preempts pointer delivery) does not transfer: a structured coordinator is a chat session whose turn ends. Its `run:` deliveries take the `hasOutstandingRunDelivery` gate the PTY lane applies for exactly that mailbox, and only for that mailbox. The test that would have caught the twelve drives the CLI with `ORCA_TERMINAL_HANDLE=structworker_…` and no `--from`. Every existing orchestration CLI test passes `--from` explicitly, so the resolver a real worker goes through was never exercised — which is why the suite stayed green while the preamble failed on its first line. Two files crossed their line ceiling and are split rather than waived: `worktree-teardown.ts` sheds its two PTY-surface sweeps and the deadline arithmetic they share, and `orchestration.test.ts` — which sat exactly on 800 — sheds the two caller-identity suites this change rewrote. * fix(orchestration): arm the takeover signal for structured chat input `worker-release` closed a structured session a user had taken over, losing work mid-conversation, while `orchestration-worker-specs.ts:106` promised "Never closes … user-taken-over terminals". Every guard was already correct and simply never armed. `reportWorkerTerminalUserInput` has exactly one call site — the real-user-input signal on a PTY connection — so structured chat input never reached `orchestration.workerTerminalUserInput`, `markWorkerTerminalUserOwned` never ran, ownership stayed `owned` instead of `user_owned`, `retainedReason` never returned `user_takeover`, and `stopStructuredWorker` proceeded. The durable flag is reused as-is rather than given a parallel mechanism: it exists precisely so a restart, an SSH drop or a renderer remount cannot erase a takeover. Addressed by SESSION, never by pane key. A structured worker's pane key is a random identity credential — anyone holding it can read and consume that worker's mailbox, and session ids are embedded in tab ids in plain text — so it stays in main and the runtime resolves the session to it. Handing it to a renderer to echo back would make it learnable by anyone who can see a chat pane. The RPC gains an optional `sessionId` alongside `paneKey`; a host that predates it rejects the call, and the report is already best-effort with a catch, so that host degrades to exactly today's behaviour rather than failing a send. The signal fires from the composer send hook and only past `accepted`: the outbox dispatcher retries, and orchestration's own pointer nudges never pass through the composer at all — so neither can be mistaken for a user takeover. * fix(orchestration): reach structured workers through group addresses `orca orchestration send --to @all` — and `@idle`, `@claude`, `@codex`, `@worktree:<id>` — silently skipped every structured worker. Recipients came from `listTerminals`, which enumerates leaves and PTYs, and a structured session is on neither. The exclusion happened BEFORE per-recipient resolution, so the `SendRecipientWarning` machinery never ran: the caller got exit 0 and a receipt naming the workers that did resolve, and a broadcast "stop work" or "base moved" reached the PTY workers and nobody else. With every worker structured it degraded to `terminal_not_found`, which reads as "the group was empty". Fixed at the group-resolution site rather than inside `listTerminals`. That result is published to paired mobile and remote clients and to consumers that assume a summary carries a `ptyId` or is writable, so widening it is its own change under `docs/reference/remote-wire-compatibility.md`. Group addressing reads exactly three fields off a recipient, and `RuntimeTerminalSummary` already satisfies them structurally, so the resolver widens to that smaller shape and nothing here invents a `worktreePath` or a `branch`. Candidates are liveness- gated on the same observation the rest of the structured surface uses — mail addressed to a settled worker would be stored for a lane that will never deliver it — and once a worker IS a candidate, the existing per-recipient warnings cover it, so an unresolvable one is reported rather than dropped. `@idle` needed more than enumeration: `getAgentStatusForHandle` reaches a PTY probe that throws for a handle with no pane, so a structured worker would have been enumerated and then silently dropped from the one group address that selects on status. It now answers from the session's journal — and off the FULL reduced timeline, never a bounded tail. Settlement tombstones the running turn's lifecycle item rather than rewriting it, so on any page-sized read a long tool-calling turn looks identical to an idle session; `@idle` would then broadcast into a running turn, which Codex answers with `turn already running` and Claude queues behind. An unreadable session answers null, never idle. `terminal list` and `worktree ps` still omit structured workers; that is the wire-visible half and is deliberately not in this change. * fix(orchestration): refuse rather than guess when a chat session has no identity An ordinary structured chat session — not a dispatched worker — is spawned with no `ORCA_TERMINAL_HANDLE`, because `structuredWorkerChildIdentityEnv` early- returns for any session outside the worker registry. `orca orchestration check` then fell through to `terminal.resolveActive`, which picks the focused tab's active leaf or the first leaf in the worktree. It returned a valid handle, so nothing errored — and `check` is destructive by default, so it consumed another pane's oldest unacknowledged batch and marked it read. The rightful worker never saw that mail. `requireUnambiguous` does not fix this, only narrows it: it refuses when MULTIPLE leaves could be meant, and with exactly one terminal pane in the worktree the guess still resolves — to a sibling. "One terminal pane plus one chat tab" is a normal layout, so the common case stayed broken. The pinned test is that case. So the child now carries `ORCA_STRUCTURED_SESSION`, and every remaining route that would GUESS an implicit terminal refuses on it with an error naming the flag to pass. The marker names NOTHING — no handle, no pane key, no session id, no token — which is the whole reason it is safe: it cannot be replayed, cannot impersonate, and cannot flow into the hook-attestation, agent-row or mobile-projection pipelines the way a pane key would. That makes it a different decision from withholding `ORCA_PANE_KEY`, not a reversal of it. It also grants no CLI reachability, so packaged builds keep exactly today's exposure. The comment at `orca-runtime-adopt-terminal-orphans-from-inventory.ts` that justified the guess — "a structured worker is covered instead by the `ORCA_TERMINAL_HANDLE` its child is spawned with" — was true only for dispatched workers and false for every other structured session, a population this branch creates. It now says which case it covers and which case it does not. * fix(orchestration): stop two surfaces lying about a worker with no terminal `orca terminal <verb>` answered `terminal_handle_stale` for a structured worker's handle. Nothing went stale: the session is live and simply has no terminal, and it never had one — so callers acted on a false claim and went hunting for a remint that cannot exist. The refusal now carries its own code and names the structured equivalents (`orca terminal read`, `worker-read --source transcript`, `orca orchestration send`), so an agent that lands there learns what to run rather than what failed. A PTY handle that really did go stale keeps the old error, and so does a session this runtime no longer owns — that handle IS dead. `terminal.show` stays non-resolving: synthesising a ptyId/leafId/paneRuntimeId would hand every public terminal verb something that looks writable and is not. `orchestration-worker-specs.ts` promised "the same verbs, the same handle, and the same worker-read sources", and all three clauses were false for a worker with no terminal. A spec agents read must not carry a false promise, so it now states the limitation and the alternative that always works. Note this had to be reconciled with an invariant this branch already holds: the worker MODE must stay opaque, or a coordinator starts branching on something no verb it runs behaves differently for. So the note says "not every worker has a terminal" and points at `--source auto`/`--source transcript` WITHOUT naming a kind — the same mode-neutral wording `readStructuredWorkerOutput` already uses when it refuses `--source terminal`. Both properties are now pinned by tests, so neither can be restored by breaking the other. * fix(orchestration): close the review findings on the structured parity work Four defects and two follow-ups from the delta review. The `worktree rm` refusal was a dead end in the desktop UI. Its message matched no matcher in `classifyWorktreeForceDeleteReason`, and an ordinary desktop delete already passes `force=true` for the dirty-file skip, so classification returned null unconditionally: the toast showed raw CLI wording with no Force Delete button, and a user with a live chat session was stuck unless they knew to reach for the CLI. That is the #11960 shape `shared/worktree/removal.ts` documents, so the refusal now has its own prefix, matcher, `WorktreeForceDeleteReason` and toast copy, classified BEFORE the `force` guard and nulled once the waiver is spent — exactly how `unstopped-pty` is handled, with matcher and hint kept in the same file as that contract requires. The copy says Force Delete will close a running conversation rather than borrowing the "could not confirm" wording, because Orca watched these sessions stay attached; there is no doubt to waive. Structured `terminal read` cursors were unsound and are now refused. The PTY cursor indexes an append-only completed-line buffer with a monotone count; a session journal is a BOUNDED tail re-projected on every read, so a saved index addressed different lines as the journal grew — and `truncated` could never fire to say so, because it tests `cursor < oldestCursor` and `oldestCursor` was always 0. A poller got wrong or duplicated lines under `truncated:false`. Separately, a streaming turn's lines counted as completed with `partialLine` hardcoded empty, so a mid-turn cursor consumed a half-written line whose growth was never redelivered — the `"hel"`/`"hello"` hazard the PTY reader guards against. The journal does have stable item identity, but `terminal.read`'s cursor is a number on the wire and cannot carry it, so a cursor read now refuses and names `worker-read --source transcript`, which already has that contract including `source_changed`. No cursor space is advertised either: `nextCursor` is null and the cursor fields are absent, rather than claiming an index the next read cannot honour. The header claim that all four fields kept their meanings was true of the shape and false of the invariants; it now says which ones hold. Two fixes had no test at their real seam, which is the same failure that produced this whole set — the runtime tested directly, the seam tested by neither. The group-addressing test hand-composed the recipient list itself, so deleting the composition at the call site left it green; it now drives `sendGroupMessage` with no PTY terminals at all. Nothing referenced `isLiveStructuredAgent`, so the `dispatch --inject` fix had no red-then-green at all; it now has one driving `RuntimeTerminalAgentPresence.isRunning`. Both were ablated and confirmed red. Folder-workspace removals sweep and kill PTYs without `requirePhysicalStop`, so the structured sweep no-opped there and left a live session bound to a workspace about to be forgotten. They now close best-effort under an explicit `closeStructuredSessions` flag, kept separate from `requirePhysicalStop` because the two questions differ: that one asks whether a stop must be PROVEN before files are touched, and it is what licenses a refusal. These paths do not refuse — the root is shared so no checkout vanishes under the child, and one of them is a never-throw forget a refusal would wedge. Reconciliation sweeps set neither and still close nothing. Also: the force close is raced against the same sweep deadline every PTY surface is bounded by, so a wedged provider close reports the timeout instead of hanging `worktree rm --force` forever; and the refusal now prints a count and the providers instead of raw session ids, which our own marker rationale treats as one tab-id hop from a credential. * test: pin structured-session close on the folder-workspace removal path The folder and orphan removal callers now pass closeStructuredSessions so a live structured session is closed best-effort rather than left bound to a workspace Orca has forgotten. These three exact-args characterizations describe that call and had not been updated. * fix(orchestration): stop the structured worker-read cursor misdelivering silently `worker-read --source transcript` for a structured worker fingerprinted only the oldest item's id, so `source_changed` fired when the window slid off the front and could NOT fire when the page's contents changed under a stable oldest item — which is the normal case, because the journal is a reduced, mutable timeline. A `running` tool item gains its `[tool result]` at its original sequence once later items exist, the 60ms delta coalescer revises a message in place, settlement can rewrite an item smaller, and a pending approval projects to null until it resolves and then appears in the MIDDLE of the array. Two silent failures followed, both returning ok. Omission: a caller handed a coalesced `hel`, resuming past it, never received the revision to `hello world` — the same defect we refused to ship on the terminal read path, already shipped here. Duplication: a resolved approval inserted ahead of a saved index, which was still accepted, so the caller re-read content it already had. The blast radius is the coordinator polling loop, the verb's primary consumer. The anchor is now the oldest item PLUS every item whose projected message sits below the caller's position, by id and revision. `createWorkerOutputSourceIdentity` already takes an arbitrary string array and the cursor is already opaque base64url carrying its own position, so neither the wire shape nor the `source_changed` contract changes. Prefix-scoped rather than whole-page deliberately: fingerprinting every item on the page would flip the identity every 60ms with the coalescer window during an active turn, making the cursor unusable exactly while the worker is working — that trades a silent bug for a useless verb. Tail growth the caller has not read cannot invalidate; any change to what it already holds does. Position-dependence is safe because `p` rides in the same opaque payload as the identity, and the returned cursor is stamped with the identity of its own end, which is precisely what the next read recomputes. The frozen archive keeps a constant identity: no item can be revised under a caller there, so it has no prefix to fingerprint. Both silent shapes are pinned across a page boundary with the journal mutating between reads — a static-journal test passes either way. Two ablations at the real call site: reverting to the oldest-item-only anchor turns both red, and widening the prefix to the whole page turns the tail-growth case red, which is what proves the scoping is real in both directions. * docs(orchestration): stop the structured terminal-read refusal recommending a dead end The refusal told a peer to "page it with `orca orchestration worker-read --source transcript`", which is wrong three ways and this file said so itself: its own header explains that this verb exists BECAUSE `worker-read` demands a dispatch id and coordinator standing "a peer does not have" — and then the refusal sent that same peer there. The verb it named is also a window index over the same bounded page, so it is not a paging answer even for a caller who can reach it; under load it now answers `source_changed` on most polls, which is better than the silent hole it had before but still not what the sentence promised. The refusal now says what actually works — the tail is bounded and newest-last, so poll it and diff — and names no alternative, because there is none. That is the honest framing: a durable cursor is not achievable here at all, rather than blocked on the wire shape. The journal is a reduced, MUTABLE timeline: an item's projected text changes at its original sequence after later items exist, the delta coalescer revises repeatedly, settlement can rewrite an item smaller, a pending approval renders as nothing and then as something, and `sequence` resets on epoch rollover. No index, numeric or opaque, survives that. So the docstring's "pagination with a real anchor lives on `worker-read --source transcript`" is gone too — there is no real anchor there — and the file now records why no windowed alternative should be built later: a broken cursor fails UNSAFE, as a silent hole in a poller's output, while diffing a bounded tail fails safe as a harmless re-read, and a second paging-shaped verb would invite the PTY assumptions this one cannot honour. The test asserted the old advice, so it now pins the contract instead: the refusal explains the working approach and must never name `worker-read`. `worker-read --source transcript` remains a good bounded snapshot for a coordinator reading a worker it dispatched; only the "or page it with" clause was false. * fix(i18n): add the missing worktree-removal agent-session refusal string The structured-session removal refusal introduced a translate() key with no en.json entry. Nothing local catches that: typecheck passes, and the full suite passes, because a missing key falls back to its inline default at runtime. Only verify:localization-catalog fails on it, which is why CI's static analysis reddened on a branch that was green everywhere else. Fallback wording mirrors the sibling unstoppedPtyLive string, since the two refusals differ only in what is still running and what Force Delete does to it. * test(codex): expect the no-identity marker on an unregistered structured child The refuse-rather-than-guess marker landed after these expectations were written, and all three assert exact env equality on the unregistered path — the one branch that now carries ORCA_STRUCTURED_SESSION. One of the two files was added by this same branch, so this is a self-inflicted drift; the other predates the branch and was broken by it. The marker's presence is still pinned positively by structured-worker-child-identity-env.test.ts and the CLI's orchestration-structured-session-no-identity.test.ts, so relaxing these three exact-equality checks loses no coverage of the security property. * fix(orchestration): require exit evidence before settling structured close --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
3160b54c69 |
feat: real background push notifications for the mobile app (#8129) (#18554)
* feat(cloud): add the mobile push gateway and its contract package (#8129) A small open-source service that holds the APNs key and FCM credentials and sends background push to paired phones on the desktop's behalf. Hosts authenticate with a box challenge and HMAC proof on their pairing key, the same shape the relay uses, so signed-in and accountless desktops share one path. Tokens are stored; alert text is held only for the coalescing window. The contract doc in docs/reference is the source of truth for every wire shape. The interop test runs the real desktop answerer against a real gateway-issued challenge so transcript drift fails in CI. * feat(push): register phones and send background push from the desktop (#8129) Adds the notifications.remote-push.v1 capability, the registerPush and unregisterPush RPCs on the mobile allowlist, a gateway client with a cached session and 401 re-auth, a durable unregister outbox, and a dispatcher that offers every mobile notification to the gateway after the socket fan-out. The dispatcher is fire-and-forget with one retry and drops registrations the gateway reports dead. Puts agentState on the mobile frame and fixes the #4375 wording so a working agent is never announced as finished. The relay host-proof code moves onto a shared envelope module with no behaviour change. * feat(mobile): background push registration, receive, and settings (#8129) Fetches the native APNs or FCM token, registers it with every paired host that advertises the capability, and re-registers on token change. Foreground pushes are suppressed inside handleNotification against the same seen set the socket path uses, so nothing shows twice. Taps route by host fingerprint. One Background notifications switch, off by default, with the disclaimer and needs-input / finished sub-switches; hidden until a paired desktop is new enough. Adds google-services.json and the expo-notifications plugin. * chore(cloud): Terraform and deploy workflow for the push gateway (#8129) Declares the Cloud Run service, runtime account, secrets, and orca_push database behind push_gateway_enabled, true only in production. The deploy workflow is gated like the relay's, deploys with no traffic, probes /ready and a validate-only FCM send, then shifts traffic. It runs as the shared production deploy account because the Cloud SQL rollout lease grant is foundation-owned; its extra authority is three bindings on the push service. docs/push-gateway.md carries the import commands for the resources created by hand and the APNs key rotation procedure. * docs: describe background notifications on the phone (#8129) * docs: check in the mobile push contract (#8129) Seven committed files cite it as the source of truth for every wire shape; docs/reference is allowlisted per file, so add the entry. * test(push): replay one checked-in host-proof vector on both sides (#8129) Cloud Verify installs only the cloud workspace, so the gateway suite cannot import the desktop answerer. Replace the cross-workspace import with a fixed challenge vector generated from the contract package; the gateway fixture and the desktop answerer each replay it and must produce the same HMAC. A transcript drift on either side now fails in that side's own suite. * fix(cloud): open the push gateway with invoker_iam_disabled, not an allUsers binding (#8129) The production domain-restricted-sharing policy rejects an allUsers run.invoker member, which the runbook anticipated. Opt the service out of invoker IAM the way the relay director already does; the host proof is the authentication either way. * docs(cloud): the push.onorca.dev record exists and is hand-managed (#8129) * fix(push): close review findings in the gateway (#8129) - Quota reservation takes a per-host advisory lock; READ COMMITTED admitted a whole burst past the cap (80/80 without, 60/80 with, against Postgres 16). - Challenge issuance no longer writes push_hosts; the row lands on proof verification. Stale hosts prune after 30 days. Per-IP token bucket on the two unauthenticated routes. - Streaming body limit via hono bodyLimit; a chunked body bypassed the Content-Length check. - registrationIds deduped in the schema; per-host device cap of 64; list bounded to its schema. - Gateway-side challenge TTL is the specified 10 s, not 40 s. - APNs stream settles on close as well as end/error. * fix(push): close review findings in the desktop client (#8129) - A gateway registration the registry cannot persist is enqueued for delete instead of leaking a live token. - Unregister outbox re-reads pending per pass, honours enqueues during a drain, and retries with backoff instead of waiting for the next launch. - Dispatcher batches registrations by 20 rather than starving the rest. - 401 compare-and-clear; a 401 after re-auth is unreachable; refused handshakes and 429s are cached briefly instead of re-handshaking per event. - Service is stopped on quit. * fix(mobile): close review findings in push registration and receive (#8129) - Consent generation guards a register that finishes after the switch went off; the host is re-queued for unregister instead of recorded live. - Foreground pushes seed the watermark before adopting the epoch, so a push on a never-connected session cannot wipe a valid watermark. - aps-environment follows the build via app.config.js; the iOS release workflow sets it to production. A bare plugin entry wrote development. - Pushes the OS showed while closed are marked seen before catch-up replay. - Token null result is not cached; failed capability probes are retried and never block an unregister; coalesced summaries are shown but not marked. - Unresolvable fingerprint routes nowhere and is suppressed in foreground. - Android channel ensured at boot; capability hook diffs clients by identity. * fix(cloud): harden the push deploy workflow and size the gateway to the budget (#8129) - Roll traffic back on a failed post-shift check; delete a candidate that never took traffic; retry the origin probe and the FCM probe. - Assert Terraform-owned scaling instead of mutating it from the workflow. - Build before taking the Cloud SQL rollout lease. - Declare the database pool in Terraform (2 per instance, max 2 instances) and add the gateway to the connection budget; the previous default put the shared instance 65 connections over its ceiling. - State plainly that the shared deploy identity's relay authority is inherited. * fix(push): read the runtime from shared state at push startup (#8129) Threading the runtime through launchDesktopMode put the launch module one line over the 300-line lint budget after the rebase. * fix(push): key the unauthenticated rate limit on the hop Cloud Run wrote (#8129) Cloud Run appends the connecting peer to x-forwarded-for; the limiter read the left-most value, which the caller controls, so a forged first hop earned a fresh bucket per request. * fix(push): close the final security review findings in the gateway and infra (#8129) - app.onError logs only the error name and answers a bare 500; hono's default handler printed the whole error, and a pg error carries the row in detail - a second per-IP bucket (240/min) runs ahead of the bearer lookup on every authenticated route, so forged bearers cannot spend the two-connection pool - one live session per host: minting deletes the host's earlier row - device-less hosts are pruned after 1 h, not 30 d; any keypair mints one free - notificationId is printable ASCII, since it becomes the APNs collapse header - the impersonated FCM probe token is masked in the workflow log - prevent_destroy on the Apple secrets and the orca_push database * fix(push): close the final security review findings in the desktop client (#8129) - fetch never follows a redirect: a 307 would replay the host proof and the phone's token to whatever origin the redirect named - registerPush params are strict and the paired identity is spread last - a per-device bucket (10/min) bounds a phone looping registerPush, which costs a gateway write and a synchronous registry write each time * fix(mobile): close the final security review findings in push receive (#8129) - a push with no epoch can no longer claim a seq-derived dedup key, in the foreground or from the tray; a forged seq:N could otherwise swallow the real bell at that seq - a provider-delivered push with no host catalog, or no fingerprint at all, stays unrouted instead of falling back to the hostId its raw data carries * docs(push): record the ip buckets, session and host retention, and the token-ownership limit (#8129) * fix(push): apply the schema on an untimed pool and retry statement-timeout aborts (#8129) Ports the relay's #18722 pattern to the gateway: DDL runs on a one-connection pool with statement_timeout 0 that is closed before the serving pool opens, and SQLSTATE 57014 joins the bounded transaction retry path. * fix: harden mobile push delivery and deployment recovery * feat: align mobile notification preferences with desktop delivery * fix: accept variable-length APNs device tokens * fix: deduplicate native APNs and background socket notifications |
||
|
|
e48d83a5e1 |
Fix MiniMax China usage routing and credential handling (#14929)
* feat(minimax): endpoint selector, API key auth, weekly usage window (#14264) The MiniMax (MiniMax) Coding Plan usage fetch was hardcoded to the overseas platform (platform.minimax.io) and a single 5h session window, so users on the CN endpoint (www.minimaxi.com) got nothing. Three changes: - Add `minimaxEndpoint` (`overseas`|`cn`) and `minimaxApiKeyConfigured` settings fields with sensible defaults that preserve current behavior. The CN endpoint also accepts an API key (safeStorage-encrypted via a new `minimax-api-key-store.ts` + IPC pair) for users without a browser session cookie. Status-bar visibility now OR's both credential flags. - Cookie-jar origin now tracks the active endpoint. Previously cookies were stored under the overseas origin and silently dropped when the user picked CN — fixed by threading `endpointMode` through the request context, the manual cookie header path, and the cookie-jar clear. - Parse the weekly window in addition to the 5h session and surface both as per-window chips (`5h [bar] 10% wk [bar] 20%`). The status bar's compact section prefers the session window; the popover keeps the existing `Session` / `Weekly` labels. The MiniMax fetcher is split into three files (data / parse / main) to stay under the 300-line cap. i18n is scoped to the Settings-page text (en + zh only); the 5H/7D duration shorthands stay English across locales by project convention. Tests: 9 new/updated files; cookies + API key exercised end-to-end via the rate-limit service with the upstream-refactored test files (`service-minimax-usage.test.ts`, `web-preload-api-settings.test.ts`, `web-preload-api-agent-providers.test.ts`, `service-test-harness.ts`, and the runtime-home / reset-credit fixtures). Refs #14264 * Keep merge formatting scoped to MiniMax * Keep MiniMax credential status in rate-limit test fixtures * Use the China console origin for MiniMax request referer --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
e7563c63f1 |
fix: fence browser recovery to attach inventory placements (#18910)
* fix: fence browser recovery to attach inventory placements * refactor: name the attach-inventory fence and make its test deterministic Extract the placement check into isPlacedAsObservedAtAttach so the recovery filter stays a flat list of named predicates, and document that omitting pagePlacementsAtAttach recovers against unfenced live state. Replace the 30-microtask drain in the post-attach regression with the handler's own completion: attach only settles after recovery returns, so awaiting the dispatch orders the assertions instead of guessing at a microtask count. Verified by forcing the fence open: both regressions fail (the post-attach one in 60ms on a retired placement) and the other 27 still pass. * test: settle the attach handler even when the regression fails early The barrier ran inline, so a waitFor timeout or the placement guard left the attach handler parked on a promise nothing awaited. Hoist it into settleAttach and call it from a finally as well; cleanup is guarded and the dispatch promise is already settled, so the second call is a no-op. |
||
|
|
f88cbb4fc9 |
fix: keep paired tab updates live after runtime terminal fallback (#19022)
* fix: preserve session publication during runtime terminal fallback * refactor(runtime): align fallback epoch comment and test preamble Match the file's `// Why:` comment convention on the inherited publication epoch, and drop a redundant duplicate mocks import in the lineage regression test while keeping the required side-effect order. No behavior change. |
||
|
|
b497f15b53 |
fix: preserve renderer browser publication during client-hosted page updates (#18961)
* fix: preserve renderer browser publication during client-hosted page updates * refactor: drop the now-dead publicationEpoch selection argument applyBrowserSessionTabSelection took a publicationEpoch and wrote it over the epoch the spread snapshot already carried. Its only production caller now passes snapshot.publicationEpoch, so the parameter is a no-op whose only remaining power is to reintroduce the epoch rotation this PR fixes. Remove it, and collapse the repeated prototype-cast boilerplate in the new reconciliation test into one helper. No behavior change. * fix: keep reconcile from publishing a browser row twice The retention filter partitioned existing rows by placement kind, so its disjointness from the live build relied on a non-local invariant: that the page registry only ever stores client placements and that server tabs are empty while no offscreen backend exists. Drop ids the live build already published instead, so a duplicate row is impossible by construction rather than by coincidence. * fix: stop the browser reconcile republishing on a pure reordering headlessBrowserTabsUnchanged compares by array index, so rebuilding the live list renderer-first read an interleaved snapshot as changed and republished with a bumped version and rebuilt tab groups for no semantic change - the same churn this branch exists to remove. Key the live set by id and emit it in the order the snapshot already had. Keying also makes uniqueness unconditional rather than resting on the page registry only ever storing client placements. |
||
|
|
4ba8ddce48 | fix: prefer retained provider snapshots during hidden terminal recovery (#18972) | ||
|
|
c49345d358 |
Fix native chat completion sorting and restored activity timestamps (#19144)
* Fix structured native chat completion sorting and timestamps * Preserve native chat activity across settled updates and host upgrades --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
b7b6ea3942 |
fix(native-chat): auto-rename the workspace on a structured chat's first turn (#19138)
* fix(native-chat): auto-rename the workspace on a structured chat's first turn Structured native chat (Claude and Codex) never reached the first-work workspace rename. The orchestrator has a single production caller, the agent-hook server listener, and structured sessions never set ORCA_PANE_KEY, so no hook event could ever be attributed to one. The renderer knew this and suppressed pendingFirstAgentMessageRename for structured launches at three sites, which also closed the gate the folder-workspace title rename depends on. The host's status feed already computes the exact edge: status 'working' with a latestPrompt normalized the same way the hook payload is, and a workspaceId that IS the worktree id. Publish that projection to the host, thread it out to the runtime, and hand it to the same orchestrator the hook path uses. Re-projections of state the host already knew (restore, an arriving subscriber) are flagged as replays and map to the orchestrator's existing isReplay gate, so a host restart cannot rename off a stale journal. One host and one journal serve both providers, so this covers Claude and Codex together. Verified in a live Electron instance, worktrees created through the real composer and prompts sent through the real chat composer: Codex langouste -> retry-helper-exponential-backoff Claude prowfish -> parse-csv-headers * fix(native-chat): preserve first-work rename across runtime and queued turns * fix(native-chat): skip branch rename for folder projects --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
ad4dc353f3 |
fix(native-chat): settle a structured send the provider proves it received after the ack window (#19140)
* fix(native-chat): settle a structured send the provider proves it received after the ack window A send waits a bounded window for the provider to echo the message it was given. On timeout the dispatch resolves `unknown`. The echo that arrives later IS matched — `recoverLateIdentity` uses it to repair the session's turn identity — but nothing tells the journal, and `unknown` is terminal there. The submission stays unknown for the life of the session. Two consequences, both reachable on any ordinary session: - The composer renders "Message delivery is unconfirmed." with a Retry, forever, for a message that was delivered and answered. - Retry redispatches, because the host only replays a recorded outcome unless `retryUnknown` is set, which that button is the only thing that sets. So the banner is a duplicate delivery armed and waiting for a click — and a user who believes the banner and resends is doing exactly that by hand. Every send made while a turn is already running takes this path: the provider does not echo a queued message until the running turn ends, which is far past the 10s ack window. Sends made while idle are unaffected, which is why this reads as intermittent. Carry the `clientMessageId` on the dispatch waiter and settle the journal submission `accepted` when the late echo proves delivery. Deliberately unfenced against the dispatch sequence: that fence decides which turn owns the identity, while delivery is settled either way. Already-terminal rows are untouched. * fix(native-chat): persist late dispatch receipts before session close --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
59fe8266bd |
fix(orchestration): keep worker lineage across app restart (STA-6366) (#19121)
* fix(orchestration): keep worker lineage across app restart (STA-6366) Terminal handles are minted per process, so after a restart the projected parent (coordinator or creator) named a handle no live row carried and every worker rendered as a top-level row. The projection now resolves the parent from the durable pane keys (runs.coordinator_pane_key, tasks.created_by_pane_key) whenever the stored handle is not one this process minted, re-resolves it to the live handle for that pane, and omits stale handles so they cannot mismatch a row. The creator-pane incarnation gate is untouched: it still decides mutation authority, and display lineage no longer depends on it. Dispatch lookup also passes the pane identity so a worker's own dispatch resolves once its handle is reminted. * test(orchestration): compare lineage without the merged attention field |
||
|
|
d07c47593d |
feat(mobile): structured native Claude chat (#18741)
* feat(mobile): structured native Claude chat Mobile already spoke the structured agent-session protocol for Codex, and the host already had a Claude capability gate — mobile just never advertised it, so `projectAgentSessionTabsOut` stripped every Claude tab before it left the desktop. The structured lane in mobile/ turned out to be agent-agnostic already (shared reducer, message projection, option catalog, prompt tokens), so this opens the gate rather than building a second lane: - advertise `agent-session.structured.claude.v1` - resolve any structured provider in `resolveMobileNativeChat` via the shared `isAgentSessionHandleProvider`, instead of a `'codex'` literal - widen the `agent-session` route type off `'codex'` - route bare Claude launches through `agentSession.createSupport` like Codex, which still degrades to a terminal when the host refuses (remote, WSL, win32, managed-account mismatch, or structured chat switched off) Deduplicate the create envelope. Renderer and mobile each assembled the `agentSession.create` params by hand; the fingerprint has to be computed over the same fields the host recomputes, so both now build it in one shared `structuredAgentSessionCreateParams`. Mobile's Codex-only launcher becomes `createMobileStructuredAgentSession(client, worktreeId, agent)` and reuses the shared display-name map; two copies of a random-UUID fallback collapse into one. Answer grouped Claude questions. A Claude AskUserQuestion carrying more than one question — or one multi-select question — is emitted with the real content in `body.questions` and the flat `options` left EMPTY, so mobile rendered a card with nothing to tap and the turn stalled with no way out. Codex never emits this shape. The phone has room for one question at a time, so the group is answered as steps and submitted once, reusing the shared `encodeAgentSessionQuestionAnswers` / `isValidAgentSessionQuestionAnswers` rather than a second encoding. Prompt responses move into `useMobileStructuredPromptResponses` because grouped questions carry a multi-step draft the rest of the session does not touch, and the session hook was at the 300-line cap. Pin the mobile capability list against the host's parser bounds: it fails closed to NO capabilities when the array exceeds 64 entries, which would look exactly like an old client. Re-pin mobile-session-route-parity: the create-actions edit drops one runtime string literal and changes one nested function body. Ablated to confirm that file is the sole cause. * fix(mobile): derive the grouped-question draft instead of clearing it in an effect The React Doctor gate flagged the session-change reset as a state adjustment after a prop change, which renders the stale draft for a frame. Store the session the answers were collected in alongside them and check it on read, so a session switch drops the draft during render with no effect at all. * test(mobile): pin that grouped steps key apart when the questions read identically Claude can ask the same text twice in one group (once per file, say). The view keys the question card by its projected content, so identical wording must still key apart or step 1's checkboxes would be submitted as step 2's answer. * fix(mobile): harden grouped Claude question answers * fix(mobile): retry transient structured support probes * fix(mobile): preserve grouped prompt response compatibility * fix(mobile): preserve tokenless duplicate choice identity * fix(mobile): point the launch tests at the generalized create API The rebase onto #18697 brought its definitive-refusal tests in cleanly, but they call the pre-rename createMobileStructuredCodexSession, and mobile tsc excludes test files so nothing caught it. Retarget them and give the agent-copy test a code that is actually in the definitive allowlist - agent_session_refused now correctly stays unknown, so it never reached the failure copy it asserted. * test(mobile): re-pin route parity after the rebase onto main Main moved its own runtime-string pin to 547; this branch drops the 'codex' literal from the create-actions gate. Ablated against main's pins to confirm that file is the sole cause before re-deriving. --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
06a607a1d7 |
feat(orchestration): make multi-agent workflows durable (#16904)
<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->
| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 225 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$21666 | $\color{#cf222e}{\Huge{\mathbf{−}}}$2820 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$18846 |
| Prod | 348 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$17107 | $\color{#cf222e}{\Huge{\mathbf{−}}}$4706 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$12401 |
<!-- /orca-pr-loc -->
## ELI5
Orca now treats orchestration like a durable control plane instead of inferring success from terminal keystrokes. Agents can tell whether a prompt was accepted or a turn started, replay an ambiguous request without sending twice, and recover coordinator mail after a crash. Completed workers can be inspected, released, or retained, and their panes no longer auto-resume as if the work were still running.
## What changed
- **Run receipts** from `run-create/use/current/show/list` are the row without routing plumbing (`home_database`, `coordinator_pane_key`) and without the duplicate `binding` object.
- **`terminal send` receipts are honest and idempotent.** `input_accepted` and `turn_started` are the only stages; `--wait-submit` observes without resending; `--retry-request <uuid>` replays the exact request against the same process incarnation. A transport timeout keeps the retry ID; only a different runtime answering strips it. Value-less or non-UUID `--retry-request` is rejected on the CLI and the SSH shim.
- **Mailbox delivery is committed before wakeup.** Pointer writes are staged in the DB before any PTY byte, replayed once after restart, and never emit a naked Enter. The watermark that parks concurrent deliveries is released with the DB reservation. Restart rescans pointer-pending and `dispatch:` mailboxes.
- **Lifecycle is a guarded transition graph** (`lifecycle-transition.ts`) with a table-driven test over every caller edge. Task reopen/overturn stays in the public contract. A PTY exit during `worker-stop` is the stop succeeding, not a failure.
- **Worker lifecycle CLI:** `worker-start` (`--spec` creates Task + attempt in one call), `worker-show`, `worker-read` (provider transcript first, bounded terminal fallback with a typed reason, local/WSL/SSH), `worker-stop`, `worker-abandon`, `worker-release`, `worker-retain`, `worker-list` (rowid-fenced pagination, fleet liveness, `attention`, literal `nextAction`).
- **Release is an explicit ownership table** (`decideWorkerTerminalRelease`): only an `owned` resource can be settled, the archive is mandatory where reachable, and an owner whose process is proven exited can always get out of `retained` via `archive_status: unavailable`. User-taken-over, external, and transferred panes stay retained.
- **Settled-worker resume fence** (folds in #17651): a settled dispatch whose pane is still open is fenced at settlement, on stop/abandon/exit, and at startup; lifted on release, retain, takeover, and pane reuse.
- **Liveness is `live` / `unverifiable` / `exited` only**, from execution-host evidence. Fleet projection reads the evidence clock, not the relay delivery clock. A host-certified exit outranks the worker's settled state. `unverifiable` never authorizes stop, abandon, retry, or release, in code or in the guide.
- **Federation:** structured reads negotiate by `method_not_found` so every shipped host keeps transcript-first output; exited remote workers are closed before being reported closed; epoch fencing holds across peer restart, downgrade, and pairing rotation; no per-second forced capability probe.
- **Schema v35:** repairs databases stamped v34 by the pre-fix branch (mailbox_handle default, index predicates), drops the write-only `lifecycle_transition_receipts` ledger and five never-read v31 identity columns.
- **Schema v36:** `dispatch:<id>` mailboxes get a real consumer generation on `dispatch_contexts` and `remote_dispatch_attachments`, bumped and fenced in the same transaction on every re-attach (manual inject, worker-start, federated attach). A stale worker whose Dispatch moved to another process now gets `consumer_fenced` instead of silently acking the new worker's Delivery. Run mailboxes already worked this way.
- **Schema v37:** `dispatch_contexts` records its creator (`creator_handle`, `creator_pane_key`), so a coordinator's context-only self-dispatch is bookkeeping rather than a nesting parent; before this, one self-dispatch made every later `worker-start` from that coordinator fail the depth cap. Pre-v37 rows keep counting (fails closed).
- **Dispatch-mailbox ownership is checked, not inferred.** A `check` from a process whose pane no longer holds the Dispatch, or whose last Attempt was abandoned/failed and moved to another terminal, gets `consumer_fenced` instead of an empty inbox that reads as "no mail yet". `--peek`/`--all` stay readable. A paneless caller still gets `stable_pane_required` with the rebind recovery.
- **Liveness certification is stricter:** a `process_exited` stage whose termination reason is `unknown` (a stop that was issued but never observed) projects `unverifiable`, not `exited`. Federated `worker-show` carries the execution host's verdict and host kind instead of a local guess. A live, ready worker with nothing pending has `nextAction: none` rather than pointing at the `worker-show` that produced it.
- **Wire:** `workerShow` keeps `dispatch.task_id` next to `taskId` for shipped CLIs. `ask --json` uses the standard `{ok, result}` envelope like every sibling verb.
- **Migration start-version detection** treats the two v32 recovery columns as versioned. Before this, every shipped database stamped below 32 resolved to the v6 floor and replayed the whole chain (the v23 backfill synthesized 68 phantom retained workers on a real v30 profile). Verified on a copy of a real 62 MB v30 profile: starts at 30, no row delta, integrity ok, 11 ms.
- **Skill guide** rewritten as a ≤200-line kernel plus seven references, to the outcome-first standard (Result / Done / Safe failure first, conditions not case lists, one done bar, references loaded at the point of use). The canonical loop uses `worker-start --spec`, names `worker-list` for completion accounting, documents `--retry-request` / `request-show` / `--wait-submit`, and requires positive evidence before any stall action. The other seven guides get the same treatment in #18724, split out so this PR stays orchestration-only.
- **`rpc/methods/orchestration-*`** (126 flat files) regrouped into `orchestration/{worker,federation,messaging,runs,gates}/`.
## Why
User reports showed the same boundary failures: false `agent_prompt_stalled` causing duplicate sends (#15180), coordinators unable to trust screen scrapes, cold-parked terminals receiving a pointer without the submit, settled workers accumulating as live tabs and auto-resuming after restart, and no way to tell a stalled worker from a working one.
## Linked issues
Fixes #15180. Fixes #17935 (orchestration skill description is 866 characters; a guard now caps every bundled skill at 1,024). Supersedes #17651 (fence folded in). Advances #16660, #16522, #14907, #13047.
## Review record
This PR was reviewed adversarially after revival: eight independent lenses (lifecycle, mailbox, send, worker, federation, transcript, complexity, live ergonomics), each required to prove findings with a failing test. That produced 16 proven blockers, all fixed with red-then-green regression tests, followed by two re-review rounds and a third fix wave that caught 3 regressions introduced by the fixes and 7 fixes that missed their target; all closed. A final pass (five lenses incl. a live built-runtime smoke, then a re-review of the fix wave) found and fixed seven more, chiefly the stale-worker mailbox steal, the self-dispatch depth wedge, and the unproven-exit certification. Three independent Codex (gpt-6-astra) passes followed: the first found nothing new, the second found and fixed 3 defects (task-status reachability, WSL-local host classification, peer-capability epoch), the third found and fixed 6 (production PTY controller never installed settled writes, ambiguous in-flight pointer failures allowed duplicate replay, SSH/relay deadlines cut off a valid `--wait-submit`, stop-vs-exit race during inspection, and two release-recovery paths for vanished or exited terminals). The full record (findings, proof tests, triage, declines with reasons) is archived outside the repo.
**Rework after the live smoke.** A first live cross-host run on the shipped adhoc build (this Mac, a paired Windows host on the same build, a paired Mac on 1.4.195, and an SSH host) found a P1: a running local worker read `unverifiable`/`missing_status` because the fleet snapshot rows lacked the terminal handle the matcher keyed on. A 59-row failure table over every bug fixed during review showed the same two classes recurring: a fact dropped in transit through optional fields, and two authorities for one fact. Two blind designs (Opus, Codex) converged on the same mechanisms, and the scoped tranches landed here with red-then-green seam tests from the real producer to the real consumer, faults injected only at the transport or hook-ingest boundary:
- **Settlement (data-loss class):** one three-valued `WriteSettlement` (`accepted | refused{reason} | unverifiable{reason, bytesHandedToTransport}`) from the SSH multiplexer through daemon client, providers, controller, to pointer staging. No boolean, no rejection-as-third-state. The two silent degrades that fabricated a handoff are deleted; a provider that cannot settle refuses before any effect. Pointer text and Enter share the contract; a partial flush is `unverifiable`, never `refused`.
- **Evidence identity (false-liveness class):** fleet agent-status evidence is a tagged union (`binding: worker | pane | unresolved{reason}`, `clock: observed | delivery`) minted once at ingest, so a hook row captured on one process incarnation can never bind to a later dispatch on the same pane. The matcher's `!worker.paneKey ||` defaults are gone. One host-scope parser replaces two.
- **Small pre-merge items:** `capability_unsupported` from an old peer is no longer relabelled `host_unavailable`; a producer census test asserts every agent-status consumer path projects a pane-only hook row as `live`.
Two ergonomics defects the second live run surfaced on a real database are fixed here too: a pre-v3 dispatch already marked `completed` projected as `outcome_unknown` / `requiresAction: true` forever (three copies of the outcome ladder disagreed on legacy rows; now one resolver, legacy `completed` reads `succeeded` with nothing to act on, legacy `failed` stays actionable on the failure), and an unscoped `worker-list` enumerated the entire database (now defaults to the Run bound to the calling terminal, `--run` overrides, and the receipt's additive `scope` field says which).
A third live round on the shipped adhoc build of `b082443e1f` (same four hosts) plus an unscripted run in the user's own prompt style (a plain Claude Code shell, `/orchestration`, three workers, zero errors, bound-Run default confirmed) found two more branch defects, fixed with red-then-green tests: a worker freshly started on a paired server projected `unverifiable`/`host_indeterminate` with `requiresAction` for ~3 minutes, including after its own `worker_done`, because the host's federation observation returned `missing_liveness_verdict` for any PTY the liveness register had not yet swept (the host now reads a connected pane it owns locally as `live`; disconnected or SSH-scoped panes stay `unverifiable`); and six pre-v3 completed rows still carried an `input` category because settling through the task-status path or `failDispatch` never closed the Dispatch's pending question threads (both paths close them now, and schema v38 closes threads already pending on settled rows). The guide's `worker-start` examples now show `--model sonnet`, since an omitted model inherits the launcher's default.
A Codex adversarial pass on the tranche diff found one real design hole (identity minted at read time instead of ingest, now closed) and two daemon settlement paths that threw instead of settling (fixed). Two `@ts-nocheck` runtime mixins on these paths were extracted into checked modules; the repo-wide `@ts-nocheck` count is unchanged at 171.
Deletions during review: ~1,900 lines (write-only ledger, unread columns, dead v1 archive path, test harnesses shipped in prod, duplicated liveness and state-machine copies, self-capability checks that were compile-time true).
## Testing
- `pnpm typecheck:tsc:node|cli|web` clean
- `pnpm run check:code-quality:changed` 0 findings; `check:react-doctor:changed` 0
- `pnpm verify:bundled-skill-guides`, `verify:skill-bundle-manifest`
- full `pnpm test` on the integrated head: 72,332 pass / 292 skipped; the only failures were three non-PR files (two zsh live-shell suites hit a node-pty spawn-helper ENOENT while a concurrent native rebuild ran, 44/44 in isolation; `release-checkout.unit.test.ts` is a known 30 s load timeout that passes in isolation on `origin/main` too).
- CI on
|
||
|
|
39cbc68f16 |
fix(native-chat): honor structured routing with saved options (#19040)
* fix(native-chat): keep saved options on structured route
* fix(native-chat): seed structured session options
* fix(native-chat): preserve create wire compatibility
* fix(native-chat): keep create replayable across an option change
Seeding host-resolved options into the attach fingerprint put a mutable
value into the durable operation identity. A create whose outcome was
unknown, retried under the same operation id after the user reselected a
model, re-resolved different options and hashed to a different
fingerprint — so the ledger refused it as a conflict instead of
replaying. That refusal is not definitive, so no legacy fallback fires
and the launch has no recovery.
Options are the session's initial state, not its identity, and the
reservation still carries them to the record. Excluding them also makes
the digest byte-identical to the pre-change one in every case, not just
when no options resolve.
* refactor(native-chat): name the structured launch option seed
The create-intent resolver narrowed saved options to model/effort with an
inline key literal, inside a file carrying @ts-nocheck — so neither the
key list nor the string narrowing had a typechecked or testable home, and
the repo already expresses this concept as a named shared shape.
Move it to resolveStructuredLaunchSeedOptions beside the persisted
settings it reads, where it is typechecked and unit-tested, and document
why the seed is exactly model and effort: they are the only ids the
picker persists that both providers also accept as strings.
No behavior change. Adds coverage for a non-string persisted effort,
which settings.json can hold and the durable record must not carry.
* test(native-chat): name the structured routing pin for what it asserts
The case drives a saved Codex model and effort through the launch path,
but the structured create intent it asserts on carries no options, so it
pins the route and not the preservation its name claimed. Preservation is
pinned host-side, where the seeding actually happens.
* test(native-chat): pin the empty seed the record cannot carry
valuesByModel is merged over the resolved model, so a stored `model` key
can blank it — the seed then empties out and must resolve to undefined.
Nothing covered that branch, so returning the empty map unguarded stayed
green.
It matters because emitting `{ model: '' }` fails the record's
bounded-string guard, and agent_session_options_invalid is not a wire
refusal code: classifyStoreFailure rethrows it, the client reads the raw
error as an unknown outcome, and the launch strands with no fallback.
Corrects
|
||
|
|
6494f2a4f0 |
fix(native-chat): resume a structured chat from Agent Session History (#18933)
* fix(native-chat): resume a structured chat from Agent Session History Clicking Resume on a chat-UI row could only reveal an already-open tab. If the chat had been closed, or this process had never published it, the click re-read an inventory that did not contain it and toasted "Retry in a moment" — advice that could never come true, because nothing republishes an unpublished tab. The legacy `claude --resume` fallback is deliberately refused for structured-owned rows, so the row had no way back at all. `close` already keeps the record and the journal on disk so a session can be attached again, and the hold path already resurrects one in full. What was missing was the tab: `restoreReadableSessions` is latched to run once, at startup, so nothing could ask for a single session later. Adds `agentSession.reveal`. The host looks up its own record, restores the session readable, and republishes the tab through the same call `agentSession.create` uses. Deliberately narrow: - It takes no hold. A provider child exists because a surface asked, and the chat pane asks when it binds. - A journal it cannot read is not a refusal. A chat whose journal predates the SQLite store restores to nothing here, but attach still recovers it, so the tab is published and the pane's hold finishes the job. - Workspace and provider come from the record, never the client, so a session id alone cannot aim the publication at another workspace. Claude and Codex both, by construction: eligibility is `adapterSupportsRecord`, which the router answers from the record's own provider. Gated on a new advertised capability rather than probing for method_not_found, matching agent-session.structured.hold.v1 — absence is visible during negotiation instead of by calling. * fix(native-chat): negotiate reveal against the host that owns the workspace The capability gate read the LOCAL runtime's advertised capabilities while the call went to the host that owns the workspace, which for a paired workspace is a different build. On desktop the renderer and its local host are always the same build, so the gate passed unconditionally and proved nothing about the host being called: an older paired host still received the unknown method and its method_not_found was reported to the user as 'this chat is no longer on this host'. The cache it read also starts empty and resets to empty when status.get fails, so 'not fetched yet' and 'unsupported' were the same value. Gate on the environment that will answer, the way agentSession.close already does, and skip the round trip entirely for a local host. Reveal now reports four outcomes instead of a boolean, so a host that is merely too old is not reported as a chat that is gone, and a host we could not reach keeps the retryable message. Also syncs the localization catalog: the 'gone' key shipped without an en.json entry, which reddens static analysis and verify while typecheck stays green. * fix(native-chat): tell a refused reveal apart from a missing chat The host raises two refusals here and they mean opposite things to a user: it holds no such record, or it holds one no adapter of its own can open. The client collapsed both into 'this chat is no longer on this host', which is a eulogy for a chat still sitting on disk. Read the refusal code, and fold the host-side case in with the too-old host under one honest message, since the remedy for both is the same. Adds the coverage the readiness pass found missing: the host's reveal answer itself (workspace and provider from the record, both refusals, an unreadable journal, a live session), and the activation branches for a host that cannot open the chat and for one that never answered. * fix(native-chat): read a host version block as the host's age, not a lost link The capability probe reaches assertRuntimeStatusCompatible, which throws a runtime_compat_block error. Treating that as unreachable told a user with an out-of-date host to retry, which is the one thing that cannot help. Branch on isRuntimeCompatBlockError the way remote-agent-session-launch already does for the same probe. Also adds the refusal-code case a previous commit claimed and did not deliver: nothing drove a structured_agent_session_unsupported reply through the reveal client, which is the branch that commit existed to add. Corrects a doc comment that reveal made wrong: attach is no longer the only call that builds the host. * fix(native-chat): let a dragged history row reach the same reveal as a click Dropping an Agent Session History row onto a pane activated the tab by id and, on a miss, raised the very toast this PR exists to remove — so the same row answered a click and a drop differently, and the drop kept the advice that can never come true. The structured branch never used the drop pane, so routing it through the shared activation loses nothing and gains the reveal. The helper only ever read one field, so its parameter narrows to that field and the drag payload satisfies it directly. A source ratchet holds both entry points to the reveal-capable path, since a mounted drag harness does not exist for this layer and what regresses is a call site, not a rendering. * fix(native-chat): stop an advisory refresh ending the click, and one click per row Manual QA found the reveal never ran: the inventory refresh that precedes it is an optimization, but its failure returned early with 'not available yet, retry in a moment' — reinstating the dead end this PR removes, one step earlier. A failed refresh now falls through to the reveal, which is the repair and does not need the refresh to have worked. The click can chain a refresh, a capability probe, a reveal and a second refresh, each with its own timeout, while nothing on the row says it is working. A per-session in-flight guard keeps an impatient second click from running the whole sequence again and landing its own toast. Also drops an unreachable owner scope: the snapshot apply discards any worktree whose execution host is not local before it reads one, so naming a remote scope there described a synchronisation that cannot happen. * fix(native-chat): bound the capability probe and stop naming the wrong machine The in-flight guard releases when the activation settles, so an await that never settles holds the row for the life of the process. The capability probe was the one call in the chain not raced against a deadline: on a cache hit it awaits a promise an earlier probe created, which may carry no deadline of its own. Race it like the two calls around it. A version block can name either side — evaluateRuntimeCompat reports client-too-old as well as host-too-old — so a message that blamed the host pointed half of those at the wrong machine. Name the remedy instead of the machine, which is true for every case that reaches it. * chore: remove a scratch repro file committed by mistake It was swept into the previous commit by a broad `git add` while a diagnostic ran in this worktree. It asserts the current renderer-sync defect as expected behaviour, so it would fail the moment that defect is fixed. * fix(native-chat): stop a reveal's own inventory refresh discarding its republished tab Manual QA: the host answered reveal with ok:true and republished the tab, and the chat still did not reopen — only a renderer reload brought it back. The renderer publishes under one epoch string for its whole lifetime, and a frame recorded under a different lineage retires that epoch permanently with nothing to un-retire it. The Resume click asks for an inventory first, and a worktree the host holds no entry for answers with the none/v0 sentinel; the structured path recorded it, retiring the renderer's own epoch, so the tab the reveal published a moment later was dropped. A reload minted a new epoch, which is why reloading appeared to fix it. A frame that carries no publication is not a later publication to fence against. Treat the sentinel and a removal frame as a cursor reset, the way the mainstream session-tabs path already clears its tracking — its comment names this exact hazard: recording that sentinel would retire the host epoch and reject the next live frame. Pre-existing, and it swallows an ordinary new-tab launch on an empty worktree too; the reveal is what turned a silent invisibility into a visible failure. * fix(native-chat): let a retraction prune its rows without retiring the epoch Correcting the previous commit. Skipping a retraction frame outright stopped it pruning the mirrored rows, so a worktree the host no longer publishes would have kept a chat on screen with nothing behind it. Apply the frame as before and clear its cursors instead of recording them, which is what the mainstream session-tabs path does. The unpublished sentinel keeps its cursor now too: it is skipped rather than cleared, so a stale frame arriving late is still fenced. Adds the case the earlier version would have broken. * fix(native-chat): keep the retraction's fences, and fence the reveal's refresh Correcting the retraction handling again. Clearing its cursors was more than the bug needed and cost a guard: the host mints a fresh epoch when it rebuilds a pruned entry, so a republication is never gated by the retained cursor, while dropping it left an inventory response issued before the close free to land afterwards and strand a chat row for a worktree the host no longer publishes. Skip only the recording. The mainstream path keeps its epoch history for the same reason, as a tombstone fence. The test that justified the stronger clearing asserted a host behaviour that does not exist — a rebuilt entry republishing under the renderer's epoch with a restarted counter. It now uses what publishStructuredAgentSessionTab actually mints for a pruned entry, and a new case covers the frame that would strand. Also fences the reveal's inventory refresh on the sync generation, which every other caller that applies an inventory already does: structured chat can be switched off mid-flight, and the answer would otherwise re-seed a row into a renderer that just discarded them. * fix(native-chat): drop the retraction's epoch history, keep its version cursor Third and final shape for this branch, and the only one of the three that holds. Keeping both maps re-poisons the epoch one cycle later: the consumer here is also the publisher, so the history's current is the renderer's own lifetime epoch, and recording the reveal's fresh epoch retires it. The next chat the renderer publishes is then dropped — this bug again, one close later. Deleting both loses the guard that stops a frame issued before the close landing after it and stranding a row nothing republishes. So: clear the history, keep the cursor. The mainstream path keeps its history as a tombstone because there the epochs belong to a remote publisher; that reasoning does not carry to a path that publishes under its own. Each of the three variants now fails a different test. * fix(native-chat): a retraction forgets what is current, not the tombstones The delete lost a fence the cursor cannot replace: the version cursor only compares within a lineage, so a delayed frame from an already-superseded epoch had nothing left to stop it putting a chat row back for a worktree the host no longer publishes. Keeping the record intact had the opposite fault — the renderer's own epoch is the history's current, so the next frame under any other epoch retired it. Clearing only current does neither: noteRetiredValue retires nothing when there is nothing current, and the tombstones stay. Each of the four shapes now fails a different test. * fix(native-chat): narrow the retraction frame through its own type Typecheck caught what the tests could not: `removed` is not on RuntimeMobileSessionTabsResult. The repo already names the shape — RuntimeMobileSessionTabsRemovedResult — so this reads it through a guard rather than the inline cast the mainstream path uses. --------- Co-authored-by: Orca Worker <orca-worker@localhost> Co-authored-by: Merge Sim <sim@local> |
||
|
|
15dabf8d0b |
perf(worktree): overlap base refresh with prepared checkout (#18998)
* Stop obsolete worktree preparations when evicted or expired * Let worktree preparation proceed during stale reclamation * Verify creation during stalled stale worktree reclamation * Preserve preparation ownership until Git removal starts * test: keep artifact share fixtures unexpired across calendar dates (#18955) * perf(worktree): overlap base refresh with prepared checkout |
||
|
|
64a449df4e | perf(search): assemble fragmented subprocess lines incrementally (#18973) | ||
|
|
fc5fa16870 |
perf(windows): split the process table into two flag sets (#17866)
* perf(windows): split the process table into two flag sets
MDE flags "suspicious memory activity" on the process-table reader: it
opened a handle into every process on the box and read each one's PEB on
a repeating cadence. Two changes narrow that.
Drop `Memory` outright. It cost a second
OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) plus
GetProcessMemoryInfo per process, and nothing reads a working set off
this table -- the Resource Manager runs its own sweep, and the addon
stores WorkingSetSize into a DWORD so anything above 4 GB wraps.
Split the rest in two. `readWindowsProcessIdentityTable[Fresh]` is a
bare Toolhelp32 walk with zero per-process handles, and returns
`WindowsProcessIdentityRow`, which has no `command` to read.
`readWindowsProcessTable[Fresh]` keeps the command line for the callers
that match on it. PTY root identity and the owner start-time probe move
to the cheap reader; agent recognition, port attribution, codex turn
processes and structured-TUI matching all genuinely need the command
line and stay.
Two independently single-flighted caches, never one per caller: the
fan-out this module prevents is one scan per caller, and each reader
still serves every caller wanting its flag set. The wedge gate and the
3s deadline stay shared, because both readers call the same addon and
one wedged read latches its one `requestInProgress`. With no binding
there is only the 1.4s PowerShell scan to run, so the identity view
rides the detailed snapshot rather than forking a second one.
Measured on Windows 11, 492 processes (p50/p95): identity 6.3/7.0 ms,
detailed 12.3/13.4 ms, previous memory+commandLine 13.1/14.1 ms.
* fix(windows): serialize native process-table reads across flag sets
The two flag-set readers could both be in flight at once, and the
vendored wrapper does not tolerate that. `getRawProcessList` pushes the
callback onto one list and calls the addon only when no request is in
progress, so a second concurrent caller's `flags` are DISCARDED and it
is handed the first caller's rows. Measured against the real addon:
identity issued first, both callers got the same array, 0 of 541 rows
with a command line. A detailed read overlapping an identity read
therefore returned a table with every command line empty, which agent
recognition reads as "no agent" -- silently, and only under concurrency.
Nothing already here excluded that. Each snapshot cache single-flights
only within itself, and the wedge set latches only after a read misses
its 3s deadline, so through the healthy ~12ms of a scan neither reader
excluded the other. Overlap is the normal state: panes poll detailed at
750ms while a teardown takes identity snapshots.
`nativeReadGate` admits one native read at a time across both flag sets.
It also fixes the relay path, where `adaptAddon` has no queue at all and
two simultaneous CreateToolhelp32Snapshot calls are the crash the
vendor's queue exists to prevent. Every link settles, so a wedged read
never strands a waiter; the waiter re-checks the wedge and rejects. With
one call outstanding, retention stays bounded at one callback rather
than one per reader.
Also from review:
- The CIM fallback now belongs to the detailed flag set alone, and the
identity view projects that snapshot through `toIdentityRow`, so an
identity row carries no command line on a no-binding host either.
- The concurrency test modelled the wrapper's coalescing queue, which
the previous synchronous mock could not express; verified failing
without the gate and passing with it.
- `agent-session-process-identity-probe` early-returns when the
creation-time flag is unavailable, which no shipped addon build
provides, instead of scanning the table to produce null.
- Corrected the cost framing: Memory took an OpenProcess(...|VM_READ)
it never read through, so dropping it halves per-process handle opens
and leaves the PEB/ReadProcessMemory telemetry unchanged.
* test(windows): keep read exclusion across resets and flag each field
Two review follow-ups, both about tests passing for the wrong reason.
`resetNativeReaderState` replaced the read gate with a resolved promise,
so waiters still holding the old chain ran beside reads queued on the
new one. Reachable only from the `__set*ForTests` hooks, which is what
makes it worth fixing: it hands a suite two concurrent calls into its
own mock addon -- the exact condition the concurrency tests exist to
detect. Chain onto the gate instead; every link settles within the
deadline, so the bounded wait that costs is the right trade.
The coalescing mock shaped every field off the CommandLine bit, so an
identity read that did request CreationTime got `creationTimeMs`
stripped. The identity-side assertion was then only `!('command' in
row)`, which a correctly flagged read and a coalesced one satisfy
equally: a future regression losing identity flags under concurrency
would have kept the case green. Gate each field on its own bit and
assert `creationTimeMs` positively, inside the helper both orderings
share.
Concurrency assertions move to a new bare-addon mock. The coalescing
mock's own latch means it can never report more than one call in
flight, so measuring exclusion there proved nothing; the bare addon has
no queue -- like `adaptAddon` on a relay, where re-entering
CreateToolhelp32Snapshot is a real crash -- and makes re-entry visible.
Verified by deletion: restoring `nativeReadGate = Promise.resolve()`
fails the reset case with `expected 2 to be 1`, and restoring the
single-bit mock fails both overlap orderings on `creationTimeMs`.
* docs(windows): count the third test defect in the list that names them
The section opened "Two defects have now shipped", numbered two, then
described the third in its closing paragraph -- a list that reads as a
complete account while quietly omitting one, which is the exact failure
the section exists to warn about. Say three and number it, and note that
the third arrived inside the fix for the first two.
Also record why the creationTimeMs and flags-array assertions are not
redundant, in the doc and beside the assertions: the flags array catches
a read served another flag set's rows, the positional creationTimeMs
check catches field shaping (identity dropping CreationTime, or
toIdentityRow not forwarding it). Neither sees the other's failure.
* docs(windows): stop describing a PEB read this release removed
Every comment here that justified the flag split in terms of PEB reads became
false when the command-line reader moved to the kernel. Left alone, the
enumeration doc contradicted itself inside one file: the flag-set section
described three chained `ReadProcessMemory` calls per process while the
sections below it explained that the addon contains no such primitive and has
no PEB fallback.
The measurement is now attributed rather than merged. Dropping `Memory` halved
the per-process handle opens and nothing else -- both handles carried
`PROCESS_VM_READ` at the time -- and it was replacing the PEB walk that took
`PROCESS_VM_READ` and `ReadProcessMemory` out of the addon. Neither change
substitutes for the other, which is worth keeping straight: the split's
remaining value is the handle itself, not the memory access.
Also adds `relay/windows-port-scan.ts` to the caller table, the one caller this
effort introduced, and records that it reads only pid/name through the detailed
reader -- free while a pane is polling, not free on a headless relay.
* test(windows): pin the fresh links path against the identity TTL cache
The identity and detailed tables are separate snapshot readers with
independent TTLs, so the detailed path's existing freshness guard says
nothing about the ancestry walk's. Cover the identity reader on its own.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
|
||
|
|
0cbb01ef4b |
fix(security): apply the Windows path-hardening ACL that never ran (#17884)
* fix(security): apply the Windows path-hardening ACL that never ran `buildWindowsRestrictAclArgs` invoked the hardening script as `powershell.exe -Command <script> <path> <sid> <isDir>`. `-Command` does not populate `$args`; it appends the trailing tokens to the command text. The script therefore read `$args[1]` as `$null`, threw `NullArrayIndex` at `$allowedSids[$sidText] = $true` under `$ErrorActionPreference = 'Stop'`, and exited 1. Both callers swallowed that: the async callback was empty and `applySecurePathRestriction` returned `true` regardless, while the sync `catch` returned `false` and nobody logged. Every Windows secure path has been left on its inherited ACL since the ACL was introduced (#5006), and nothing said so. Replace PowerShell with `icacls.exe`, which takes plain argv. That removes the quoting surface entirely rather than escaping it: interpolating a path into the command text would have turned a dead no-op into arbitrary PowerShell on a filesystem path, since `-Command` executes what it appends. It also drops the execution-policy dependency and the `powershell.exe` spawn an EDR flags, and runs ~25x faster than the PowerShell cold start. Hardening is now three passes: `/reset` to purge explicit ACEs that `/inheritance:r` leaves behind, `/inheritance:r` plus a `/grant:r` per allowed SID, then a read-back that checks the DACL is protected and grants only the intended rights. The predecessor's verification block was equally dead, and an apply that is never read back is only half a control. Failures stay non-fatal — non-NTFS volumes, network paths and restricted tokens fail legitimately and must not break startup — but they are no longer invisible: every failure is logged, and a failed async apply now evicts its cache entry so the next call retries instead of trusting a success that never happened. Routing through `runProcess`/`runProcessSync` also retires this file's `node:child_process` allowlist entry. * fix(security): verify the hardened ACL by identity, not by shape Review found the bug class this PR fixes surviving inside the fix. The verify pass checked rule count, absence of the inherited marker, and exact rights — never *who* the rules named. Granting Everyone full control satisfies all three, so hardening reported success on a DACL that handed the credential to every local account, and most of the real-filesystem tests still passed. Verification now reads the descriptor back with `icacls /save`, which emits SDDL with raw SIDs, and compares the principal set exactly. That is also locale-independent by construction: the previous parse read localized account names out of icacls' OEM-codepage stdout, where a non-ASCII path survived by accident rather than by the documented mechanism. SDDL parsing moves to `windows-security-descriptor.ts`. Two further self-inflicted problems, both measured: The post-rename re-harden led with `/reset`, which re-widened a DACL that was already correct — the staged file's protected DACL survives the rename, so the pass had nothing to do but open a window. Polling an external process during a write into a relocated root caught it: the e2ee keypair dropped to `BUILTIN\Users:(RX)` plus `Authenticated Users:(M)` — read *and* write — before tightening again. Hardening now verifies first and returns early when the DACL already reads back correct, which closes the window and cuts the steady state from three spawns to one. Re-measured: 158 samples, one DACL state, zero broad. Evicting the cache on every failed async apply reintroduced #4901. The env store re-hardens on the read path at ~2/s, so on a host where hardening cannot work (FAT32, network path, restricted token) that was two icacls spawns and two warnings a second, forever. Async retries now take a retry floor and a hard per-path attempt cap. The write path keeps retrying unthrottled — it is user-driven, and a failed credential ACL must still be retried on the next write. Also: failures route through a reporter hook that the main process points at the diagnostic tracer, because `console.warn` reaches nothing in a packaged GUI-subsystem build; `writeSecureFile` returns whether hardening took, and the async branch reports `pending` rather than claiming `applied`; a transient `whoami` failure no longer disables hardening for the process lifetime, and the SID is shape-validated; the `/c` guard now covers the synchronous runner too. * fix(security): re-probe hardening instead of latching a transient failure The per-process attempt cap added for the read-path storm was a permanent latch: one AV scan, momentary lock or %TEMP% blip and every later credential write in that session went unhardened, silently, on a host where hardening would now succeed. Same defect class as #17858's computer-use host, and worse here because what stops happening is security hardening on credential files and nothing said so. The retry budget now bounds the *rate*, not the lifetime: at most three attempts per path per minute, re-probing in every later window, forever. The transition is announced in both directions — `throttled` once per window on entry, `recovered` when a rate-limited path hardens again — so a host stuck in the degraded state is diagnosable rather than merely quiet. The reporter type covers both, and the main process ends the `recovered` span successfully rather than failing it. Extracted to secure-path-hardening-retry-budget.ts, which keeps secure-file.ts under its line cap without a max-lines disable. Also confirms the second flagged risk rather than assuming it: a real unwritable %TEMP% is now covered by a test proving verification fails closed, reports at the `verify` stage, and still leaves the ACL applied — so that path loses proof, not protection, and with the lifetime cap gone it can no longer combine into a permanent-off state. * fix(security): verify a directory's whole inheritance flag set The flag check tested only that `OI` was present — never that `CI` was, nor that nothing else was. That was harmless while `/reset` + `/grant` ran on every pass and repaired whatever was there. The verify-first short-circuit made it load-bearing: what verification accepts is now left alone, so a latent under-check went live because a different fix started depending on it. Two directory DACLs passed while being wrong — both protected, three non-inherited full-control rules, correct SIDs, differing from correct only in their flags: (OI)(F) - no CI, so subdirectories are left unprotected (OI)(CI)(IO) - inherit-only, so the directory object itself grants nobody anything; the next writeFileSync into it fails with EPERM, on a directory just cached as hardened Verification now compares the whole flag set, which also rejects IO and NP, and names the offending flags in the failure. Both shapes are planted in real-filesystem regression tests, including an assertion that a write into the repaired directory succeeds and its child inherits. Confirmed both tests fail against the old check and pass against this one. * fix(security): back the hardening retry off exponentially The fixed one-minute window bounded the retry rate but left a standing floor of three attempts per path per minute on a host where hardening can never succeed — FAT32/exFAT, a network path, a redirected profile. That budget is per path and there are several secure files, so the floor multiplied into tens of thousands of icacls spawns a day for work guaranteed to fail. The delay now doubles after each consecutive failure, from a one-minute floor to a thirty-minute ceiling, and the attempt cap is gone entirely: once the backoff elapses the path is re-probed however long it has been failing. A permanently incapable host settles at ~2 attempts/hour. Slowing the backstop costs almost nothing, because it is not the recovery mechanism: the synchronous write path is deliberately unthrottled, so a host that recovers hardens on its very next credential write regardless of what the read-path budget says. The `throttled`/`recovered` reports are unchanged and matter more here, since the quiet periods between probes are now much longer. The curve is pinned in a new unit test against the exported delay function rather than a copy of its constants, covering the doubling, the ceiling holding at 5000 consecutive failures, a 30-day failing path still re-probing, one announcement per degraded episode, and per-path isolation. The integration tests keep only what they uniquely prove: that the read path is wired to the budget, and that a day of failures still re-probes. Confirmed four of these fail against a reinstated lifetime cap. * ci(windows): run the real-icacls DACL suite in CI The win32 suite only self-skips off Windows, so it passed vacuously in every lane. Register it the way the cmd-shim suite is registered. * fix(security): describe the cache's real cost, which is icacls now Both cache comments still justified themselves with PowerShell -- "~1-1.5s" and "a PowerShell spawn every read" -- in the same file whose PR removed PowerShell from this path. The caches are still right, but for different numbers, and the old ones are the kind an engineer would reasonably delete a cache over. The real shape: hardening verifies first and returns early, so an already-correct DACL costs one synchronous icacls spawn and a rewrite costs four (verify, reset, grant, verify). Still worth caching on the read path, which polls at ~2/s. * test(security): make the DACL suite safe to schedule Registering this spec in the Windows lane put it under two rules it had never been measured against. Teardown now goes through `removeTreeSync`, which the lane's boundary test requires, and repairs the DACLs the suite plants on purpose first: those retries only cover transient locks, so a regressed `(OI)(CI)(IO)` repair leaves the root un-removable and `afterAll` throws EPERM. And the no-permission case decides by elevation before it writes anything. `windows-2022` runs elevated, where hardening succeeds: the old branch asserted nothing about denial and instead replaced the `hosts` DACL, then `icacls /reset` -- which is not a restore, it drops the explicit `SYSTEM:(F)` that file ships with. Ephemeral in CI; permanent for a developer running the lane from an elevated shell. Now it asserts or it skips. The probe reads the token integrity SID rather than `icacls /save`, which succeeds unelevated (`BUILTIN\Users:(RX)` carries READ_CONTROL) and would have skipped the case on every machine. * fix(security): measure the hardening latches on a clock that cannot go backwards `mayAttemptHardening` compared wall-clock times, so any backwards step -- an NTP correction, a VM snapshot restore, a user changing the clock -- made the elapsed time negative and held every failing path below its delay until the clock caught up. Measured at the 30-minute ceiling with the clock stepped back a year, the path was refused at +0d, +1d, +30d, +180d and +364d, and re-probed only at +366d. That is the permanent latch the exponential backoff was added to remove, and it contradicts the module's own "bounds the rate without ever bounding the lifetime". The SID lookup's own one-minute window had the identical shape and is worse: a failed lookup makes `planFor` return null, which disables the synchronous *write* path too, so the write-path exemption that recovers the read-path budget cannot recover it. Both now measure elapsed monotonic time, following the repo's existing `monotonicNowMs` spelling. Two things the write path was not doing, both found in the same pass: - A successful synchronous apply now records the outcome. It is exempt from the budget, but it was also invisible to it, so a host that had demonstrably recovered kept the read path backing off for up to 30 minutes and no `recovered` transition ever came from that lane. Only success is recorded; recording failure would put the exempt lane back under the budget. - `writeSecureFile`'s JSDoc now says its boolean covers the file only. The directory harden is fire-and-forget and answers `pending` on Windows regardless, so a `true` says nothing about the directory's ACL. * fix(security): stop the hardening test doubles from faking a no-op Three CI failures on this branch, one failure shape: hardening silently does nothing and the check that should have caught it agrees. The auth critical-path test hand-rolled a `node:child_process` factory with `execFileSync`/`execFile`. The rewritten ACL path goes through `runProcessSync`, i.e. `spawnSync`, which the factory never returned — so every spawn threw into the SID lookup's bare catch, `planFor` returned null, and hardening no-opped. It mocks `child-process/run-process` now, the boundary production code actually calls and the one sibling ACL tests already mock: an export missing there fails loudly by name instead of returning undefined. Its fake icacls writes a real UTF-16LE SDDL file, so the pinned spawn count per write is a property of the ACL path rather than of the double. The test forces `platform='win32'`, so this failed on every platform, Linux CI included. `windowsSystem32Binary` is a production bug, not a test bug: it builds a Windows path with the host `join`, which off-platform yields the mixed `C:\Windows/System32/whoami.exe`. On Windows the two joins agree, which is why it survived; on Linux the SID lookup's whoami match missed and 27 of secure-file's 32 tests exercised a lane that never ran. These are always Windows paths, so `path.win32.join` is what it should have been. The import-boundary pin still read 160 after this branch migrated secure-path-windows-acl.ts off `node:child_process`; the ratchet correctly refuses a pin left above reality. * fix(security): resolve the machine-relative SDDL alias, and stop a denied read destroying the file Path hardening verified the DACL it wrote by comparing the SIDs `icacls /save` reports. SDDL substitutes two-letter aliases for well-known SIDs, and the resolution table could only hold constants -- but `LA` and `LG` name an account by RID inside the *machine's own* SID, so on a box whose user is the built-in Administrator (a CI runner, an Administrator-only install) the current user read back as `LA`, matched nothing, and hardening reported failure for every path. Resolve those two against the machine authority derived from the user SID; without one they stay unresolved and the comparison still fails closed. Three secret stores treated any read failure as "malformed -- regenerate" and overwrote. A hardened file granting a SID this process does not hold reads as EPERM while its directory stays writable, so the overwrite succeeds: renaming over an unreadable file needs FILE_DELETE_CHILD on the parent, not DELETE on the file. That destroyed the E2EE secret key, every paired device's bearer token, and the plugin vault. Distinguish EPERM/EACCES from a parse failure and refuse. Also close the async lane's unhandled rejection: `void p.then(onSettled)` turned a throw from `onSettled` into a dead main process, and the retry budget it calls threw whenever nothing had configured it -- a contract held only by import order. The budget now defaults its own bounds. * test(windows): say which ACEs icacls listed when a planted DACL fails `toHaveLength` reports only a count and vitest elides the array, so three preconditions failing on the CI runner said "expected 3, got 6" and nothing about what the sixth entry was. Name the entries in the failure. * fix(security): stop three more stores overwriting what they were denied Same swallow-default-overwrite shape as the readers already fixed, found by sweeping every store that reads under a hardened root. - plugin-storage-store.ts returned `{}` on any read failure and set()/delete() wrote it back, losing the plugin KV store. It is the secrets store's shape line for line, so the two now behave identically. - relay-revoke-outbox.ts returned [] and save() wrote it, dropping revocations that never reached the relay -- a revoked device stays live. - profile-cloud-session-store.ts mapped an EPERM read onto `decrypt-failed`, which fails the `status === 'found'` guard in clearCloudSessionIfUnchanged and falls through to an rmSync of the account session. A denied read now reports `unreadable`, which licenses nothing; the refresh path bails on it and the auth status surfaces it rather than reporting a bare reconnect. All reuse isPermissionDeniedError. The predicate stays an EPERM/EACCES allow list rather than "ENOENT defaults, everything else throws": these stores are meant to self-heal a truncated or malformed file, and inverting it would turn a corrupt keypair into an app that cannot start. The distinction that matters is "could not read it" versus "read it and it was garbage". * test(windows): plant fixture DACLs that cannot inherit what they did not plant %TEMP% grants [SYSTEM, Administrators, <user>] (OI)(CI)(F) by default, and those propagate into every fixture. Three preconditions read back 4 and 6 ACEs where 3 were planted, and the extras looked like Orca's own hardening because the shape is identical -- on a runner whose user is the built-in Administrator, the inherited trio IS the trio production grants. Combining /inheritance:r with /grant:r leaves the argument order to icacls, and that combined form drops the inherited ACEs on Windows 11 but keeps them as explicit ones on the Windows Server runner. Removing inheritance in its own invocation makes the grant the whole DACL on either host, and the fixture root is de-inherited once up front so nothing propagates in. Rooting the fixtures outside %TEMP% would not have fixed this: any directory inherits from wherever it lives. The fix is to stop inheriting, not to move. No assertion is relaxed -- the counts stay exact. * test(windows): pick a foreign SID that stays foreign on an elevated runner `S-1-5-32-544` is only foreign to a token that is not an administrator. The CI runner is elevated AND logged in as the built-in Administrator, so granting Administrators granted the reader full control: the file stayed readable, and all six preservation assertions went vacuous rather than proving anything. BUILTIN\Guests is resolvable everywhere and no interactive token is a member, so the read is denied on an unelevated developer box and on the runner alike. An unresolvable SID would have been the stronger choice but icacls rejects one with ERROR_NONE_MAPPED (1332). The premise guard is what caught this -- it asserted the file was actually unreadable instead of trusting the grant, and named elevation as the suspect. * fix(security): refuse on any read that never reached the contents, not just a denied one isPermissionDeniedError becomes isUnreadableError, because "permission denied" was never the concept -- "could not read it", as opposed to "read it and it was garbage", is. EBUSY, EMFILE, ENFILE and EIO say exactly as little about a file's contents as EACCES does, and they fell into the branch that regenerates and overwrites. On Windows EBUSY is the likelier of the two: antivirus holding a credential open at the moment of a startup read produces it, which makes it a commoner path to the same permanent loss than the ACL case that motivated the original fix. Still an allow list, deliberately: ENOENT keeps licensing a create, and a parse failure keeps self-healing. The stores are built to recover from a truncated write, and turning that into a refusal would trade a recoverable state for an unrecoverable one on the startup path. Also fixes the regression suite's own premise on an elevated runner: makeUnreadable combined /inheritance:r with /grant:r, and that form keeps %TEMP%'s inherited [SYSTEM, Administrators, user] as explicit ACEs on Windows Server -- so the file stayed readable and all six assertions were vacuous. Same split-the-invocation fix as the ACL suite's planter. * test(windows): skip the preservation suite where a read cannot be denied An elevated token logged in as the built-in Administrator reads straight through a DACL that grants it nothing -- confirmed on the CI runner against both BUILTIN\Administrators and BUILTIN\Guests, and with the grant split into its own icacls invocation so the DACL really was the planted one. On such a host the premise these tests rest on does not hold, and every assertion would pass while proving nothing. So probe once at module scope and skip rather than assert vacuously -- the same trade the ACL suite already makes for its unelevated-only case. The gate stays in the compound `<win32 check> && <flag>` form the win32 lane ratchet detects, so the file stays registered in both lane lists. Coverage is not lost where it counts: isUnreadableError has unit tests that run on every platform and every host, and the stores' refusal is exercised in full on any machine where a denial is reproducible -- which is every developer box. --------- Co-authored-by: Orca Worker <orca-worker@localhost> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
cff202c16a |
fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell (#17880)
* fix(windows): drop EDR-flagged -ExecutionPolicy Bypass from encoded PowerShell
MDE flags `-ExecutionPolicy Bypass` paired with base64 `-EncodedCommand` as a
behavioural signal. Measured on Windows 11: neither `-Command` nor
`-EncodedCommand` is execution-policy gated (both run under an explicit
`-ExecutionPolicy Restricted` and `AllSigned`; only `-File` fails), so the
switch was a pure no-op on every one of these command lines.
Removes the switch from all four sites that spelled it, and de-encodes the one
site whose payload never passes through a re-parsing shell:
- ssh-remote-powershell: one chokepoint for ~40 remote-Windows call sites.
Base64 kept — the remote sshd DefaultShell re-parses this string.
- setup-agent-sequencing / windows-cmd-runner-delayed-launch: base64 kept —
these strings are typed into a terminal pane.
- windows-interactive-login-spawn: base64 kept — `cmd.exe /c start` re-parses,
and the cmd-safe-token guard rejects the `&` and `"` in the raw relay script.
- windows-mobile-firewall local runner: `-EncodedCommand` -> `-Command`, since
execFile reaches CreateProcess with no shell in between.
The setup startup gate keeps execution-policy relief in-payload (process scope),
because it evals a user-authored startup command that may invoke a `.ps1`, and a
`.ps1` IS gated. Caught by the real-process suite; mirrors the agent-hooks
launcher's trade.
The elevated firewall child deliberately stays encoded: `Start-Process
-ArgumentList` joins its array into one ShellExecuteEx string without quoting
and PowerShell re-splits on whitespace, measured to collapse `C:\My App\...`
to `C:\My App\...` — a firewall rule for the wrong program.
* test(ssh): enforce the no-script-file invariant remote payloads rely on
Dropping `-ExecutionPolicy Bypass` from `powerShellCommand` is a no-op only
while no remote payload loads a PowerShell script file — execution policy has
never gated anything else. That invariant held by inspection and was guarded by
nothing, so a future payload that dot-sourced, used `-File`, or imported a
`.psm1` would break only on a remote host with a Restricted/AllSigned
LocalMachine policy and no GPO: a failure on someone else's machine.
States the invariant at the wrapper, and adds a ratchet that scans every module
importing it for `.ps1`/`.psm1`, `Import-Module`, `-File`, and dot-sourcing.
The scan discovers importers itself (13 today) so new ones are covered, and
asserts it found some, so an emptied list cannot pass vacuously.
Mutation-checked: injecting each construct into a real importer fails the
matching case and names the file. The first dot-source pattern passed a
`;`-prefixed sample but missed `powerShellCommand(". '$x'")` — the likelier
shape — so the pattern now accepts a string-literal start and the self-test
samples carry their surrounding quotes.
* test(ssh): close two blind spots in the remote-payload ratchet
Both found by independent mutation testing of the ratchet itself, and both let
a real violation pass while the guard reported green.
`-File` was matched case-sensitively, so `-file $scriptVar` slipped through —
PowerShell switches are case-insensitive, and with a variable path the `.ps1`
pattern does not cover for it, so that shape escaped both nets. The naive fix
is wrong: bare /-File\b/i matches `--credential-file`, `--log-file` and
`--body-file`, which occur in three of these importers. Anchoring to a token
boundary catches the lowercase, odd-spacing and argv-element forms with zero
offenders across all 14.
Comment stripping paired a `/*` appearing inside a string (a glob such as
'src/*.ts') with any later comment close and deleted everything between, hiding
violations in the gap. Anchoring the block strip to line start, as the `//`
strip already was, fixes it — verified by injecting an `Import-Module` after a
glob string: the unanchored form misses it, the anchored form catches it.
Extends the same case-insensitivity to `.ps1`/`.psm1` and `Import-Module`,
which had the identical flaw (`import-module`, `DEPLOY.PS1` are legitimate
spellings); measured to add no false positive.
Each construct now carries the fixtures it must catch AND the near-misses it
must not, so a future tightening cannot quietly trade one for the other — the
negative fixtures are what would have caught the naive `-File` fix. Non-vacuity
bound tightened to >10 against 14 importers.
* docs(ssh): state what the remote-payload ratchet cannot see
The scan matches source text, so a script file reached only through a variable
(`& $scriptPath`) never appears in source and no pattern can catch it. The
ratchet narrows the hole; the invariant note on `powerShellCommand` covers the
remainder.
Recorded because a guard that reads as complete coverage when it is not is
worse than one that states its edge: the next author trusts it further than it
deserves, and should learn this limit from the test rather than an incident.
* test(ssh): scan remote payloads with the shared source walk
The ratchet had its own tree walk and comment stripper. The walk skipped
neither node_modules/dist/.git nor dot-directories and excluded tests by
`.test.ts` alone, so its importer count -- the guard's own goalpost -- could
be wrong about what it scanned. The stripper was anchored to line start to
dodge a `/*` inside a glob string, which silently skipped trailing comments;
`stripComments` tracks quote state and handles both.
Importer set re-derived against the shared walk: 15, floor unchanged at 10.
* fix(setup): report a failed execution-policy relief instead of swallowing it
The in-payload Set-ExecutionPolicy carried -ErrorAction SilentlyContinue and
an empty catch, so any failure vanished. A Windows PowerShell 5.1 install with
duplicate extended type data fails every cmdlet in Microsoft.PowerShell.Security
-- autoload, not policy -- and the user then saw only their own .ps1 being
refused, with no trace that the relief had been attempted or why.
-ErrorAction Stop is what routes a non-terminating failure into the catch at
all; the catch reports the FullyQualifiedErrorId to stderr and deliberately
does not rethrow, so a broken policy cmdlet cannot take down the startup this
gate exists to run. Success path is unchanged and stays stderr-clean.
Verified by execution on a clean child environment: success -> policy=Bypass,
stderr empty; shadowed failing cmdlet -> diagnostic on stderr and the gate
still continues; the old empty catch -> silent.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
|
||
|
|
4e8e14424d | perf: avoid splitting every path during file autocomplete (#18919) | ||
|
|
54a8afc91d |
fix(orchestration): typed error codes for dispatch and worker-start refusals (#18902)
* fix(orchestration): typed error codes for dispatch and worker-start refusals orchestration dispatch (and worker-start, which composes it) surfaced task not found, task not ready, and inject rejected as the same bare runtime_error, so an agent reading the receipt could not choose between creating the task, waiting on dependencies, or picking another terminal. Add task_not_found (data.taskId), task_not_ready (data.status, data.unmetDependencies), and inject_rejected (data.terminal, data.reason), each carrying data.nextSteps so every shipped CLI already prints the recovery. worker-start's not-ready refusal moves from task_not_startable to task_not_ready with the same detail. runtime_error stays for genuinely unexpected failures. Proven red-first from RpcDispatcher through the CLI's own failure formatting, plus an SSH bridge test that the host CLI's typed refusal relays unchanged. * test(orchestration): load CLI formatter at runtime in the dispatch-code test The composite node typecheck (config/tsconfig.node.json without --composite false, as CI runs it) rejects a static import of src/cli from a main test with TS6307. Load the formatter and error class dynamically behind narrow structural types, as the CLI/runtime boundary test does. * fix(orchestration): keep task_not_startable and split the CLI-format proof Review on #18902: - Drop task_not_ready. worker-start already published task_not_startable for a not-ready Task, so renaming it would change an existing receipt value under old clients. dispatch now emits task_not_startable too (it was a bare runtime_error before, so this is purely additive), with the new data.status / data.unmetDependencies / data.nextSteps. - Move the refusal receipts (code, message, data) into src/shared/orchestration-dispatch-refusal-contract.ts so the runtime emits them and the CLI test formats the identical envelope. The RPC test under src/main asserts toEqual against the contract; the new src/cli/orchestration-dispatch-refusal-format.test.ts feeds those same receipts to formatCliError / reportCliError. Neither tsconfig widens and the composite typecheck CI runs is clean. * fix(orchestration): keep published refusal messages and type the DB claim guards Codex review of #18902: - Every call site keeps the exact message it published on main ("Task not found: <id>", "only a ready Task can start.", "cannot retry from Dispatch"); the shared contract now takes the message per site and only owns the code and data. Baseline strings are pinned as literals. - createDispatchContext's own missing/non-ready guards, including the atomic-claim loser, now emit the same typed receipt instead of a bare Error, so a dispatch that races a status change no longer flattens to runtime_error. Covered by a dispatcher-level race test. - Invalid --retry-of keeps task_not_startable but now carries status, unmetDependencies, retryOf, and a retry-specific next step. - Dependency recovery text distinguishes waiting on running deps from retrying/unblocking failed ones. - CLI test adds an unknown-code case so the old-client claim rests on an assertion, not a comment; SSH test asserts exact stdout. - Guide table narrowed to the covered preflight cases; occupancy stays runtime_error and is named as such. |
||
|
|
f8780a2c86 |
feat(native-chat): stop monitored tasks individually (#18807)
* feat(native-chat): stop monitored tasks individually * test: expect Claude task stop capability --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
2513e21390 |
fix(native-chat): publish structured session status from the host so the sidebar never goes stale (#18776)
* fix(native-chat): publish structured session status from the host The sidebar learned whether a structured chat was mid-turn by replaying the session journal in the renderer, through a reader whose lifetime was tied to the chat pane. Hiding the pane stopped the reader before the turn's settlement arrived, so the row stayed on "working" until the chat was reopened. The same coupling meant a tab never opened this session showed no status at all, and a reloaded renderer lost every settled row. The host owns the journal, so it now projects each session's status once per journal publication and fans the changes out on one stream per client (`agentSession.subscribeStatus`). The projection survives eviction of an idle session's provider child and is republished when readable sessions are restored. The renderer bridge subscribes to that feed per runtime target and never opens a transcript reader; the observation hook is gone. Additive wire surface behind the existing structured capability; old hosts reject the method and the renderer retries, showing no status. * fix(native-chat): negotiate the status feed and stop losing a change on subscribe The status stream is additive to a surface that already shipped, so a host advertising agent-session.structured.v1 can still answer subscribeStatus with method_not_found. Every renderer error path reconnected, so a remote host one release behind got a relay round-trip every 5s and no sidebar status at all. Give the method its own capability and probe it before subscribing; a failed probe still retries, an absent capability does not. Re-projecting on subscribe also wrote straight into the shared cache, so a second client could pin the first to a stale summary. Route those diffs through publish() before the arriving subscriber is registered. * fix(native-chat): bound the status prompt, merge snapshots, and prove the unread path One status frame carries every retained session and a send admits 256 KB per prompt, so ~16 large-prompt sessions could push the snapshot past the 4 MB outbound guard and into the retry loop. Bound latestPrompt to the same 200-char single-line preview every other agent-status row already carries. A snapshot also replaced the cached map wholesale, so the empty first frame from a restarting host retracted every row before restore republished them. Merge instead; the tab map, not this feed, decides which sessions are listed. Tests: the hidden-pane claim now sits at the host, where a journal with no transcript subscriber is driven from running to idle; the RPC test reads a real projection instead of its own stub. * fix(native-chat): merge the duplicated status-event type import * test(native-chat): pin the restart status publication, and log the unsupported host Startup restore indexes a readable session and publishes its status, which is what puts a never-reopened tab back in the sidebar. Only an Electron screenshot covered that wiring; a sitting status subscriber now pins it directly. The terminal "host too old" branch was silent, so a mixed-version report showed an empty sidebar with nothing in the log to explain it. --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
d7767fb196 |
perf(worktree): remove redundant creation and terminal startup work (#18793)
* perf(worktree): remove redundant creation and terminal startup work * test(worktree): cover optimized creation call signatures Preserve explicit branch adoption, WSL callback routing and sparse cleanup expectations. * perf: preserve user Git checkout worker settings * perf(git): skip malformed remote base probes * perf(cli): avoid loading other agent hooks for Codex preflight * fix(build): retain Codex preflight entry for packaged CLI * test(ssh): wait for replacement PTY before lease recovery input * test(ssh): verify recovered shell execution and lease ownership * test(electron): reap isolated macOS crash reporters on teardown * test: allow either observed self-exit snapshot ordering * test: capture frozen-host input recovery evidence |