* refactor(terminal): move the topology revision and leaf-lookup helpers into terminal-topology
advanceTerminalTopologyRevision, findTerminalTabIdForLeaf and
hasHostAuthoritativeTerminalMembership move verbatim from the renderer-save
membership rebase into persistence/terminal-topology, the home of the commit
boundary. Importers are repointed; no behavior change.
* test(terminal): test-only guard for topology writes outside the commit boundary
The three session sinks now publish through one commitWorkspaceSessionPartition
helper, which hands the prior and published partition to the topology write
guard. Production never arms the guard, so each sink pays one global lookup.
The unit suite arms it in report mode: a sink write that changes class-(a)
topology (membership, root, bindings, titles, incarnations, sleeping records,
remote session ids, tombstones, default-applied, revision) outside a commit
scope is attributed to its writer's file by stack, and fails the test unless
the writer is on the unrouted-writers allowlist that later routing PRs shrink.
Renderer saves and test seeding are exempt. Deep-freeze is available but stays
off suite-wide until the in-place writers return new sessions.
* refactor(terminal): add the topology commit module with bindLeaf, closeLeaf and closeTab
terminal-topology-commit.ts is the boundary for class-(a) terminal topology.
bindLeaf forwards to persistPtyBinding, whose write now runs in a commit
scope; the spawn commits and the relay reattach bind through it. closeLeaf and
closeTab wrap the existing close mutation in a commit scope and one
persistence.terminal-topology span (kind, outcome, refusal reason; no ids),
and the runtime close goes through them. Session output is unchanged: tests
compare it byte for byte with the old writers for local, ssh: and folder
workspaces.
* chore(terminal): drop an unused lint suppression from the topology write guard
* fix(terminal): attribute topology guard writers relative to the repo root
The guard read a frame's file through its last /src/ segment, so a test under
tests/ (folder-upgrade-identity-persistence.unit.test.ts) had no source frame
and failed as an unknown writer. Frames are now taken relative to the repo
root the setup passes in, tests/ counts as test seeding, Windows backslashes
are normalized before the node_modules skip, and nested src/ paths keep their
full path. The two runtime funnel files skip only their funnel function, so
another writer in them still shows. The R9 allowlist key names the file whose
frame actually writes. The class-(a) diff and the attribution move into their
own files; the stack limit is restored in a finally.
* refactor(terminal): drop bindLeaf until binding reaches a sink; add the boundary ratchet
bindLeaf and the commit scope inside persistPtyBinding changed nothing: the
binding write never reaches a session sink, and a scope inside the Store method
would have admitted every direct caller once it did. Both return in B1-4; the
spawn commits and the relay reattach call persistPtyBinding directly again.
The runtime close now calls one closeLeafOrTab entry, so its callbacks keep
their contextual types. A census test is the primary enforcement: only
persistence/terminal-topology and the callers it lists may call
persistPtyBinding, the session setters or the three sinks, and every
unrouted-writer allowlist entry must name an existing file.
* fix(test): resolve the topology guard's repo root without the global URL
Under happy-dom the global URL is not Node's, so fileURLToPath(new URL(...))
threw in the setup file and failed every happy-dom test file.
* refactor(terminal): drop the runtime topology write guard; the boundary ratchet enforces
The AST boundary ratchet is the enforcement for B1. The stack-attributed
runtime guard, its class-(a) diff, the unrouted-writer allowlist, the vitest
setup and the freeze option are removed; it saw two writers in the whole unit
suite and its real value starts only once binding reaches a sink (B1-4).
Also: one closeLeafOrTab wraps the close in the span (no per-kind copies or
narrowed types), the span has one finish like persistence.pty-binding, the
sink helper is publishWorkspaceSessionPartition (it publishes; the commit
boundary is the module), the ratchet drops the private publishSession row and
checks that every listed caller file exists, and the close comparison keeps
its two meaningful cases with span cases chosen by name.
* refactor(terminal): trim the B1-1 commit module and ratchet to what they enforce
- Point the acknowledged-tab-retirement audit fixture at the moved
advanceTerminalTopologyRevision; its old import no longer resolved.
- Drop publishWorkspaceSessionPartition: it was the removed guard's
interception point, so the three session sinks return to origin/main.
- One traced(kind, mutate) wrapper in the commit file replaces the span
factory; closeLeafOrTab is one call.
- The ratchet walks src/main with the shared scanSourceTree, drops the
loading-store-internal rows and the redundant file-exists test; exact-set
equality already fails on a missing file.
- The commit test is a pure unit test of the span outcomes: no Store
harness, electron mock or self-comparing close.
* refactor(terminal): census the runtime session controller's write and drop stage ids from comments
The controller's setter was named set, which the boundary census could not
list without matching every Map.set, so a new OrcaRuntime mixin could write
sessions through it unseen. Rename it setForWorktree and census it.
Comments now describe state instead of citing plan stage ids.
* refactor(terminal): census writer references and trace refusals by callback
- traced() takes refusalOf instead of assuming an Error refusal, and only
mutate() sits in the try, so a span outcome of threw means the write threw.
- The boundary ratchet counts references, not just direct calls: non-null
calls, bracket keys, aliases, destructures, .call/.bind and parenthesized
callees all count; declared names and type positions do not.
- Census terminalSurfaceCloseMutation (boundary-only) and the partition sinks
setLocalWorkspaceSession / setHostWorkspaceSession.
* test(terminal): count writer uses in extends clauses and instantiations, skip type-only imports and local declarations
The census skipped ExpressionWithTypeArguments as a type, which also holds
`extends f(x)` and `x<T>` value expressions. Type-only import/export
specifiers and declared names (variables, parameters, accessors, enum
members) no longer count as uses. The audit fixture is listed in the table
instead of a separate exemption.
* test(terminal): count quoted and assignment-pattern destructures of layout writers
* test(terminal): count every mention of a layout writer except its definition
Telling definitions from uses per syntax kind kept missing nested and
for-of destructures. Exempt only the writer's own function or class-member
definition; any other mention (including object-literal keys) counts, so the
census errs toward a loud false alarm rather than a silent miss. Quoted names
count only in member-name position.
* test(terminal): count every string literal naming a layout writer
Member-name positions missed wrapped keys like store[('name')] and
store['name' as const]. Counting every string literal outside types is
shorter and errs toward a loud false alarm.
* test(terminal): exempt only class members and functions as writer definitions
Object-literal methods and accessors were exempt while equivalent arrow
properties counted; all object-literal keys now count alike.
* test(terminal): parse files with unicode escapes in the writer census
A name spelled with a \u escape never appears verbatim, so the text
prefilter skipped it.
* test(terminal): parse any file with an escape in the writer census
\x, identity and line-continuation escapes also decode to a writer name
without it appearing verbatim.
* fix(daemon): pause producers when stream backlogs grow
* fix(daemon): reset stream backpressure on socket replacement
* docs(daemon): point retention audit at current reproducer
* test(daemon): validate stream retention audit outcomes
* fix(daemon): bound the stream producer stall and leave a visible gap
Stream backpressure pauses a session's PTY with no deadline: the only
un-pause comes from the consumer draining, so a half-open peer that stops
reading without closing freezes the shell for the rest of the session.
Arm a 60s watchdog on the false->true stream-pause transition (not on the
re-assertions refresh() makes for neighbouring sessions). On fire, mark the
session stall-released: it becomes keep-tail droppable, its backlog is
thinned behind a dataGap, and the producer runs again. The existing dataGap
path makes the renderer restore that pane from the daemon's snapshot, so the
user sees the terminal jump to current rather than sit frozen. The mark
clears once the session's last byte leaves the daemon, restoring ordinary
pausing. Nothing here reports a process exit - loss of contact with a
consumer is not evidence about the child.
Also enable TCP keepalive on the stream socket so a genuinely dead peer
closes and onStreamDisconnected clears the pause.
* test(daemon): put each casting SAFETY: directive on one line
`oxlint-disable-next-line` covers only the line directly after it, so a
rationale wrapped onto a second comment line suppressed nothing and the
casts failed the changed-code quality gate. Drop the remaining JSON.parse
cast for an annotated binding.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
* fix: retire closed editor models from the app shell
* test(editor): use checked Monaco attachment calls
* Preserve bounded editor view caches when retiring closed models
* docs(editor): describe batched model retirement
* fix(editor): preserve cleanup work across registry replacement
* fix(editor): build editor model URIs with the file scheme
Monaco keys its model registry by `uri.toString()`, and both
`@monaco-editor/react` (via the `path` prop) and the closed-tab disposal
path built that key with `Uri.parse`. On Windows a raw path such as
`C:\repo\a.ts` parses as scheme `c`, which fails the scheme gate in
`modelService._schemaShouldMaintainUndoRedoElements`, so closed-file undo
history was dropped for every file at any size — not only the large files
the tradeoff note covers.
Add `toEditorModelUri`, the one filesystem-path -> model-key function,
built on `Uri.file` so the result always carries the `file:` scheme and
re-parses to itself. Route model creation, disposal lookup and the
still-open ownership comparison through it so all three agree; a
divergence there would dispose a model an open editor is still editing.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
* fix(ai-vault): bound incremental transcript record assembly
* fix(ai-vault): skip one oversized record instead of dropping the session
An agent transcript record over the 10 MiB budget threw out of the JSONL
fold, so the whole session vanished from Agent Session History and from
search. A 10 MiB base64 image or a runaway tool result is ordinary.
The reader now discards the offending record up to its newline and keeps
folding. The in-progress record always starts at `consumedThrough`, which
is what makes both its running size and the resume offset past a discarded
span exact; an unterminated oversized tail leaves the cursor at the
record's start so a still-growing record is re-read rather than guessed at.
Skips accumulate on the resume point keyed by start offset, and the scanner
reports them as a per-session `notice` so nothing is silently lost.
The budget itself is unchanged.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
* fix(runtime): persist acknowledged terminal tab retirement
* test(runtime): drain tab retirement fixture writes before teardown
* fix(runtime): explain a refused workspace terminal close
The Sleep-workspace path threw the raw refusal enum ("stale-terminal") as an
Error message, which reaches a CLI user verbatim and a Sleep toast via
describeSleepFailure. Map each refusal reason to a sentence instead.
Also pins two behaviours that had no coverage: the user-visible outcome of a
republished stale-terminal refusal on the web client (the caller cannot tell it
from a real close), and the one-call-per-close invariant that keeps a successor
terminal alive.
The bounded close retry was NOT implemented: notifier.closeTerminalTab carries
only a tab id, so a second call destroys whatever successor took that id.
* test(runtime): build refusal fixtures without type assertions
The changed-code quality gate rejects new `as` casts. Replace the
branded-outcome cast with refusedMobileSessionTabClose, and model the
wire-skew reason as a decoded host answer instead of `as never`.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
* fix(claude): enforce history window quota while reading
* test: repair history quota audit dependency and CI import
* fix(native-chat): record why restart reconciliation leaves work unconfirmed
Two silent paths hid the cause of an unconfirmed submission. The reconciler's
bare `continue` on an `unknown` outcome dropped the reason it already carried,
and the transcript read swallowed its error, collapsing an oversize file and a
genuine read failure into the same verdict.
Log both. No control flow changes.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
* fix(browser): bound CDP output for stalled clients
* fix(browser): log CDP outbound overflow before terminating the client
The outbound queue terminated the automation client silently on overflow, so
the client saw a socket close indistinguishable from a crash. Surface the cap
that tripped and the backlog held when it did.
The queue dropped its backlog before invoking onOverflow, so the counters were
already zero at the callback. Snapshot them first and pass them through.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
* fix(terminal): retire captured remote handles when pending panes close
A restored pane can hold a scoped `remote:<environment>@@<handle>` layout
binding while `remote.attach()` is still waiting for `terminal.resolvePane`.
The transport's `getPtyId()` is null, so an explicit split close passed null to
`closeWebRuntimeTerminal`, dropped the binding and destroyed only the viewer.
The host terminal stayed connected.
Only an exact scoped handle whose environment matches the owning workspace's
runtime authorizes the close. The provider helper captures the pairing
revision, runs its existing compatibility check, then rechecks pairing and
ownership immediately before dispatch.
Rebased onto main after #21001 was squash-merged. The previous head was a merge
commit that carried its own conflict-resolution content -- the runtime branch in
`terminal-pane-close-admission.ts` and the restored `it.each([false, true])`
parameter -- which a plain rebase drops along with the merge. Rebuilt from the
recorded net diff instead and verified byte-identical at 15 files,
906 insertions, 41 deletions.
* test(memory): rebase the pending runtime-close proof onto the squashed base
`fix.patch` recorded a baseline taken against #21001's pre-squash branch tip.
Squash-merging #21001 replaced that tip with a single commit, so the recorded
hunks no longer reverse-applied and `reproduce.mjs` aborted with
`Source changed: use-terminal-pane-close-actions.ts` -- confirmed by running it
before regenerating rather than assuming the rebase alone would fix it.
Regenerated against `main` and re-run: 5 pass / 10 fail before, 15 pass / 0
fail after, exit 0, and every `results.json` hash recomputed from the run
rather than hand-edited.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
* fix: avoid retaining foreign SSH file frames before metadata
* test(ssh): exercise empty metadata through the streaming mux fixture
* fix(ssh): fail the file read when beforeResolve never runs
Moving the metadata install from .then() to beforeResolve moved it from a
mandatory callback to an optional one, and handleResponse clears the request
timer before beforeResolve runs. That left "response fulfilled, metadata never
installed" with no deadline: the read never settled, holding its notification
and dispose closures until mux disposal. Before this PR the same state failed
after the 60s inactivity deadline.
Unreachable with the concrete mux, which calls resolve on the line after
beforeResolve, but the hook is optional in the type and nothing enforces the
pairing. The guard is a no-op on every real path: empty, missing streamId,
cap-exceeded and alloc-failure all settle first, and the success path sets
metadataReady.
Found during review of #21167; raised at
https://github.com/stablyai/orca/pull/21167#issuecomment-5726058832
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Claude <noreply@anthropic.com>
* fix(terminal): retire explicitly closed pending split connections
* test(memory): keep pending split proof compatible with formatted source
* fix(terminal): confirm pending split retirement before stopping work
* fix(terminal): restore the pending split-close gates CI checks
Three CI gates were red on this branch and all three were this branch's own.
The hook-order parity snapshot did not count the `confirmedCloseRef` this
branch adds to `use-terminal-pane-close-actions.ts`. Dumping the flattened
order against clean `main` shows exactly one added `useRef` at position 148
and no reordering, so the count moves 211 -> 212 and the digest with it.
`pending-split-close-test-fixture.ts` is Vitest support code, but it sits
outside the `*.test` / `*.spec` / `tests` globs that already switch
`anti-slop/no-module-mocking` off, so the gate failed on all twelve of its
`vi.mock` calls. It carries a file-scoped disable with the reason, matching
`work-item-search-test-harness.ts`.
`fix.patch` still described the pre-confirmation shape of the close hook, so
`reproduce.mjs` aborted with `Source changed` and the cited ablation could not
run at this head. Regenerated against the committed sources; the harness again
reports 10 pass / 14 fail before and 24 pass / 0 fail after.
Merges `main` rather than rebasing: #21005 is stacked on this branch.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
* fix(chat): release provider children after lost resume holds
* test: load audit fixtures as modules and verify combined mobile payload
---------
Co-authored-by: m4air <m4air@Mac.localdomain>