mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
perf/runtime-graph-publication-rate-squashed
11165
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
edebbdd56f | revert(renderer): restore the module-local agent-status memo; a graphState field needs a banned cast | ||
|
|
14a03a29ad | chore(renderer): merge the duplicated mobile-session-inputs import in the gate fixtures | ||
|
|
b24ca766ff | chore(renderer): scope the census Proxy traps past the no-reflect-get audit | ||
|
|
1671ae2d92 | test(renderer): reuse the shared publication-cache reset instead of a second copy | ||
|
|
42e1ac9154 | docs(renderer): stop restating the fingerprint contract in three places | ||
|
|
78e24704b4 | refactor(renderer): make ambiguousTabIds a publication input instead of a threaded parameter | ||
|
|
274cf0e5f9 | refactor(renderer): keep the agent-status memo on graphState, not behind a getter/setter pair | ||
|
|
4d4bf2cf94 | refactor(renderer): drop the local narrowMapByKeys copy in favour of the exported one | ||
|
|
905ae7b290 | refactor(renderer): drop the bucket-equality copy of narrowedEntriesEqual | ||
|
|
234943666f |
docs(renderer): correct the worktree-source fingerprint purity claim
The fingerprint comments said `buildMobileSessionWorktreeInputs` is pure over exactly the collected refs. It is not: the builder also reads the live terminal registry and PaneManager/DOM through `captureMountedTerminalSurfaces`, which no store reference and no census proxy can witness. That input is fenced at the call site by the `registeredTabIdsByWorktree` and cached-capture-size guards, so the old wording invited a maintainer to delete the guards that `sync-runtime-graph-late-terminal-mount.test.ts` exists to protect. Also fixes a pointer to a test file that does not exist. |
||
|
|
66ae19545e | test(renderer): make the worktree-source census granularity- and publication-aware | ||
|
|
0a82f4594f | test(renderer): pin the remaining runtime-graph fingerprint fields | ||
|
|
7385b7b50c | test(renderer): pin every runtime-graph worktree fingerprint field | ||
|
|
989af5d706 | perf(renderer): key the worktree-id memo on the shared empty browser slice | ||
|
|
f8d017a2ff | test(renderer): cover the late-mount republication clause in the worktree gate | ||
|
|
b64d4162bb |
perf(renderer): rebuild runtime-graph snapshots only for changed worktrees
Publishing the runtime graph re-walked every accumulated worktree and every tab on every frame, regardless of how little had changed. On an 870-worktree / 1,740-tab session that is two full tab walks per title frame and 870 per-worktree snapshot rebuilds, when typically one worktree moved. Each worktree's snapshot now carries a fingerprint of the 25 store inputs its builders read. A publication reuses the cached snapshot for any worktree whose fingerprint is unchanged by reference, so per-frame work becomes O(what the frame changed) rather than O(session size). Slice scans (ambiguous tab ids, status grouping, the mounted-tab ownership index) are memoized on store-slice identity behind the same rule. This subsumes the memoization in #21075 rather than stacking on it: `collectAmbiguousTerminalTabIds` becomes a wrapper over the ownership index, and the agent-status grouping memo is re-keyed. #21075's key was `(agentStatusByPaneKey, tabsByWorktree)`, and a status frame replaces `agentStatusByPaneKey` by construction, so that memo missed on exactly the frame class it targeted — 1,740 tab reads and a 1,740-entry Map allocation per status frame for no saving. Measured with a tab-read counter on an 870-worktree fixture, reads per publication by frame class (#21075 -> this branch): pane-title 0 -> 0, agent-status 1,740 -> 0, tab-title 5,220 -> 6,960 on the cold walk but one walk instead of two thereafter. This is not a fix for typing latency. Attribution puts runtime-graph publication at 14.3% of renderer CPU and 6.3% of long-task time; deleting it outright moves typing p90 from 190ms to 134ms. What this buys is that the cost stops growing with session size. |
||
|
|
4a86b2dc56 |
refactor(mobile): checked reply readers for files, dictation, host-screen and agent-history (step 7) (#21269)
* test(mobile): record main's file-preview and markdown-disk-fallback replies
Four of this branch's read sites had no malformed-reply coverage, so the reader
change would have had nothing to move at them. `familyGoldens` matrixes only the
first scenario of each family, and `files.preview-load`'s base is the grant-refresh
chain while `session.tab-documents`' is the served markdown tab — which left
`files.read` and `files.readPreview` on the worktree preview path, the artifact
image read, and the markdown tab's on-disk fallback recorded on their success path
only. This commit is the before picture, taken from main's own tree with no product
edit in it.
Three new families, five scenarios, ten goldens:
- `files.preview-worktree-text` / `files.preview-worktree-image` — `files.read` and
`files.readPreview` as the preview screen asks them for a worktree file.
- `files.preview-artifact-image` — `files.readTerminalArtifactPreview`.
- `session.markdown-disk-fallback` — the `files.read` leg a headless host's
`renderer_unavailable` sends the markdown tab down. It carries a second scenario
that serves `markdown.readTab`, because a matrix site needs a fulfilled reply
recorded somewhere in its own family to replay as the `normal` partition.
No existing scenario moved to a new family and no adapter changed, so every
pre-existing golden keeps its `adapterSha256` and `scenarioSha256`. Recorded in a
detached worktree at the manifest's pin (`4b876758d3`) with this manifest copied in;
the control is that all 748 pre-existing goldens came back byte-identical to
origin/main's, which `git diff
|
||
|
|
9de6f2c6cd |
test(terminal): bump the pane hook-order parity pin past #9035 (#21276)
* test(terminal): bump the pane hook-order parity pin past #9035 #9035 added a useRef and a useCallback to use-terminal-pane-foundation (search input ref, focus-search-input) without moving the parity pin, and its own PR run never executed the shard that holds it. Every PR opened since fails `tests node 24 7/8` on `expected 211 to have a length of 209`. The two hooks are in order behind the existing ones and useMemo stays at 8. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(terminal): re-pin the hook-order hash for the two #9035 hooks The count alone was not the pin: the flattened order is hashed too. The new order is the old one with useRef and useCallback inserted at the foundation stage and nothing else moved (diffed before and after #9035). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
b66ef2e8a8 |
fix(agent-launch): resolve a launch scope, not a git worktree record (#21193)
* fix(agent-launch): resolve a launch scope, not a git worktree record `agent.launch` asked the runtime for a managed worktree record and then read exactly one field off it, `.id`. That record does not exist for every workspace a launch can run in, so the request refused launches the method could otherwise run: the floating workspace resolves to a scope with an id and a path but no worktree row, and `showManagedTerminalWorkspace` throws `selector_not_found` rather than hand back the id it had already resolved. A folder workspace survived that only because the resolver fabricates a worktree row for it. The scope is the answer that is real for all three kinds, so the launch asks for that instead. `showManagedTerminalWorkspace` is unchanged - callers that genuinely need the git record still get it, and still get the refusal. With floating now reaching the mode decision, the host must know which kind of workspace it resolved. The kind is derived from the id it resolved itself, never accepted from a caller, and the route module's existing `floating` blocker does the rest: a workspace with nowhere to keep a session runs a terminal agent. Behaviour change, deliberate: a floating-workspace `agent.launch` used to fail with `selector_not_found` and now succeeds as a terminal agent. That is what lets the floating titlebar agent button move onto the shared launch command instead of driving tab startup itself. No wire change: `AgentLaunchTarget` is untouched. * test(agent-launch): cover floating RPC workspace resolution |
||
|
|
434365d2de |
Offer to reconnect native chats that were working when Orca restarted (#21096)
* feat(native-chat): resume structured chats that were working at restart
Teardown records a marker for every session this host was genuinely running a
turn for, derived from the LIVE runtime rather than a persisted status row, so
a stale `running` row left by an older crash can never trigger a resume. On the
next launch a modal lists exactly which chats would resume and resumes them via
native continuation (Claude resume/resumeSessionAt, Codex thread id) — never by
re-sending the prompt, which is what makes an agent redo finished work.
A session resumes only when all of these hold: a teardown marker exists and has
not expired, the record's lease is released and reconciled, a provider resume
cursor exists and still matches the marker, the journal's own turn record names
the same turn, and the marker has not already been spent. Markers are consumed
before the resume is submitted, so a crash mid-resume cannot double-fire, and an
admission gate refuses a second concurrent resume for one session. Resumes are
staggered three at a time rather than spawning every provider at once.
The modal's "Don't ask again" checkbox writes the nativeChatResumeWorkOnRestart
setting, which Settings can turn back off; automatic mode runs the identical
predicate and staggering and reports what it did. Declining consumes the markers
so the prompt cannot return every launch — nothing is lost, because opening a
chat still re-acquires it at the same cursor.
* fix(native-chat): compare handle ROOT and turn state when offering a resume
Four defects QA found in the restart-resume offer, fixed together because the
first two interact: shipping the root fix without the state fix would convert a
silent no-op into actively offering finished chats.
1. Claude was never offered (0/4). The marker recorded agentSessionProviderHandleKey,
which embeds Claude's leaf uuid — a branch cursor. The adapter's own close path
appends a `resumed` link with an advanced leaf during the SAME teardown, so the
marker went stale seconds after it was written and the drift guard refused every
Claude session forever. Record and compare agentSessionProviderHandleRoot instead:
the root is the part a resume must preserve, and changing it is a fork, which is
exactly what this guard is for. Codex is unaffected (its thread id is the whole
key) but uses the root too, so the rule is uniform.
2. The predicate compared turn IDENTITY but discarded turn STATE, so a `completed`
turn satisfied it as readily as an interrupted one. Eviction rewrites `running`
to `interrupted` and never to `completed`, so the state is what separates work
that was cut off from work that finished. Require `interrupted` or `unverifiable`.
3. A chat blocked on a pending approval or question was marked as working, because
the teardown reader accepted any `running` turn while the product's own projection
calls that state `attention`. Teardown now defers to that projection: an agent
waiting on the USER is not interrupted work.
4. "Resume all" could silently no-op. The modal fetched candidates at mount; by click
time the chat's own pane may have bound and taken the hold, moving the lease to
`live` so the predicate dropped it and the call returned no results, leaving the
dialog open behind a dead button. Re-derive at click time and settle an
already-live session as resumed — it is running, which is what the user asked for.
Test fakes now model the Claude close path that advances the leaf, which is why no
unit test could previously exhibit defect 1. Ablation covers all eleven guards.
* fix(native-chat): gate the already-live settlement on the full resume predicate
Two follow-ups from re-QA, both cases of a rule stated by intent rather than by
discriminator.
1. The already-live path bypassed the predicate. "Resume all" sends no session
ids, so the fallback's target set was every marker, and it was gated only on
the session having a live provider child. A chat the predicate had refused --
a completed turn, say -- whose pane happened to own the lease was therefore
settled as `already_live` and had its marker spent, inflating the "Resumed N"
count with chats that were never eligible. No provider spawned and no tokens
were spent, but a marker the predicate rejected must never be consumed.
The resumable set now takes an explicit `leaseState`. The already-live path
derives a second set with ONLY the released-lease clause relaxed, and settles
a session just when it is in that set. Every other clause still applies.
2. The `attention` rule was one-sided. Teardown refuses to mint a marker for a
chat blocked on the user, but the set predicate had no equivalent, so a marker
arriving by any other route was offered once eviction rewrote its turn to
`interrupted` -- the same asymmetry the completed-turn case had.
Gated on projectStructuredAgentSessionStatus === 'attention'. That projection
tests for a pending approval or question BEFORE it looks at turn state, so it
still reports `attention` after the turn is settled, which makes it the durable
signal and keeps one source of truth with teardown.
Ablation now covers thirteen guards, including one for each of the above.
* fix(native-chat): capture awaits-user on the marker instead of re-deriving it
The awaits-user clause could never fire. It asked the live projection for
`attention`, which needs a prompt whose resolution is still `pending` -- but
teardown CANCELS that prompt a few phases after it writes the marker. By the next
launch the evidence is gone, for precisely the sessions the clause was written
for. QA measured the injection still being offered and then resumed.
This is the same shape as the leaf-drift bug: state read after teardown is not the
state that justified the marker. The discriminator, now applied across the whole
predicate:
- a fact teardown itself destroys or mutates must be CAPTURED on the marker
while it is still true;
- a fact that evolves on its own must be RE-DERIVED at read time, never
snapshotted.
So `awaitsUser` is now recorded at teardown and the predicate reads the recorded
value. Teardown still declines to mint a marker for such a session, so the
recorded flag is the second line rather than the only one.
Audit of every other clause against the same test:
- turn id (captured) -- teardown rewrites turn STATE but never the id. Correct.
- provider handle root (captured) -- the close path appends a resumed link, and
appendAgentSessionProviderHandleLink refuses one that changes the root, so the
root is invariant under exactly the mutation that broke the key. Correct.
- turn state (re-derived) -- DELIBERATE exception, stated here rather than left
implicit: we are not reading the state that justified the marker, we are
reading teardown's receipt that it settled the turn. A turn still `running`
means eviction never finished, and we refuse. Correct, and intentionally so.
- lease reconciled / released / handoff stage (re-derived) -- these answer a
different, launch-time question: may this host take the lease NOW. The
teardown-time value would be meaningless, and `unreconciled` is cleared by
this launch's own reconciliation. Correct.
- adapter support, marker TTL, marker consumption (re-derived) -- all evolve
independently of teardown. Correct.
Only awaitsUser was on the wrong side.
* fix(native-chat): drop the unreachable awaits-user marker flag
The captured flag was dead code. `awaitsUser` could only be true when the
projected status was `attention`, and `attention` hits the `continue` above the
push -- so every marker teardown can ever write carries `false` (QA measured
22 of 22 across two real teardowns). The predicate clause reading it was
unreachable by any production path.
A flag that is structurally always false is worse than no flag: it reads as a
safeguard, so the next person to touch this trusts it. The asymmetry it was
added to close was only ever reachable by fault injection, because teardown is
the sole writer of markers and already refuses attention sessions.
Removing it also drops an upgrade discontinuity: as a required field it made a
marker written by the previous build fail validation and be silently discarded,
costing a resume offer on precisely the upgrade where the user was mid-turn.
Markers predating the providerHandleRoot rename still will not parse, but those
carry a leaf-sensitive key the predicate would refuse anyway, so nothing usable
is lost.
In its place the teardown gate now states that `status !== 'working'` is the
SINGLE gate for awaiting-user sessions, why a predicate-side mirror would be
unreachable, and why it could not even re-derive the fact -- so the reasoning is
inherited rather than rediscovered.
Ablation is back to twelve guards; every other clause is unchanged.
* fix(native-chat): say reconnect, not resume, and show each offer's age
Two changes, both independent of the parked continuation decision.
1. The copy claimed something QA disproved. "Resuming continues each agent where
it left off" is false: reconnection restores the session at the point it
stopped, with full context and without re-sending the prompt, but the
interrupted reply does not continue on its own. The toast's "Resumed N chats"
implied work had restarted.
Audited every user-facing string against the rule that none may claim work
continues or that a reply resumes -- which caught more than the three strings
the fix started from. The title, the row button, "Resume all", "Resuming...",
the not-now hint ("picks it up where it left off"), the checkbox and its hint
("resume on their own"), the list's aria-label and the Settings row all made
the same claim. The user-facing verb is now reconnect throughout; the body and
update variant state outright that the interrupted reply will not continue.
en.json synced, runtime boot catalog regenerated.
If we later decide to send a continuation instruction, this is one commit to
change back. Shipping text we know to be false was the worse option.
2. Rows now show each offer's age. The TTL is 24 hours and a stale offer looked
identical to a fresh one. The marker already carried `recordedAt`, so this is
a render change plus one field on the renderer's candidate type, formatted
with the existing formatUiRelativeTime helper rather than a new one.
The clock is stamped once when the list arrives rather than read during render:
ages then stay stable across re-renders, and the render stays pure, which the
react(purity) rule requires.
Guards, predicate and RPC are untouched; ablation still covers twelve.
* feat(native-chat): show the workspace name on each reconnect row
A row read `codex · folder:8f3a1c22-… · 8 hours ago`. Recognising which chats
would reconnect is the entire point of the list, and at twenty rows a UUID
identifies nothing.
No RPC or host change was needed: the renderer can already resolve this id.
Resolved the way automation dispatch resolves the same id space
(resolveAutomationDispatchWorkspace) -- a folder workspace by its full
`folder:<uuid>` key via getKnownWorktreeById, a git worktree by its bare
`repoId::path` id via allWorktrees. Both return a Worktree, whose displayName is
a required field, and DetectedWorktree extends Worktree so either shape answers.
Falls back to the id when nothing resolves, which is what the row showed before
and also covers the window before the worktree store has hydrated.
The lookup lives in a per-row subcomponent because a hook cannot run inside
`map`, and its selector returns a primitive string so repeated selector runs
cannot churn referential equality.
* feat(native-chat): group the reconnect modal by worktree and add opt-in continuation
Grouping. Rows are now grouped under a worktree heading with the repo glyph and
an agent count, using the sidebar's own collapse mechanics. Only presentational
pieces are reused -- RepoIconGlyph, CompactAgentExpansion, AgentIcon and
formatShortTimeAgo. The sidebar's agent row cannot be: worktree-card-compact-agent-row
imports DashboardAgentRow, the dashboard's own type, so both surfaces render one
live-agent model requiring a pane, tab and status entry. Every chat offered here
is by definition stopped, so supplying that would mean inventing live state.
Two things I had assumed were reusable and were not:
- DashboardHostBadge returns null unless hostKind is ssh or remote. Structured
chat is local-only, so it would always render nothing. The host line is
omitted rather than faked; the badge is the right element to add if and when
structured chat gains remote support.
- No state dot. Every AgentDotState misleads here: idle and unverifiable both
presuppose a live pane, interrupted renders red like an error, done green,
working a spinner. A missing dot beats one saying these agents are running.
One worktree renders flat with no heading -- a name, count and chevron around a
single group says nothing the dialog has not already said.
The age column now uses formatShortTimeAgo for sidebar consistency. It takes
(timestamp, now) and subtracts internally rather than taking a delta, so the call
is (recordedAt, listedAt); passing the old delta would have rendered plausible
nonsense. The clock is still stamped once into state, so ages stay stable and the
render stays pure.
Continuation. A secondary "Reconnect and continue" action sends one message, from
a single shared constant, identical for both providers. Reconnect is unchanged and
still sends nothing. An info popover quotes the literal message read from that
same constant, so what is shown cannot drift from what is sent.
Ablation now covers fourteen guards. Two are new: continuation only follows a
reconnect that actually happened, and -- inversely -- a send injected into the
reconnect path must turn the test red, since "don't ask again" rests on reconnect
never sending.
* feat(native-chat): say terminal sessions kept running, and clear the quality gate
The modal lists stopped chats with no way to tell that CLI agents are fine, and
the true state of the world is counterintuitive: the terminal sessions survived
the restart and the chats did not. One line now says so, next to the heading
where it frames the list rather than as a footnote at the bottom.
Wording follows the app's own vocabulary rather than inventing a term: the
catalog settles on "terminal sessions" (terminalSessionCount, "Terminal sessions
are grouped by workspace", "No terminal sessions yet"), and UpdateCard already
reassures with "Your terminal sessions won't be interrupted during the update" in
the same text-xs text-muted-foreground treatment. "kept running" rather than
"were restored" -- nothing reconnected them, they never stopped, and the line
says nothing about why.
Also clears check:code-quality:changed, which I had not been running -- oxlint
alone covers neither the design-system nor the casting audit, so 18 findings had
accumulated across the branch.
- design system (4): Button spacing hand-rolled as gap-1/px-2 is just size="xs";
PopoverContent and DialogTitle own their typography and spacing, so the
text-xs moved to the popover's own children and the title's icon gap moved to
a plain wrapper.
- casting (14): production code loses its assertions outright via Reflect.get,
the idiom already used in managed-hook-detection-commands and
worktree-name-retirement. The marker validator reads each field through
Reflect.get and now checks recordedAt is a number rather than asserting it;
the store-file parse uses the existing `file` shape instead of a second
assertion; the runner narrows the admission error's owner with typeof.
Test fixtures keep their assertions behind the line-specific SAFETY:
rationale the repo mandates for exactly this case.
One trap worth recording: the audit reports an assertion at the line its
EXPRESSION OPENS, not where `as` appears, so a disable-next-line above the
closing brace of a multi-line literal is inert and silently changes nothing.
Guards unchanged; ablation re-proved 14/14 at this head.
* fix(native-chat): give the reconnect row's provider icon an accessible name
Every row rendered the provider as a bare AgentIcon, whose svg carries no
aria-label, title or alt. With a Claude chat and a Codex chat in one worktree the
two rows were identical to any non-visual consumer, and the dialog offered
several identically-named "Reconnect" buttons with nothing to tell them apart.
A regression from
|
||
|
|
09622f0c28 |
feat(relay): add a break-glass override for the same-cap monitor gate (#21270)
* feat(relay): add a break-glass override for the same-cap monitor gate Every mutating same-cap wave consumes a fresh 15-minute aggregate monitor dry-run. When a chronic fault is what the gate freezes on, waiting for a green window means waiting for the condition the wave removes: the gate froze 44 consecutive times on the recurring Cloud SQL stall the rolling image fixes. Add `gate-override-reason` and `gate-override-confirmation` (`SKIP_RELAY_MONITOR_GATE <target-image-digest>`) to the same-cap dispatch. A valid pair skips only the aggregate evidence download, provenance verification, and single-use marker. A partial or mismatched override fails closed before any mutation, in both the caller and the reusable job. Record the actor, reason, and confirmation in the gate run summary and, for a canary, in the sealed artifact. The live per-wave preflight still runs. Give it a `--no-monitor-state` source that takes the expected selector from the dispatch inputs and pins the migration policy to `strict`, rather than synthesising a state file that would claim a dry-run it never ran. Also give `director.instances` the two-consecutive-sample tolerance the cell probes have: Cloud Run replaces an instance in place, so the count leaves the [5, 6] band for one sample roughly twice a day, and a deploy overlap raises it the same way. Min and max share one streak so an alternating count still freezes. * fix(relay): canonicalise the break-glass preflight membership The override path parsed the operator's membership with a bare schema parse, while the live selector read from the director is normalised and the comparison is an ordered `JSON.stringify`. Unsorted dispatch input would therefore read as selector drift on a healthy fleet, and the every-configured-cell-exactly-once check was lost with it. Normalise through the same `normalizeSelectorMembership` call the monitor CLI uses when it seals evidence, against the same durable Terraform cell set. Tests use a collect stub that returns the director's canonical selector rather than echoing the expected one, so the ordering is actually exercised: unsorted input must canonicalise, and a duplicated, missing, or unknown cell must be rejected. |
||
|
|
0ed2771fa5 |
fix(relay-ops): tolerate a single unreadable monitor sample (#21272)
An unreadable sample (collector_failed) now gets the same two consecutive sample budget per source as an unread signal, so one failed Cloud Monitoring read no longer restarts the continuous window. monitor_gap keeps zero tolerance because it means the run itself stopped sampling. The pre-drain lineage cap moves from 25 to 35 minutes so a 15-minute window plus one restart still reaches a verdict, and the collector error message is now logged instead of being swallowed. |
||
|
|
e6dcb8b938 |
fix(editor): keep preview Add-note controls out of PDF export (#21268)
Exporting Markdown to PDF from Preview printed an Add-note + button above every block. Preview exports the .markdown-body subtree, and its per-block annotation control renders inside that subtree, so the clone-scrub pass never removed it. Mark the controls container with data-orca-export-hide at the source, add the explicit class to UI_ONLY_SELECTORS (attr-strip fallback; generic attr covers renames), and hide it in EXPORT_CSS as a belt-and-suspenders backstop. Review note bodies and open composer drafts are transient review state and are intentionally excluded from the document PDF. Fixes #21198 / STA-7761 Attribution: diagnosis and core scrub entry by @gum798 (PR #21199, closed in favor of this PR) Co-authored-by: gum798 <33922655+gum798@users.noreply.github.com> |
||
|
|
7e2ebac318 |
chore(mobile): repin the RPC recording baseline to main after #21246 (#21266)
Every step-7 squash leaves the pin guard red on main until the baseline
names a commit main contains. Repin to
|
||
|
|
6142657d7a |
refactor(mobile): checked reply readers for the tasks domain's board, runtime, search and create (step 7) (#21246)
* test(mobile): record main's agent.launch create receipt before checking it `agent.launch` is the one read site in the tasks domain's project-board, runtime, source-search and workspace create/source files with no recording family at all, so main's answer to a malformed launch receipt was undocumented and a checked reader would have had nothing to move. One family, one scenario, two goldens: `worktree.agent-launch-create` drives `createWorktreeWithNameRetry` down the `agent.launch` arm instead of `worktree.create`, which needs an `agentLaunch` argument on the existing worktree-create-retry adapter. The agent is a constant there on purpose — which agent is picked changes only the params, and the arm under test is which method the create is issued on. A separate family rather than an eighth `worktree.create-retry` scenario: `familyGoldens` drives its reply matrix over the family's FIRST scenario, so adding to that family would have recorded a pilot golden and left the launch receipt with no partitions. As its own base it gets all eleven. Recorded from a detached worktree at the pinned baseline with this branch's `rpc-recording/` and manifest copied in, per the recipe in the recorder README: `mobile/pnpm-lock.yaml` has drifted past `4b876758d3` on main, so `--record` refuses on this branch's tree even though `mobile/src` and `src/shared` are byte-identical to the pin. Thirty-four existing goldens move on `adapterSha256` and nothing else — the six families mounted through the edited adapter module. No body moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): checked reply readers for the tasks domain's board, runtime, search and create Forty-three unchecked reply readers across five files become checked zod readers, so a malformed host reply surfaces as one readable error naming the method instead of a downstream TypeError, a rendered `undefined`, or a screen left ready over garbage. Deliberately a behaviour change on malformed replies only. Five schema modules, each recording the consumer line behind every requirement and the host handler it was checked against: - `task-project-board-reply-schema.ts` — the sixteen `github.project.*` envelopes. Where a consumer reads a member off BOTH arms unguarded the schema is a union on `ok`; where it guards everything (`result.error?.message ?? '…'`, `result.labels ?? []`) it is a flat passthrough and requires only the container, because a requirement on a member the consumer already defaults would refuse a reply main rendered. - `task-runtime-reply-schema.ts` — the hydration reads. The three preference writes read `z.unknown()`: no call site interprets their body. - `task-source-search-reply-schema.ts` — the provider searches and the pasted single-item lookups. The Linear union replaces the hand reader in linear-mobile-issue-read.ts, whose own copy reached the screen unattributed. - `workspace-source-reply-schema.ts` — SSH state, agent detection, orca.yaml hooks, sparse presets and base-ref search. - `workspace-create-reply-schema.ts` — the create receipt, the launch receipt and the hosted-base union. Requirements are exactly the members a consumer reads unguarded AND a recorded golden shows the host sending. That second half is load-bearing: the recorded GitHub search row is `{ number, title }`, the recorded Linear issue is `{ id }`, the recorded project is missing `id`/`url`/`source` and the recorded sparse preset is missing `repoId`/`createdAt`/`updatedAt` — requiring what the shared types declare would have dropped rows main renders. Where the value therefore stays looser than the screen's own state type, the call site keeps one narrowing cast with that reason on it rather than a default that would fabricate state. Two enum decisions, both pinned: - `ownerType` is CLOSED with no fallback. It is echoed into the next `github.project.listViews` params, and remote-wire-compatibility.md rule 4 forbids a reply-schema fallback from shaping a param; the host's own listing handler answers `validation_error` for any other value. - `ssh` `status` is OPEN and degrades to `disconnected`, main's own answer for a state it did not receive. The readiness gate is an equality test against `connected`, so an arm this build has not heard of can never grant a create, and the record survives with its Connect affordance. - Every other host vocabulary a consumer equality-tests — the project view `layout`, the `setupRunPolicy` — stays `z.string()` for the same rule. Tri-states are preserved, not collapsed: the row detail's `reviewDecision`, a work item's `author` and the SSH record's `error` each keep explicit `null` distinct from absent, with a unit pin on each. `blank-workspace-create.test.ts` splits one `it.each` in two. The two create routes now answer a workspace-less reply differently: `agent.launch` still reports "Failed to create workspace", because its reader guards `worktreeId` itself, while `worktree.create` is named as unreadable, because the create screen reads `result.worktree.id` unguarded into the session route. Both reach the same catch; only the sentence changes. `mobile-tasks-refactor-parity.test.ts` moves four hashes and no count. Hooks hold at 350 with 28 bodies edited and no dependency array moved; statements hold at 417 and declarations at 194; `semantics` loses exactly four lines, all four string literals that lived inside the one deleted inline cast type. No method literal and no `rpc:` call signature moves. The inventory loses its five tasks lines; the boundary test stays green. Goldens are refreshed in the next commit, which is where the disclosed behaviour change is proved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin and re-record the corpus over the tasks domain's checked readers Repins `baseline` to |
||
|
|
c4917d6e74 |
fix(cloud): retry transient director admin failures in the relay monitor and preflight (#21263)
The director's /v1/admin/cell-status maps any thrown operation error onto HTTP 404, so a Cloud SQL pool connect timeout arrived at the ops tooling as "Relay admin telemetry returned 404" and killed the whole sample. Retry the admin reads that carry a transient database error, and let the live preflight spend one of its existing attempts on a thrown collector instead of failing the wave. |
||
|
|
1957437005 |
fix(relay): stop admin routes reporting a stalled database as 404 or 409 (#21264)
Every admin handler collapsed a thrown error into one status, so a two-second pool connect timeout answered POST /v1/admin/cell-status with 404. The rollout tooling never retries a 4xx, by design, so the wave failed on a database that was briefly out of reach and recovered on its own. Transient database failures now answer 503 with Retry-After, the shape the public routes and the region catalog already use. Every other error keeps the route's existing 404 or 409 mapping. |
||
|
|
8b2502fc92 |
fix(cloud): give same-cap waves ten minutes to consume gate evidence (#21259)
* fix(cloud): give same-cap waves ten minutes to consume gate evidence The live preflight rejected monitor evidence older than five minutes, but the same-cap job only reaches that step about five minutes after the monitor completes: runner queue, the gate job, and a full-branch checkout. On 2026-09-17 the first green gate in 44 attempts died at 302 s. The preflight still takes live samples, so the older baseline is safe. * docs(cloud): state the ten-minute preflight evidence bound |
||
|
|
7a1f55c52a |
fix(native-chat): give a failed Claude background task a typed row instead of an opcode (#20519)
* fix(native-chat): give a failed Claude background task a typed row instead of an opcode
A failed backgrounded command printed red rows whose visible text was the wire
opcode, and printed one failure twice. All five task lifecycle kinds are
catalogued status-chrome, but the payload sniffer in classifyProviderFrame runs
first and promotes any frame reporting a failure to the generic unknown-frame
fallback, whose sentence lookup has no key for the field Claude puts its own
sentence in. Two frames for one task therefore produced two rows, both of them
the method name.
Suppressing those frames is not the fix: when the last background task settles
the tracker flushes it and the strip unmounts, local_bash is excluded from the
subagent roster, and the status feed publishes only live tasks, so for a lone
backgrounded command the transcript row is the only report of the failure that
exists anywhere.
So the catalogue now binds: kinds a dedicated typed translator owns are named
as covered, and the generic fallback refuses to emit for them in either
direction. A new row owner keeps one durable row per task id, opened by the
announcement, revised in place by the lifecycle frames and closed by the
notification, carrying the provider's summary, error, output path, usage and a
run state. The row is written on the same dual carrier the subagent roster
uses: a frozen text twin plus a typed block, so a client without the block type
reads the sentence rather than nothing.
hasProviderError keeps its authority everywhere else, unchanged.
* fix(native-chat): keep tool attribution across a background-task row
A background task's row is a system message landing mid-turn between the
assistant's tool calls, exactly where the spawn-group roster row lands. Without
the same exemption it ended the run the following tool messages fold into, so a
tool result arriving after one stopped folding into its own assistant turn.
Also syncs the catalog with the row's one new translate key.
* fix(native-chat): harden background task rows
* fix(native-chat): settle background rows on provider end
* fix(native-chat): scope malformed task fallback text
* test(native-chat): assert only eligibility at the disposition layer
The malformed-task-frame test asserted the generic fallback resolves Claude's
`summary` field itself, which was true only while that key sat in the shared
key list. Eligibility is what this layer decides; the sentence the row leads
with is Claude's, supplied through the display-text seam and proven in the
translation test.
* fix(native-chat): gate background-task admission and scope rows per run
Admission now matches the reference on all three gates. Type is the whole gate
and MONITORS ARE NOT ADMITTED: a monitor runs for the life of the session and
has no outcome a row could report, so it never reaches the timeline. On first
admission only, the task's tool_use_id must name a tool call this session
forwarded at the TOP level — a Task spawned inside a subagent's sidechain names
an id that never reached the transcript, and a top-level row for it would claim
an invocation the user never saw. And a task that already exists and has not
finished is not re-opened: a duplicate announcement is a redelivery, not a
second run.
Rows are now keyed per RUN. A provider may reuse a task id for a distinct later
invocation, and a row keyed by the id alone overwrote the first run's transcript
history instead of leaving it standing. Generation 1 keeps the bare key, so
every row already written is unaffected.
The spawning tool call is carried on the row as parentToolUseId. Orca's journal
has no structural parent link for an item — AgentJournalItemIdentity has four
arms and none carries one — so the relationship is data on the item rather than
nesting.
A terminal frame that names NO tool still opens a row. That is a named
deviation, recorded at its call site, and the measurement behind it is in the PR.
* fix(native-chat): read the aggregate roster by membership, not a phantom status
The background-tasks payload types every entry as exactly
{task_id, task_type, description, ambient?}. It has no per-entry status, so the
state this owner derived from one was always undefined and the reopen branch it
guarded was unreachable on every real payload — proven by deriving the state
from an SDK-shaped entry and getting null.
Membership is the only liveness the payload carries: it is the whole live set
after a change, so presence means live and absence means merely "no longer
listed", never an outcome. Presence does not revive a settled row either — the
level's ordering against the start/stop edges is unspecified and it carries no
evidence of a new run, so the task's own frames stay the only thing that opens
or settles one. Only the identity fields it really sends are read, and ambient
housekeeping entries are excluded as the payload asks.
The two helpers that served the dead branch are removed, along with the test
that exercised it through a synthetic status the CLI cannot send.
* fix(native-chat): mirror reference task admission and drop the synthesis path
The forwarded-parent gate is conditional on the field being PRESENT. An
announcement naming a tool this session never forwarded is a nested child and is
refused; one naming no tool at all is admitted, because absence of the field is
not evidence of an unforwarded parent. The previous rule required the field and
so refused every tool-less task.
Terminal frames now match on task_id alone. The forwarded-parent question is
settled once, at admission, and is never re-asked on a notification or a patch.
A frame for a task that was never admitted yields no row, and a patch is folded
into the row it names rather than opening one.
That removes the synthesized-row path entirely, and with it the named deviation
it carried: the captured tool-less failure lands on a row that already exists,
because its own tool-less announcement is admitted. The dead builders go with
it.
Left deliberately stricter than the reference, and flagged rather than changed:
a terminal frame still records its task id as terminal even for a task never
admitted, so a late announcement cannot open a row for work already reported
finished. Two existing tests pin that.
* fix(native-chat): preserve background task ownership across restarts
* chore: restore pnpm-lock.yaml to origin/main
A local pnpm run rewrote the lockfile and the merge commit swept it in. The
branch changes no dependencies, so it must carry no lockfile delta at all.
* fix(claude): harden background task lifecycle
* fix(claude): bound task generation history
* fix(claude): preserve task identity after history eviction
* fix(claude): resolve background task identity after rebind
* fix(claude): isolate queued task runs
* refactor(claude): give the background-task ledgers one bounded owner
The bounded collections behind a background-task row were read out of the
class with `Reflect.get` to prove they stay capped, which the anti-slop
gate rejects. Move them into `ClaudeBackgroundTaskLedgers`, which owns
the caps beside the eviction helpers and reports a typed readonly size
view the tests assert against.
Also replace a `Reflect.get` in the mobile recording proxy with typed
property access.
* fix(native-chat): report a background task failure the transcript never admitted
A terminal `task_notification` for a task no announcement ever admitted rendered
nothing at all. The typed row owner declined the row because its map held no
entry for the id, and reported the frame as handled — which is exactly what
tells the generic provider-frame fallback to stay quiet. Both surfaces declined
the same frame, so a real failed background task was dropped on the floor.
A terminal frame is self-sufficient: it states an outcome, and it carries the
summary, status, error, output path and usage that outcome needs. It now opens
its own row from those fields, with the summary as the label and `unknown` as
the kind when the frame names no task type. The row map enriches a terminal
frame; it never gates one. Every terminal status writes one, not failures alone,
so there is one rule here rather than a third behaviour for failures.
The deliberate hand-offs still win, because they are recorded rather than
implied: ambient, subagent and foreground tasks are claimed in the foreign-owner
ledger the notification path already checks first. A Task spawned inside a
subagent's sidechain now records its refusal there too, under `sidechain`,
instead of leaving no trace and reading as a task nothing ever decided about. A
capacity-refused task whose outcome the generic fallback already printed records
`fallback` the same way, so a redelivery neither prints twice nor mints the row
capacity refused.
The anti-resurrection guard stays and stays scoped to announcements: a late
`task_started` cannot reopen work already reported finished. The restart rule is
now stated once instead of twice — a different parent alias is the provider's
restart signal only when BOTH runs name their parent, which is what the terminal
ledger already required of an evicted row and what the live row now requires too.
* test(native-chat): pin that an orphan task row reopens the provider's turn
* fix(native-chat): stop an orphan task row drawing its sentence twice
An orphan row took its header label from the notification's summary, which
also renders as the row's sentence, so the same string appeared in both slots
of the same collapsed row. The label now stays empty and the header falls back
to the task kind, leaving the sentence to carry the provider's words.
* fix(native-chat): keep Claude task outcomes owned through capacity and redelivery
* fix(native-chat): keep settled overflow notifications from reopening a turn
* fix(native-chat): retain Claude task rows through journal pressure
|
||
|
|
abc8386e14 |
fix(mobile): name a create's launch so a lost reply cannot build two workspaces (#21137)
* fix(mobile): name a create's launch so a lost reply cannot build two workspaces `agent.launch` admits a caller-supplied `operationId` through a durable ledger, so exactly one execution happens and every replay returns the recorded answer. No client sent one, so the machinery was inert and the original defect was still live: mobile retries a lost create by design, and a retried launch built a second agent in a second workspace. Mobile now mints an operation id per create candidate and sends it whenever the host advertises `agent.launch.replay.v1`. The invariant is one operation per candidate. `computeAgentLaunchFingerprint` folds `target` whole, so the workspace name is inside the fingerprint; carrying one id across a name-collision bump would meet its own row under a differing fingerprint and refuse `agent_session_operation_conflict`, failing the create outright on the second candidate. The id is therefore minted beside `clientMutationId` at the top of each loop iteration and reused verbatim by every retry arm inside that candidate — never re-minted, since a new id is a new operation. Admission runs ahead of every effect, so `_invalid` / `_expired` / `_capacity` prove nothing launched: those re-send the same candidate unnamed rather than let bookkeeping fail a create the host would have performed. `_unknown` is the one refusal that is not safe to re-send, and it surfaces. Also corrects a false comment: the legacy path caches the whole launch under `clientMutationId`, so inside its 60s window a replay adds neither a workspace nor a surface, and outside it adds both — not "a second surface, never a second workspace". * fix(mobile): preserve launch identity on refusals * fix(mobile): use launch receipts to authorize replay * test: move mobile launch replay coverage outside node project * fix(mobile): enforce replay-safe launch delivery at the host * test: run mobile launch contracts in mobile checks * test: cover mobile launch contract workflow dependencies |
||
|
|
6b426a8623 |
test(mobile): repin the RPC recording corpus to main after #21176 (#21254)
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3e32b83522 |
refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7) (#21176)
* refactor(mobile): checked reply readers for notifications, components, terminal, transport, home, worktree and browser (step 7)
Twenty-one unchecked reply readers across thirteen files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`, or
a card left "proven" over a reply that carried no rows. Deliberately a behaviour
change on malformed replies only.
What each domain required, and why it required no more:
- notifications (5 readers). All four call sites read the payload through `?.`,
so every schema is nullish at the top level and no member is required. The
test-push `reason` and the register `reason` become closed enums, because the
two comparisons against them are the whole of what they decide and an arm this
build does not know took the generic copy on main too. The stream unsubscribe
and the unregister read no body at all.
- components (4). `repo.hooks` requires `source` and nothing else: the drawer
assigns it straight into `SetupHookDetails.source`, whose type is
`string | null`, with no guard in between — nullable so the "no hooks file"
answer keeps its explicit null. `setupTrust` is nullable as well as optional
because the `components-setup-ask` fixture sends an explicit null, and
salvaging that would move a `normal` golden. `ui.get`'s trust record salvages
per repo, so one unreadable repo cannot cost the others their approvals. The
Codex redeem reply stays `z.unknown()`: `decodeResetResult` is a real
scope-and-snapshot validator and splitting it would give one reply two refusal
rules.
- terminal (4). The send verdict and the viewport pair keep main's exact
`=== true` projections. `terminalSendAcceptedSchema` moves here from the
session domain, which now re-exports it: terminal is the lower layer and two
identical copies could drift on what "delivered" means.
`terminal-send-rpc-response.ts` is deleted, its projection now being the
schema's.
- transport (3). `status.get` declares its five members and requires the object;
the three callers disagree about what an unreadable status means, so each keeps
its own verdict behind a named reader — the gate wants the failure, and the
probe and the pairing race must not have it, because both call `interpret`
inside a `.then` fulfilment handler where a throw becomes a detached rejection.
`capabilities` salvages whole rather than per element, which is main's own rule
and what `transport-capability-probe-non-string-capabilities-drop` records.
The two pairing readers are the shared credential contract itself, moved off
the four call sites that each ran `.parse()` on the interpreted value; its
`.strict()` is main's shipped rule for that released surface, not a new one.
- home (2), worktree (2), browser (1). The stats row is checked as an object and
nothing more, `totalHomeStats` being the reader that says so itself; its
per-host slot is now typed as the wire row it holds rather than as the computed
total. `worktree.ps` cannot require `worktrees`: the host answers a union whose
unchanged arm carries `{ unchanged, snapshotId }` and no rows. The twelve
browser commands read no body; `browser.goto`'s settled URL stays nullish
because `navigateToAddress` is inline in `MobileBrowserPane.tsx`, which no
adapter mounts, and a move there would ship unevidenced.
Three fixtures were wrong and are corrected, each disclosed rather than worked
around: the runtime-context test kept a content hash directly under a repo key,
which is not a shape `ui.get` sends; and two snapshot-client tests ran their
reply list dry and handed `fetch` an absent result while claiming to model a
transport failure.
`push-test-envelope` is re-anchored at the same defect's new home, the cast
having been deleted. The boundary test's offender floor comes down from 20 to 10
with the list, which is what its own comment says it is for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the corpus and re-record step 7's checked reply readers
`baseline` moves to this branch's product commit, which is what `--record`
compares the fenced tree against, and every one of the 758 goldens is
re-recorded from it. The repin is what rewrites the `baseline` header on all of
them; nothing else about the corpus moves except the bodies disclosed below.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mutate the workspace catalog's reader back to unchecked
The step-7 defect evidence needs a scenario whose reply is the one the change
moves. Every pilot scenario in the catalog family scripts a well-formed reply, so
a mutant that only changes how a *malformed* reply reads has nowhere to diverge —
which is why the pilot's own suite passed against an unchecked catalog reader
while its matrix golden failed.
`worktree-catalog-snapshot-unreadable` scripts `worktree.ps` answering
`{ ok: true }` with no result at all, which is what `result-absent` drives at the
matrix site, and records the fetch rejecting with `RpcIncompatibleReplyError`.
`worktree-catalog-unchecked-reader` then swaps the operation's reader for one that
answers `compatible: true` for every payload — main's reader, in one line — and
the recording moves back to a fulfilled fetch carrying
`admission: { kind: 'invalid' }`, which is the answer that let a broken catalog
render as an empty host (STA-3123).
One golden added and none moved: the manifest sits outside the fenced paths, the
family's matrix base is still `worktree-catalog-snapshot`, and the mutation
registry is not part of `recorderSha256`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the push-test reason arms the closed enum constrains
`pushDeliveryTestResultSchema.reason` closes over the four arms of the host's
`MobilePushTestResult` (src/shared/mobile-push-contract.ts:99), but no scenario
carried the member, so the corpus could not have caught a wrong vocabulary.
Three scenarios on the existing display-test mount carry it now: the two arms
the screen branches on and one arm no build knows.
Each golden was recorded first at the main pin
|
||
|
|
a3046cd27b |
fix(relay): treat database pool connect failures as transient, not director faults (#21243)
* fix(relay): treat pool connect failures as transient, not director faults pg-pool raises connection-acquire failures as a plain Error with no SQLSTATE, so the transient classifier matched only one of the three messages it can produce. The other two reached the routes unclassified and became HTTP 500s, which is what the rollout safety gate counts. The acquire boundary now marks the errors it produces, so "Connection terminated unexpectedly" counts as transient when the socket died during the handshake and stays a hard failure mid-statement, where a retry could repeat a commit whose outcome is unknown. /v1/regions and /v1/admin/evacuation-status gain the transient handling /v1/assign and /v1/resolve already had. * fix(relay): mirror the pool-connect verdict in failure diagnostics The query-failure event's connectionTimeout boolean matched one of the two messages connectionTimeoutMillis can produce, so the 210 dialling timeouts in the last day logged as false and were invisible to the field meant to find them. The pool-connect vocabulary now lives beside the acquire boundary that owns it, and both the router's classifier and the diagnostics read it from there, so the two cannot drift. The event also carries the routing verdict the caller already computed, making "how much of this burst reached users as a 500" one field. * fix(relay): null-safe transient classification and honest transient docs The classifier now runs inside the query catch, where a thrown null or undefined would have turned a database failure into a TypeError that buried it. The diagnostics doc claimed transient maps to a 503 or a 500. Sweeps, startup reconciliation, and admin routes that answer 409 all emit the same event, so counting the false ones over-states user-facing hard failures. |
||
|
|
7184b1dc5b |
fix(relay-ops): recalibrate the pre-roll monitor gate to chronic production baselines (#21241)
* fix(relay-ops): let the pre-roll gate ride out chronic production noise The 15-minute pre-drain dry-run froze 39 times out of 39 on conditions that have nothing to do with the roll it gates: - A cell probe is one HTTP round trip from one runner. When the Asia cells' readiness SQL probe times out behind a saturated pool, the load balancer answers "no healthy upstream" for ~30 s and the gate froze on a single sample. Cell probe signals now need more than cellProbeToleranceSamples consecutive failing samples to freeze; absorbed blips are recorded in the state artifact. Director and auth probes keep zero tolerance. - directorErrors 3 -> 15. Measured non-503 5xx per rolling five minutes over the 24 h to 2026-09-17: p90 3 / p95 5 / p99 9 / max 52. The old bar sat on the p90 and froze 29% of gates. - cloudSqlBackends 250 -> 320. Measured latest-sum over the same 24 h: p95 212 / p99 262 / max 282. The old bar sat under the observed peak and froze 22% of gates. Failure codes are unchanged so downstream matchers keep working, and the trusted evidence scripts are untouched. * fix(relay-ops): key probe tolerance by cell and extend it to live preflight Three review findings on the cell-probe tolerance: - The streak was keyed per signal, so a cell alternating between slow (latency over bar) and down (health/ready 0) held every individual streak at one and never reached the tolerance. A continuously unhealthy cell passed the gate. The streak is now keyed by cell id, so one cell's health, ready and latency readings share it. - The live preflight runs one sample before every mutating wave and retried only on freshness codes, so the same Asia blip could still fail a wave there. It now re-samples per-cell probe breaches on the same tolerance, spaced the existing interval. Director and auth probes still fail the wave on the first bad sample, as does any non-probe threshold. - docs/relay-incident-monitor.md still stated the old bars. Updated the threshold table, the 400-connection ceiling text, and the superseded 2026-08-26 and 2026-09-12 entries, and added a dated 2026-09-17 recalibration entry. Also pins the resumed-state case: a state file carrying a full streak now has a test proving it freezes on the next bad sample. Trusted evidence scripts remain untouched. |
||
|
|
0d23ea6e68 | Update README downloads badge | ||
|
|
de15227a1d |
feat(terminal): search match count + Cmd+F focus parity (#9035)
* feat(terminal): show search match count and keep Cmd+F from closing search Bring the terminal search bar to parity with the editor find bars: - Show a live match indicator (0/0, current/total, "No results", or <count>+ past the highlight limit) driven by the xterm SearchAddon onDidChangeResults event. - A repeat Cmd+F while the search is open now re-focuses and selects the query instead of toggling the panel closed; Esc remains the close path. Adds unit coverage for the indicator states and the toggle decision. * Use auto-generated localization key for TerminalSearch no-results (#9035) Replace the hand-written "noResults" i18n key with the SHA1-based auto key (auto.components.TerminalSearch.10e039b591) to match the repo's auto-keying convention, and sync the key across all locale catalogs. Addresses CodeRabbit review feedback. * test(terminal): cover search dispatch after keyboard module split * fix(terminal): refocus search from its input and verify real matches * test(terminal): use portable echo commands for search proof * refactor(terminal): keep search subscription and cleanup together --------- Co-authored-by: Neil <neil@stably.ai> |
||
|
|
96eb97aad6 |
fix(runtime): split the host-contact epoch out of the connection generation (#20359)
* fix(runtime): split the host-contact epoch out of the connection generation `connectionGeneration` carried two meanings and one reader was always wrong. holding the session mirror through an outage leaves its subscriptions stranded and that edge was the only thing left to revive them. But the same value is the mirror's cache key -- use-runtime-session-mirror-environment-key.ts keys the subscription effect on it, every published frame is stamped with it, and web-session-terminal-retirement-proof-ledger.ts drops retained proofs when it moves. So the bump #20085 needed as a resubscribe signal re-keyed and rebuilt the mirror after any brief flap, which is the #19647 symptom #19873/#20059 fix. Measured first: with the reconnect bump deleted, an ended stream followed by recovery issues zero resubscribes, and the mirror's subscribe call registers no `onClose`, so main's terminal close is dropped. #20085's claim is true -- the subscription really is dead after recovery -- so the trigger has to exist. It just must not be the cache key. Give each meaning its own value: - `connectionGeneration` returns to identity only: a new runtime session, a re-pair, an explicit clear. A same-runtime return no longer moves it, so no stamp, fence or retained proof is invalidated by a flap. - `hostContactEpoch` counts "the host answered again after we lost contact". It lives on the store entry and is read only as a dependency of the two subscription effects in use-web-session-tabs-sync.ts -- never passed to an installer, never part of `environmentKey`, so it cannot become a stamp. `useRuntimeSessionMirrorEnvironmentKey` becomes `useRuntimeSessionMirrorEnvironmentKeys`, returning `environmentKey` (identity) and `resubscribeSignal` (the epoch edge) from the one target scan, so the hot ownership scan is not doubled. Each direction is pinned by its own test: removing the resubscribe dependency fails only 'reinstalls both session-tabs subscriptions when the host answers again'; restoring the reconnect bump fails only the two key-stability tests. * test(runtime): pin the mirror hydration verdict across a host flap The generation tests assert the key string; this asserts what the user feels. The mirror's hydration verdict is stamped with the connection generation, so any bump discards it and every mirrored pane re-parks -- the tab-list rebuild. Held across an unverifiable probe, still discarded when the runtime id actually moved. * test(runtime): build real host statuses instead of casting partials |
||
|
|
851befa929 |
fix(runtime): hold parking and transport reads through an unverifiable probe (#20096)
* fix(runtime): hold parking and transport reads through an unverifiable probe Three remaining sites where a non-verified status probe was read as evidence the host is gone, per docs/reference/ssh-execution-boundary.md. - runtime-status-refresh published its own copy of the "null the status unless verified" rule, then handed the snapshot to applyRuntimeHostStatusSnapshot, which re-derived it. The copy was dead but free to drift; the snapshot branch now calls applyRuntimeHostStatusSnapshot directly, leaving one implementation. - The paired-parking capability reads treated a nulled status as "host cannot park", so a transient probe failure unparked live paired terminals and dropped a parked session's reattach in favour of a fresh cold restore. Both now read lastVerifiedRuntimeStatus; a capability is a fact about the host's build. - runtimeHostConnectionStateForEntry handled transport 'disconnected' and 'ready' and let 'connecting'/'unknown' fall through to the default 'disconnected' — reporting a host mid-handshake as down, a worse verdict than an actually disconnected transport gets. It now passes the snapshot's transport through. * fix(runtime): keep a revoked host out of the parking promise Holding a capability through an unverifiable probe is right; holding it through the host's own refusal is not. `blocked` (auth rejected, protocol mismatch) stops every retry for good, and parking trades the client's only copy of the scrollback for a host-side restore that can then never happen -- the destructive direction. `isRuntimeHostContactRevoked` names that one terminal verdict once, and the connection-state derivation now reads it too so there is a single definition. Also narrows the transport hint to 'connecting'. 'unknown' means no transport was ever attempted, which is the permanent state of an unreachable paired host: as 'checking' its row lost its Connect action and the status bar read "connecting" for the whole session. * test(runtime): pin the parking gate against over-firing on a flap |
||
|
|
182ff17141 |
fix(runtime): hold four more host reads through an unverifiable probe (#20095)
* fix(runtime): hold the session mirror through an unverifiable probe Two derivations read the same host state and reached opposite verdicts, and the destructive one won. When a status probe came back unverifiable over a still-ready transport, runtimeHostConnectionStateForEntry called the host 'runtime-unavailable' (connected) while getReachableRuntimeSessionMirrorTargets dropped it, tearing down and cold-rebuilding the session-tab mirror while the host's flows were still delivering. The root cause is that applyRuntimeHostStatusSnapshot nulls entry.status for any non-verified probe while the snapshot retains the runtime identity. The connection-state reader consults the snapshot; the mirror-target reader did not. Give both readers one answer: - lastVerifiedRuntimeStatus() in shared/runtime-host-status.ts is now the single definition of "the last identity the host answered with". runtime-status.ts already had this inline as previousVerifiedStatus and now calls it. - The mirror-target reader asks the shared connection verdict instead of entry.status, gated on isDisconnectedRuntimeHostState -- only the one exit verdict earns a destructive read, per docs/reference/ssh-execution-boundary.md. 'checking' and 'reconnecting' are unverifiable, not evidence of an exit. Holding the target through the outage would strand the mirror on its own: the subscription is installed by the effect in use-web-session-tabs-sync.ts keyed on useRuntimeSessionMirrorEnvironmentKey(), a stream 'end' frame is dropped without resubscribing, and the parking layer retries only a rejected subscribe call. The teardown was the recovery. So regaining contact now advances the connection epoch, giving recovery its own "the host is back" trigger rather than leaving the mirror to be restored as a side effect of having been destroyed. The connection epoch is not the runtime session: a same-runtime return fires no restart hook, no provider session bump, and no toast. * test(runtime): drop the redundant status casts the new casting gate rejects * fix(runtime): hold four more host reads through an unverifiable probe Siblings of the session-mirror defect fixed in #20085. runtime-status-snapshot nulls `entry.status` for any non-verified probe while the snapshot retains the host's identity, so a host with a ready transport that is still delivering reads as gone to anything gating on `entry.status != null`. - client-event subscription selection dropped the stream for such a host, and its disconnect edge bumped the SSH generation, rebuilding even the active host's subscription - the web client's active session-tabs stream tore down and cold-rebuilt, twice per blip - landing preflight discarded its whole result - runtime-aware SSH selectors blanked mirrored target rows Each now reads the shared verdict, isConnectedRuntimeHostState of runtimeHostConnectionStateForEntry, or lastVerifiedRuntimeStatus where the read is host identity rather than reachability. No new predicate: every "genuinely gone" case is byte-identical, so nothing gains a retry loop. * test(runtime): build real host statuses instead of casting partials |
||
|
|
67dda9affe |
fix(runtime): hold the session mirror through an unverifiable probe (#20085)
* fix(runtime): hold the session mirror through an unverifiable probe Two derivations read the same host state and reached opposite verdicts, and the destructive one won. When a status probe came back unverifiable over a still-ready transport, runtimeHostConnectionStateForEntry called the host 'runtime-unavailable' (connected) while getReachableRuntimeSessionMirrorTargets dropped it, tearing down and cold-rebuilding the session-tab mirror while the host's flows were still delivering. The root cause is that applyRuntimeHostStatusSnapshot nulls entry.status for any non-verified probe while the snapshot retains the runtime identity. The connection-state reader consults the snapshot; the mirror-target reader did not. Give both readers one answer: - lastVerifiedRuntimeStatus() in shared/runtime-host-status.ts is now the single definition of "the last identity the host answered with". runtime-status.ts already had this inline as previousVerifiedStatus and now calls it. - The mirror-target reader asks the shared connection verdict instead of entry.status, gated on isDisconnectedRuntimeHostState -- only the one exit verdict earns a destructive read, per docs/reference/ssh-execution-boundary.md. 'checking' and 'reconnecting' are unverifiable, not evidence of an exit. Holding the target through the outage would strand the mirror on its own: the subscription is installed by the effect in use-web-session-tabs-sync.ts keyed on useRuntimeSessionMirrorEnvironmentKey(), a stream 'end' frame is dropped without resubscribing, and the parking layer retries only a rejected subscribe call. The teardown was the recovery. So regaining contact now advances the connection epoch, giving recovery its own "the host is back" trigger rather than leaving the mirror to be restored as a side effect of having been destroyed. The connection epoch is not the runtime session: a same-runtime return fires no restart hook, no provider session bump, and no toast. * test(runtime): drop the redundant status casts the new casting gate rejects |
||
|
|
25dd70e611 |
Test: target question card title by testid instead of text (#21153)
* test: target question card title by testid instead of text Add data-testid to NativeChatQuestionCard's title element and update the e2e test to query by testid with a text filter. The transcript row also renders the question text, so the previous test could match either location, causing flaky results. Gating on the card's own title node ensures the assertion verifies the card is actually rendered. Fixes #20724 * test(browser-history): budget the fastest sample, not p95 The prepare/match budget assertions measure wall clock inside a vitest worker that shares cores with the rest of the shard, so a slow sample records a preemption rather than the matcher. CI shard 6/8 measured a p95 of 3.57 ms against the 2 ms ceiling while the same test passes in isolation; #18788 already records this file failing the same way. Assert the fastest sample instead, matching the estimator the palette matcher budget already uses for the same reason. Ceilings stay at 2 ms. Measured locally (20 samples per batch): the fastest sample moved 0.05 ms -> 0.08 ms between idle and a 3,374-file parallel run, while p95 of those same batches swung 0.09 ms -> 0.50 ms. * test: target question card title by testid instead of text Add data-testid to NativeChatQuestionCard's title element and update the e2e test to query by testid with a text filter. The transcript row also renders the question text, so the previous test could match either location, causing flaky results. Gating on the card's own title node ensures the assertion verifies the card is actually rendered. Fixes #20724 |
||
|
|
779667c1e7 | refactor(runtime): declare the host-status entry once instead of per consumer (#20262) | ||
|
|
560c42e1d1 |
fix(cloud): pin the asia cell database pool in the same-cap plan validator (#21171)
* fix(cloud): pin the asia cell database pool in the same-cap plan validator Raising `database_pool_max` from 10 to 16 for production-gce-c27, c28 and c29 made every same-cap roll of those three cells fail closed at plan validation. The cell startup template emits `ORCA_RELAY_DATABASE_POOL_MAX` only for a cell whose region differs from the root region or whose pool is off the default, so the asia cells carry that line while the us-central1 cells do not. The plan validator requires the before and after startup scripts to normalize to the same text, masking only the lines it independently pins to a reviewed value. The pool line was neither masked nor pinned, so the live template's `'10'` and the plan's `'16'` were read as unreviewed drift. The validator gains an optional `--database-pool-max`, accepted in `same-cap-cell` mode alone. When it is supplied the after-script must contain exactly that pool line and the line is masked from the equality check; when it is not supplied the after-script must contain no pool line at all. Masking without the pin would have removed the guard rather than moved it. The same-cap job resolves the expected pool next to the hard cap, cross-checks it against the committed `relay_gce_cells` map (asserting the default 10 for the us-central1 cells), and passes the flag to both validator invocations only for the cells that emit the line. * test(cloud): require the pool pin for a line the live template already carries |
||
|
|
f949d5fcc4 |
ci(mobile): fail CI when the RPC recording pin leaves main's history or the corpus does not reproduce (#21156)
* test(mobile): fail CI when the RPC recording pin leaves main's history `mobile/rpc-foundation/pilot-scenarios.json` carries the commit every golden claims it was recorded from, and `--record` refuses on any other tree. A behaviour-change branch pins its own last fenced commit, which stops being reachable the moment the branch squash-merges: nobody can record on main again until a hand-made repin lands, and until now only a human noticed. #21123 was that, and so was the repin after #20954. `scripts/rpc-recording-pin-guard.mts ancestry` fails when the pin is not an ancestor of the commit under test, and prints the repin recipe. It refuses to answer on a shallow clone rather than trusting grafted history, so the job checks out with `fetch-depth: 0`. Ordinary product drift past a reachable pin is not a failure. `reproduce` makes the other claim the corpus header makes, which the recording suites do not: they replay the goldens against the CURRENT tree, so a golden recorded somewhere other than the pin -- a merge that auto-merged golden JSON, a refresh copied back from a scratch directory -- passes them and is what the header exists to deny. It checks the pin out detached, lays this tree's recorder and manifest over it, and lets the same suites compare in place, so the comparison is `compareGolden` with lockfile and platform masked as ever. It runs unconditionally on a push to main, which has no `verify` job and is where a squash lands a spliced corpus. On a pull request it runs only when the corpus, the manifest or the recorder moved: nothing else can move the verdict away from the one the base commit published, and `verify` replays the corpus against the branch tree meanwhile. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): judge the recording pin against the tree it was read from Round-1 review of the pin guard. The pull_request ancestry check read the pin out of the merge preview and judged it against the branch head. Those differ whenever main repins after the branch point, so ordinary stale branches failed, and the instruction told the author to repin to their own head -- which creates the unreachable pin the guard exists to catch. Judge the checked-out tree instead. `git worktree prune` in the reproduce teardown was repository-wide. This git directory is shared by every worktree on the machine (611 registered here), so it could deregister an unrelated one whose directory was momentarily missing. `worktree remove --force` alone is enough; a failure to remove is now reported rather than papered over. Also: the concurrency group is per commit on main, because GitHub cancels a pending run in a group whatever `cancel-in-progress` says; the skip gate fails closed when a provenance path stops matching instead of skipping forever; the census-boundary comment states the rule the code uses; and five exports with no consumer are now module-private. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): let an untracked golden and the guard itself buy a reproduction Two bot findings on the skip gate. `git diff` sees tracked paths only, but the reproduction's overlay copy and its census both read the corpus directory as it sits on disk, so an untracked golden or manifest is input to the verdict and used to skip the run that would judge it. Enumerate untracked entries under the provenance paths the way the recorder already does, and run rather than skip: an unjudged local addition is the case the reproduction exists for. The guard script is now a provenance path of its own, so a change to it re-runs the reproduction it implements. Left alone deliberately: run-process.ts and the workflow's `paths:` scope over src/shared, which is a pre-existing gap for the whole mobile workflow rather than this job's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refuse to reproduce when the suite list has drifted from the files Round-2 review. The suite names reach vitest as positional filename filters, and vitest exits 0 when only some of them match. A renamed census suite therefore dropped out of the reproduction silently and the guard still printed that the corpus reproduces: three files and 761 tests instead of four and 762, exit 0. Resolve every name under the recorder overlay before spawning, and throw naming the drifted entry. The unit case walks the list and omits each name in turn, so no single rename can slip past it. This is the same fail-open shape as the renamed-pathspec finding. Also: pass an explicit directory type to `symlink`, since Windows needs one and a junction needs no privilege where a real symlink does; and build the throwaway test repositories with `symbolic-ref` rather than `--initial-branch`, which needs git 2.28 against a declared baseline of 2.25. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
229dd62cab |
test(mobile): repin the RPC recording corpus to main after #21169 (#21173)
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
73b33302b9 |
Show the Source Control AI CLI arguments box only where it actually works (#21149)
* Show the Source Control AI CLI arguments field only where it applies * Fix Source Control arguments on remote launches |
||
|
|
01a1b6b024 |
refactor(mobile): checked reply readers for the tasks item and list domain (step 7) (#21169)
* refactor(mobile): checked reply readers for the tasks item and list domain (step 7)
Thirty-eight unchecked reply readers across four tasks files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`,
or a sheet left ready over garbage. Deliberately a behaviour change on malformed
replies only; nothing on the wire moves.
mobile-task-item-state-operations.ts 17
mobile-task-item-detail-operations.ts 8
mobile-task-item-comment-operations.ts 7
mobile-task-list-operations.ts 6
Two rules decide every schema, and both are stated in
task-provider-entity-reply-schema.ts:
1. A member is required only where a tasks consumer reads it with no guard.
Everything reached through `?.`, `??` or a `typeof` test stays optional,
because a reply without it rendered the same fallback then and now.
2. No member is required that the site's own recorded `normal` reply lacks. The
corpus is the only evidence of what a host really sends at each site, and
requiring a member absent from that control would turn a good reply into an
incompatible one.
Rule 2 holds two schemas at the container: `github.prFileContents`, whose
recorded reply is `{ oldContent, newContent, truncated }` where
`getPRFileContents` returns `{ original, modified, ... }`, and `gitlab.todos`,
whose recorded row is not a `GitLabTodo` and whose `normal` partition therefore
records main crashing in `actionName.replace`. Both still gain their container,
which is what names a reply that is not an object or not a list. Correcting
those two scenarios is the follow-up that unlocks narrowing the rows.
Nine writes share one envelope reader and five comment writes share another:
`ok === false` and `error` are one host convention across them, and no input
would make two of them want different answers. The acceptance, the name and the
recorded family stay per operation. Three readers are reused rather than
re-declared — the session domain's boolean confirmation for `setPRFileViewed`
and `resolveReviewThread`, and its salvaged-member combinators throughout.
Three call-site shape tests the reader now answers for are deleted: both
`Array.isArray(payload)` guards on the checks read and the
`typeof count === 'number'` fallback on the item count. `GitHubPRFileContents`
is widened to optional members, which is what the reader can promise, and
`buildGitHubPrFileDiffPreview` takes the widened sides — `splitContentLines`
already treated a falsy side as no content, so no runtime behaviour moves.
The tasks source-parity hashes are refreshed: hook, statement, declaration and
render-token counts are unchanged, the render-token hash does not move at all,
and `semantics` is a pure deletion of ten lines.
Inventory: 137 unchecked readers over 30 files becomes 99 over 26.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the RPC recording corpus and re-record the tasks reply deltas
`baseline` moves to
|
||
|
|
e42f7c00bd |
feat(native-chat): render a proposed plan as a plan, not a generic approval (#21090)
* feat(native-chat): render a proposed plan as a plan, not a generic approval A finished plan arrives as an ExitPlanMode tool call. With no handling for it, the generic approval path serialized the tool input, so a plan appeared as thousands of characters of escaped JSON. A plan is content to read, not a privilege to grant. Classify the plan in the permission callback and carry it as a typed subject on the approval item, keeping the existing approval kind so the prompt still reaches every consumer. Mobile filters pending approvals on that kind, so introducing a new one would have made the prompt vanish there silently. Classification runs before registration, so a future permission-mode short-circuit cannot swallow a plan proposal. The assistant tool-use stream is a second ingress and is pinned by its own test, because neither path can be assumed to fire on its own. Rather than adding a second card, the plan renders inside the approval card's existing bounded content region. It inherits the height cap, the scrolling, the keyboard focus and the pinned action row that region already provides, and a typed plan replaces the raw detail instead of rendering both. Buttons read as plan decisions. Mobile renders the same subject through its own markdown component in the same region. * fix(native-chat): preserve plan review semantics * fix(native-chat): keep plan approval one-turn |
||
|
|
5947d6b269 |
infra(relay): raise asia-east2 cell pools to 16 and record the measured connection ceiling (#21163)
* infra(relay): raise asia-east2 cell pools to 16 and retire four idle cells The three asia-east2 cells sit 176 ms from the Cloud SQL instance in us-central1. Server-side statement time there is 0.2 ms, so a pool slot is held by the round trip, not by the query. At a pool of 10 they measured 94-156 waiters and 2 s waits, and client accepts ran a ~4 s p95 against 222-646 ms in us-central1. Raising those three pools to 16 is the agreed first step; every other cell stays at 10. c4 and c5 join the committed fence set. Both are existing-only capacity the admission selector can never place on again, they carried ~1 connection each on 40-day-old images, and each still holds 10 Postgres connections. The fence set is the prerequisite the fence-source workflow confirms before it drains and attests a cell; it is not itself the resize. c17 and c18 are not fenced here. They are migration-only, and the runbook requires retire-migration-cell to move a migration-only cell to existing-only through a generation-bound selector CAS before it can be fenced. Terraform cannot express that step. The Cloud SQL consumer contract carried two stale numbers: auth at 2 instances when production has run a cap of 20 since 2026-09-04, and a 400-connection ceiling when the live instance reports 500. Both are corrected, and the budget now asserts its headroom in two named gates instead of one aggregate boolean. Those gates fail: auth alone accounts for 200 configured connections and a 215-connection rollout overlap, so the operating maximum is 713 against a usable ceiling of 490. Nothing here caused that, and no pool was lowered to hide it. * infra(relay): move the Cloud SQL contract correction out of this branch The contract correction (auth at its real 20-instance cap, the measured 500-connection ceiling) makes the budget gate fail for reasons that have nothing to do with asia pools or fenced cells, and it held this branch red. It moves to its own branch where the failure is the subject. production-cloud-sql-app-consumers.json returns to main unchanged. The budget test keeps main's single gate and only repins the cell figure that this branch genuinely moves: 230 -> 228, being +18 for three asia pools at 16 and -20 for fencing c4 and c5. Against main's 400-connection model that leaves an operating maximum of 383 under a usable ceiling of 390. * infra(relay): move the c4/c5 fence entries out of this branch Terraform now sets a cell's MIG target size directly from relay_gce_fenced_cells (relay-gce-cells.tf); the lifecycle ignore that used to protect operational target_size drift is gone. So a fence entry sitting on main ahead of its fence-source run is a standing instruction that any apply reaching that cell may execute without the documented drain and attestation. Keeping the entry in the same merge as an unrelated pool change widens that blast radius for no reason. The two entries move to their own branch, to be merged immediately before fence-source runs for c4 and then c5. This branch keeps the multi-line reflow of the list, which makes that later diff two added lines instead of a rewritten one. The cell figure in the budget test follows: 230 + 18 for the three asia-east2 pools at 16, with no fenced-cell subtraction. That is 403 operating against a usable ceiling of 390, so the headroom gate now fails by 13. It fails against a ceiling of 400 that is itself wrong; the instance reports 500. See the PR body. * infra(cloud-sql): record the measured 500-connection ceiling The budget's usable ceiling came from maxConnections: 400, described as the tier default. It is a tier default, since no max_connections flag is set, but the instance does not report 400. SHOW max_connections on it returns 500, measured 2026-09-16. On main the model sat at 385 against a usable ceiling of 390, five connections of margin, so raising the three asia-east2 pools by 18 failed the gate by 13 against a ceiling that was never checked. Against the measured one it is 403 against 490, clearing by 87. Only the ceiling and its source note change here. auth stays recorded at 2 instances, which is also wrong; PR #21165 corrects it, and with the true auth figure the budget is over by 225 for reasons that have nothing to do with these pools. * test(cloud): state the cell pool arithmetic literally in the budget pin comment |
||
|
|
68ea3b92e3 |
fix(native-chat): stop a collapsed run claiming success when a tool call failed (#21151)
* fix(native-chat): stop a collapsed run claiming success when a tool call failed A settled activity group drew its completion mark whenever no call in it was `running`. That is not a success test: a tool call is `running`, `completed` or `failed`, so a run whose call failed had nothing running, took the mark, and asserted success over a failure the reader could only find by expanding the run. Success is now stated rather than inferred. `nativeChatToolRunSucceeded` grants the mark only to a run that is settled, has nothing still running, and has no failed call — a call's own `failed` verdict or an error result, the same composite test the task-list, edit-card and ask-row readers already use. A call with no lifecycle state is neither, so legacy transcripts still settle. A collapsed run that did contain failures now says so in the header, as a quiet `N failed` in the header's own mono type with a spoken `Failed tool calls: N`. Text only: a tool error is routine work, so no destructive tint and no swapped glyph. The count is taken over every call in the run, not the latest. * fix(native-chat): count failed tool calls without result mispairing |