A host that answered but whose probes failed publishes the deliberate legacy
collapse (foregroundProcess = the stable-cache shell name, hasChildProcesses =
false) with the real verdict in processEvidence. That legacy half is
byte-identical to an idle shell, so cleanup dropped the blocker and select-all
swept the row. unavailable only ever covered a handle we could not route to.
Old hosts keep their old meaning: readPtyProcessInspectionEvidence falls back to
the legacy interpretation when the field is absent.
The extracted store-free module imported its own return type back out of
runtime-terminal-inspection.ts, the store-coupled module it was split from. The
value cycle was broken but the layering read backwards, and a later change that
turned that type import into a value import would have silently reintroduced the
store->runtime->store cycle the split exists to prevent.
Define the type in the leaf and re-export it from the old module so the existing
callers keep their import site.
probeTerminalLiveness now reports 'unverifiable' for a degraded read, but the
blocker it pushed did not reach the destructive path: terminal-liveness-unknown
sits only in LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS, which is @deprecated and
which no renderer reads. The row kept its checkbox, was swept up by select-all,
and passed the removal preflight -- so an unreachable terminal stopped reading as
idle but still permitted the deletion.
Add it to WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS, the set the dialog actually
consults via buildWorkspaceCleanupFacets -> selectableIdentities. Select-all no
longer sweeps a row we could not verify, while canQueueWorkspaceCleanupCandidate
still returns true so the workspace stays individually deletable rather than
becoming a dead end.
Also repoint the tests off tier/selectedByDefault, both @deprecated and unread,
onto the gates the renderer consults. Those assertions passed against dead code
and read as protection that did not exist.
Workspace cleanup asked main two questions per terminal —
`pty:hasChildProcesses` and `pty:getForegroundProcess` — and both coerce a
degraded read to `false`/`null` before it crosses the IPC boundary. A
disconnected SSH provider short-circuits to those values, and the daemon
adapter catches its own dead-socket request and answers `null`. Nothing
throws, so `probeTerminalLiveness`'s `unknown` arm never ran for the case it
exists for, and a worktree whose agent was still working presented as an
idle, unblocked cleanup row.
Read `pty:inspectProcess` instead. It is the one inspection that carries
`unavailable` across the boundary, and it is a single round trip instead of
two. An `unavailable` answer, or a rejection, now yields `unverifiable` and
the `terminal-liveness-unknown` blocker — unknown falls toward "do not clean
up", which is the conservative arm here.
The inspection read moves to a store-free module so the cleanup store can
call it without a store->runtime import cycle; the type declaration stays put.
Deepens the terminal-host evidence coverage the gate audit found thinnest.
Each arm below goes red with that arm reverted alone:
- an empty node-pty title read is a failed read, not an observed exit
- a title read that throws is unverifiable
- a corroborating scan followed by an unavailable one retires the
corroboration rather than inheriting it (the settlement has to be recorded
on unavailable scans too, or the last successful one stands forever)
- the pty's own exit stays a positive observation, so the conservative arms
cannot swallow a real completion
- the children verdict never outranks a non-observed foreground
The Windows unverifiable-job hold gains the read the existing assertion could
not reach: a read returns before the scan it schedules settles, so the hold has
to outlive that scan too. Ablating the hold with only the pre-existing
assertion present stays green; it goes red with the added one.
Ablation on the refreshed composed tree showed the constant itself was
unpinned: widening SHELL_TITLE_SCAN_CORROBORATION_MAX_AGE_MS to Infinity
left the whole matrix green. A wedged `ps` is the trade-off this PR
accepts deliberately, so the bound that scopes it is load-bearing.
The daemon terminal-host's inspectProcess collapsed every foreground read into
`{ foregroundProcess, hasChildProcesses }`, so a read that observed nothing —
node-pty's POSIX title read silently falling back to the spawned shell, or a
degraded/failed `ps` identity scan letting the cached agent identity go stale —
produced the same payload as a genuinely idle shell. The agent-completion
monitor reads that payload as positive exit evidence and reports a still-running
agent as finished.
The host now reports per-probe live/unverifiable/exited evidence beside
byte-identical legacy fields (docs/reference/ssh-execution-boundary.md), the
same additive `processEvidence` contract the relay and local-provider legs use.
A shell-shaped title is an observation only while a completed scan corroborates
it; a scan that could not answer — unavailable, table missing the pane, or
thrown — settles as unverifiable and withdraws that corroboration.
`SubprocessHandle.observeForegroundProcess` is optional and a handle that omits
it reads as `unverifiable`, so the unknown case falls toward the conservative
arm rather than toward a synthesized observation.
Two cross-PR hazards found against #17024, both closed on the side that owns
the code they depend on.
1. #17024 moves `RuntimeTerminalProcessInspection` into a new leaf module while
this branch adds `processEvidence` to it in the old one. The merge conflicts,
and the natural "keep ours — the type moved" resolution drops the field. It
is optional and every reader guards it, so nothing at the call sites changes:
`readPtyProcessInspectionEvidence` then promotes a host that could not answer
to `observed` + `exited`, positive exit evidence from a read that observed
nothing. The new test reads the field through the declared type, so the drop
fails the typecheck at a named site, and asserts the real reader's output
both with the evidence and without it.
2. `inspectPtyProviderProcess` falls back to composing `getForegroundProcess` +
`hasChildProcesses` for a provider that has no `inspectProcess` of its own.
Both coerce a degraded read to null/false with no way to say "could not ask",
which is the idle a workspace-cleanup row gets swept up on. Pinned with the
real LocalPtyProvider and no double: a degraded scan must reach the caller as
`unverifiable` on both probes, and the same sample read through the legacy
pair alone is shown reading as `exited`.
Both go red with their arm reverted alone and stay green under the others.
A gate ablation found the `unavailable` arm of the completion monitor free
while its structurally identical out-of-contract twin was pinned. After an
`unavailable` sample the pending exit could stop being discarded and nothing
would notice: a pending exit would survive the gap and be confirmed by the next
positive sample, so the two "consecutive" samples that dispatch a completion
would be separated by a read that observed nothing. `unavailable` is strictly
less evidence than an out-of-contract verdict, so if either arm deserves the
re-arm it is this one. Its backoff charge was free for the same reason and is
pinned here too, or an unreachable host gets polled at the active tier forever.
Also pinned, each red with that arm reverted alone: the exit-1-is-not-absence
timed-out check on both pgrep and ps, the ps spawn-failure staying
unverifiable, `processName: null` from a completed scan reading as positive
absence, an unowned pty reading as positive absence, and a pty replaced
mid-scan staying unverifiable.
`!result.signal` in subprocessRanAndMatchedNothing is left unpinned on
purpose: Node reports `code` and `signal` mutually exclusively, so
`code === 1` with a signal is not an input production emits, and a test for it
would assert an unreachable branch.
The comment described the pre-evidence hazard it no longer causes: the catch
now publishes an `unverifiable` verdict, so the retained `?? null` legacy name
(kept byte-identical for pre-evidence wire peers) can no longer read as an
exit. Left as-is it invites a future reader to "fix" the legacy collapse and
break that parity.
A degraded local foreground read (failed or pane-less ps table, thrown
scan) used to collapse into the same payload as an observed idle shell,
and the agent-completion monitor read it as exit evidence. The local
provider now implements inspectProcess with per-probe
live/unverifiable/exited processEvidence (same contract as the relay
leg), keeps legacy fields byte-identical, and the POSIX resolver stops
claiming availability for cached tables that never contained the pane.
The mirror of the children-axis rule, one axis over. Recognition used to read
`recognizeAgentProcess(result.foregroundProcess)`. Moving it onto the evidence
union made it read `evidence.foreground.processName`, so a sample whose union
reports `{observed, processName: null}` beside a legacy `foregroundProcess:
'codex'` no longer recognizes an agent. Its foreground verdict is still inside
the vocabulary, so the out-of-vocabulary guard cannot refuse it either, and the
sample falls through to the exit gate and dispatches a process-exit completion
for an agent the host still names in the foreground.
Only the `null` pole of the legacy scalar is the lossy collapse this batch
removes; it conflates "no agent in the foreground" with "could not ask". A
recognized non-null name is a positive observation on every producer:
buildPtyProcessInspectionWireResult copies it straight off an `observed`
foreground verdict, and the ps/pgrep probes only set it from a command line the
host actually read back. Discarding a positive liveness observation to honour a
union reporting no name is the #16900/#16908 polarity bug on the foreground
axis, so the gate now refuses whenever either half still names an agent.
The refusal is added at the exit gate rather than by widening recognition, so
the arm can only ever withhold a dispatch, never introduce one: reading the
legacy name into handleRecognizedProcess would fire a process-replacement
completion on a disagreeing name, which is less conservative, not more.
No conforming host is affected. To reach the gate the foreground verdict is
already 'observed', so buildPtyProcessInspectionWireResult's legacy scalar is
byte-identical to the union's processName the gate already found unrecognized,
and a host predating the field gets that same scalar through the reader's legacy
fallback. Only a peer this client cannot vouch for can disagree, the same
premise the children-axis arm is pinned on.
Pinned by four monitor tests through the real reader with no mock; all four fail
on the previous head, each with an actual false process-exit completion for
'codex'. The four-way ablation keeps the arms independent: reverting only this
term reddens its four tests and leaves the vocabulary-guard, positive-exited and
legacy-live-children pins green.
The exit gate used to read `if (result.hasChildProcesses)` and refuse on
`true`. Moving it onto the evidence union dropped the legacy field, so the
gate now refuses only on a children verdict other than 'exited'. That is more
conservative in one direction — the direction the positive-exited arm pins —
and less conservative in its mirror: `hasChildProcesses: true` beside
`children: 'exited'` is a shape the boolean-only line refused and the
verdict-only gate dispatches a process-exit completion for.
Only the boolean's `false` pole is the lossy collapse this batch removes; it
conflates "no children" with "could not ask". Its `true` pole is a positive
observation on every producer — buildPtyProcessInspectionWireResult sets it
from `children.verdict === 'live'`, processHasChildren from a pgrep that
exited 0 with output, and the daemon host from an observed non-shell
foreground. Dropping a positive liveness observation to honour a disagreeing
'exited' is the #16900/#16908 polarity bug pointing the other way, so the gate
now refuses whenever either half reports live children.
No conforming host is affected: readPtyProcessInspectionEvidence's legacy
fallback maps `true` to 'live', which the verdict term already refuses, and
the wire builder never emits the disagreeing pair. Only a peer this client
cannot vouch for can, which is the same premise the mirror arm is pinned on.
Pinned by three monitor tests through the real reader with no mock (all three
fail on the previous head, each with a false process-exit completion). The
four-way ablation keeps the arms independent: reverting only this term reddens
its three tests and leaves the vocabulary-guard and positive-exited pins green.
The two hardening arms were mutually redundant in coverage: reverting either
alone left the suite green. The out-of-vocabulary guard is now pinned on its
own; this pins the other half.
Given the vocabulary guard, every sample reaching the exit gate already carries
'live' or 'exited'. On a host that builds its wire result with
buildPtyProcessInspectionWireResult the legacy `hasChildProcesses` boolean is
exactly `children.verdict === 'live'`, so reading the boolean and requiring a
positive 'exited' agree — the arm is invisible there.
They part on the shape the reader is written to survive: a peer this client
cannot vouch for, whose two fields disagree. readPtyProcessInspectionEvidence
never cross-checks the legacy scalar against the evidence union, so
`hasChildProcesses: false` can arrive beside a positively observed
`children: 'live'`. That boolean's `false` is the lossy collapse conflating
"no children" with "could not tell" (#16900/#16908). The evidence union is
authoritative, so 'live' must refuse even though the legacy field waves it
through.
Reverting the positive-exited arm alone turns two of these red with a false
`process-exit` completion; reverting the vocabulary guard alone leaves all
three green.
#17012 landed on main spying on ptyShellUtils.isProcessAlive while this branch
moved the probe into pty-process-probes. The rebase merges textually clean but
breaks 6 of the file's 9 tests and the node typecheck.
The two hardening arms added in 7f00203 were mutually redundant: reverting
either alone left the suite green, so either could have been deleted without
CI noticing. Three tests now isolate the vocabulary guard:
- a foreground verdict outside 'observed' paired with a positively matched
'exited' children probe — the exit gate reads a legitimate exit here, so
only the foreground arm can refuse it;
- the companion that the refusal does not wedge the monitor;
- an out-of-contract children verdict must take the inspection-error backoff,
the guard's stated promise to re-arm "like any other failed inspection".
All three fail with the vocabulary guard reverted and pass with the exited
gate reverted, so they pin that arm alone.
A foreign host can put any JSON in processEvidence. An observed verdict
carrying a non-string processName rode through normalization into
recognizeAgentProcess and counted toward the 'foreground is not a recognized
agent' leg of the positive-exited gate; non-string unverifiable reasons were
forwarded as-is. Out-of-type payloads now degrade to unverifiable
(malformed-evidence reason), and non-string reasons fall back to
'unspecified'.
The monitor entered the completion path on children !== 'live' after the
unverifiable early-return — a fall-through, not a positive 'exited' match
(the polarity shape fixed in #16900/#16908). A fourth verdict arm admitted
by a future evidence funnel would have silently read as exit evidence.
Completion now requires children === 'exited'; anything outside the known
vocabulary re-arms like a failed inspection. Pinned by an out-of-contract
monitor test (fails on the previous head) and a funnel-level normalization
unit test that keeps the normalize hunk independently covered.
A failed pgrep/ps probe on a relay host resolved as
{foregroundProcess: null|fallback, hasChildProcesses: false}, which the
agent-completion monitor read as positive exit evidence and turned into
a process-exit completion. Probes now return live/unverifiable/exited
verdicts, the relay publishes them in a new optional processEvidence
field beside byte-identical legacy fields, and the monitor refuses to
treat an unverifiable probe as exit evidence.
`pty.shutdown` was the only signal the relay ever got that a tab had closed,
and it retired nothing: it requested a kill and returned. Every retirement path
in the relay keys on proof of process death, so when the kill did not reap the
pane shell the relay kept forwarding the orphaned agent's hook events as a live
agent pane with no tab, and kept publishing its `agentSessionOwners` from
`pty.listProcesses` with no liveness check at all.
The relay now records the retirement the client stated, drops the pane's cached
agent status with it, refuses to forward or replay posts from a retired pane,
verifies the kill actually landed instead of assuming it, and reaps any PTY
whose pid it can prove is gone before listing it.
No wire change: no new RPC, field or stream opcode.
* fix(pty): key buffered pre-attach exits on the PTY incarnation, not a clock
A restarted SSH relay renumbers PTYs from pty-1, so a fresh spawn is routinely
handed an id whose previous shell is still emitting a late exit. #16970 stopped
that exit blanking the new tab by dating every buffered record and dropping
anything older than the spawn request. That fence is a clock, so it cannot judge
a stale exit that arrives AFTER the request left — the residual risk #16970
documented.
Thread the incarnation main already puts on the pty:exit payload (and the
pty:spawn reply) through preload to the pre-handler buffer, so a buffered exit
names which lifetime of the id died. An exit disagreeing with the incarnation
now attaching is discarded whenever it arrived.
Only a positive disagreement discards: absence stays "unknown", never a
mismatch, so hosts that predate the field keep #16970's behaviour exactly. The
fence is retained for the two cases with no incarnation to compare — buffered
bytes (pty:data carries none) and unnamed exits.
No wire change: incarnationId was already published on the relay's pty.exit
notification and pty.spawn reply, and already forwarded over the in-process
pty:exit / pty:spawn IPC. Only the preload types and the renderer read it now.
* fix(pty): read the incarnation through the shared guard, not truthiness
A malformed incarnation is evidence of nothing, so it must read as "unknown"
rather than as a value that disagrees with every well-formed one — otherwise a
non-string on the payload would discard the very exits the buffer exists to
deliver. Route both the record and the comparison through the existing
isPtyIncarnationId guard.
* refactor(pty): name the bounded-map helper after what it does
It evicts the oldest entry when the map is full and the id is new; it reserves
nothing. Rename only — no behaviour change.
* fix(pty): key the buffered-exit STORAGE on the incarnation too, not just the check
Review caught a swallowed exit. Keying only the comparison on the incarnation
while the storage stayed one slot per pty id left the two races this buffer
exists for able to cancel each other out:
1. the freshly spawned shell dies before the pane attaches -> its exit (X) is
buffered;
2. the relay flushes the previous owner's exit for the same recycled id (W),
which OVERWRITES X in the single slot;
3. the spawn reply names X, so the identity discard drops W -- the only
record left.
registerExit then finds nothing and the pane binds to a PTY that is dead and
will never be reported dead: a hang instead of the blank tab #16970 fixed.
Store one record per lifetime, capped at 4 per id, so W can never evict X. A
duplicate exit for a lifetime replaces that lifetime's record rather than
crowding out another's; drain still delivers the newest survivor, preserving
the last-write-wins behaviour a single slot always had.
* fix(pty): filter buffered exits by lifetime inside the buffer, not at call sites
Review found the identity was enforced only where connectIpcPty calls the
discard, while preHandlerPtyExit has several other consumers. The severe one is
registerEagerPtyBuffer: both background launchers spawn directly and then drain
whatever is buffered for the returned id, so a relay-recycled id holding the
previous owner's exit tore a freshly launched agent session down seconds after
it started -- no fence, no admitPtyId, no identity check at all.
Move the rule into the buffer: every read goes through
admissiblePreHandlerPtyExits, so a record proven to belong to another lifetime
is unreachable by construction rather than because each caller remembered to
discard first. hasPreHandlerPtyExit/drainPreHandlerPtyExit take the asking
lifetime; registerEagerPtyBuffer and registerExit thread it through, and both
background launchers pass the incarnation their own spawn returned.
A reader that cannot name an incarnation still sees everything, which is the
honest answer -- it holds no evidence to discriminate with. That keeps the
pre-spawn fast path in connectIpcPty behaving exactly as it does today; see the
PR for the consumers this still does not cover.
* chore: drop unrelated formatter churn in reliability-gates.jsonc
Repo-wide oxfmt reindented pre-existing entries in a file this change never
touches. Keep the diff to the PTY incarnation work.
* fix(ssh): replay an undelivered remote PTY stop on the next handshake
A pty.shutdown that dies on the transport left the remote shell running
forever: kill.ts marked liveness unverifiable and nothing retried.
Record the undelivered stop on the existing durable SshRemotePtyLease and
replay it against the authoritative host on the next handshake to that same
target, fenced by the host-minted PTY incarnation so a replay cannot kill a
later PTY that reused a recycled pty-N id. Retire the record on confirmed
delivery, on the host reporting the PTY absent, and on a bounded TTL.
No wire change: the fence reads incarnationId, already published on
pty.listProcesses. A host that does not publish it degrades to no replay.
* fix(ssh): do not leave a replayable kill order behind a reversible stop
Worktree sleep stops through stopAndWait and marks those stops reversible;
when one does not land the pane stays live and the user keeps using it. An
order recorded there would come back on a later handshake and kill that
terminal. Only killPtyFromRuntimeController — where the client gives the PTY
up for good — records one, and it skips any PTY a reversible stop owns.
* fix(ssh): cover the renderer kill route and harden the replay's evidence
pty:kill is a separate implementation from killPtyFromRuntimeController and
is the one an ordinary tab close reaches, so the record was never written on
the path #12447 describes. Extracted it out of inspect.ts (which was over the
line budget and was not what the file is named for) and wired both branches.
Also:
- finishPtyShutdown no longer retires the order. It runs on paths that asked
the host and on paths that never did, so retiring there was a contract every
caller had to know, and the one that forgot silently dropped a kill order.
Retirement is the replay's, on inventory evidence only.
- A recycled relay id now expires its lease. Declining to kill was only half:
reattach fences on paneKey/tabId, never incarnation, so an untouched lease
bound the user's old pane to whatever now holds the id.
- Dropped isPtyAlreadyGoneError from the tombstone path. It matches message
text a transport failure could wear; every tombstone now traces to a listing.
- TTL is owned by a durable prune that actually deletes, not by a branch that
was unreachable behind the read filter and only looked tested.
- The replay re-reads the inventory per wave and re-checks the fence next to
each shutdown, and can never reject into the connect path.
The verdict-flip pin was computed from flips on the rendered park verdict but
applied only to the cold-park candidate set, so a loop driven by the
worktree-level park prop or the activation-deferred branch kept remounting a
pane at commit cadence while the pin silenced its own breadcrumb for 60s.
Apply the pin to the rendered verdict via selectParkVerdictPinnedTabIds, and
expire pins for every live tab so damping lapses on its own instead of
re-arming forever.
Snapshotting a VM on which `orca serve` has already run captures the
runtime's user-data dir into the image. Every VM booted from that image
then shares one pairing identity and one agent-session-authority key,
which defeats the per-device token design.
Confirmed by booting two VMs from one such snapshot: both emitted
identical deviceToken and pairedDeviceId.
Adds the rule to the base-snapshot section and repeats it for the
agent-auth layer, which is the likelier place to start the runtime by
hand while smoke-testing. Says to delete the whole user-data dir rather
than a named file list, since that list drifts as Orca adds state.
* docs(headless-server): fix package list, extraction perms, and ldd command
Three fixes to the headless Linux server guide, each of which stops a
first-time setup from working.
The prerequisite list installed only CLI tools and Xvfb, none of the
shared libraries Electron links against. On a minimal server or
container image `orca serve` then fails before Electron starts. Adds the
library set, plus the unsuffixed package names for releases that predate
the 64-bit time_t transition.
The guide tells you to run --appimage-extract and, separately, to run
the service as a dedicated non-root user with the install directory
root-owned. Those two halves combine badly: extraction leaves
squashfs-root as drwx------, so the service user cannot traverse it, and
chmod 755 /opt/orca does not reach inside. Adds the missing chmod to
both places.
The troubleshooting step said to run `ldd squashfs-root/orca`. The
Electron binary is orca-ide, and ldd on a path that does not exist
prints nothing and exits cleanly — a clean-looking result in exactly the
situation where you are hunting a missing library.
* docs(headless-server): correct the t64 substitution failure mode
The mixed-list warning named the wrong mechanism. Old names mostly still
resolve on 24.04, because each renamed package declares Provides: its
unsuffixed name. The exception is libasound2, which liboss4-salsa-asound2
in universe also claims — apt refuses to choose between two providers and
aborts the whole install line rather than silently installing a shim.
Also pins libfuse2t64 as definite rather than possible, and widens the
libfuse2 line to cover 20.04, which is in the support matrix.
fish splices an unquoted command substitution that produces zero words out of
the argument list entirely, so `test (type -t codex 2>/dev/null) = file` became
`test = file` whenever codex was not resolvable. fish's test rejects that as
malformed and wrote "test: Missing argument at index 3" to stderr on every pane
and agent launch for fish users without codex installed.
Capture the substitution into a local first, mirroring the bash/zsh variant,
then clear it so the guard leaves no variable behind in the user's session.
Quoting in place is not a fix: fish never performs command substitution inside
double quotes, so the wrapper would silently never be installed.
Wrapper behavior is unchanged for every codex state (file, function, alias,
absent) on fish 3.1.0 and 4.7.1, and no other shell's preflight is touched.
Combines #16923 and #16928.
Co-authored-by: erishforG <erish2150@gmail.com>
Co-authored-by: Fuzzwah <rob.crouch@gmail.com>
* style: format codebase
* style: format codebase
* refactor: extract skill install dialog footer and content
Extract footer and content sections from SkillInstallDialog and
SkillInstallManagementDialog into separate components for improved
maintainability and clarity of component responsibilities.
* feat(terminal): weight-layer forensics for the bold-collapse bug (STA-4042)
Field instrumentation to name the writer behind regular-text-renders-bold:
- metric-weight-change crumbs at the writePaneMetricOptions funnel
(prev/next/reason; weights never change in normal operation)
- terminal-weight-parity-mismatch audit on every visibility resume
- sentinel weightProbe capture fields: live options vs atlas captured
config vs renderer-buffer bold census
- Cmd/Ctrl+Shift+click unconditional capture (no divergence gate, no
recovery) for states the missing-ink detector cannot see
- patched addon-webgl ctx.font readback probe: detects failed font
assignments that rasterize glyphs at a stale weight
* fix(terminal): treat canvas weight-700-serializes-as-bold as a match in the atlas font probe
Found by live validation: Chromium's ctx.font getter normalizes numeric 700
to the keyword 'bold', which made every legitimate bold rasterization count
as a failed assignment (124 false positives in one session).
* chore: update patch hash for the font-probe normalization fix
* fix(terminal): bound bold glitch diagnostics
* fix(terminal): cover serialized WebGL probe state
* feat(settings): hidden staff toggle to arm terminal render diagnostics
Replaces the reserved hidden-experimental placeholder slot with a real
switch (Shift-click the Experimental sidebar entry to reveal). It arms
and disarms the render-desync capture sentinel live — no localStorage
incantation, no reload — for the bold-glitch investigation. The passive
probes stay always-on; only the capture gestures are gated.
* fix(settings): make render diagnostics disarm exact
* chore(settings): rename hidden group to 'Hidden experimental settings', drop its description
* feat(settings): unlock hidden experimental group via Option-click on the Experimental page title
Replaces the Shift-click-sidebar unlock with the Updates-header idiom:
Option-click the Experimental page title toggles the hidden group.
Removes the now-unused click-modifier plumbing from the settings sidebar.
The debounced session writer cleared its pending changed-field set whenever the
persist gate was shut, after it had already advanced its identity-based
detection baseline. Because detection is `prev[key] !== next[key]`, a field
dropped there could never be re-detected, so a mutation made during an SSH
apply (up to 30s plus a 1s tail, on every connect and reconnect) was lost for
good — including a closed-tab tombstone, which is exactly the state that stops
the host from resurrecting the tab.
Keep the pending set instead, and add a one-shot wake-up from the gate owner so
a deferred write is not stranded when the suppression tail expires with no
store update behind it.
A redeployed SSH relay renumbers its PTY ids from pty-1, so a freshly spawned
PTY can be handed an id a dead one used to own. The renderer's pre-handler
buffer is keyed on that id alone, so `registerExit` found the dead PTY's
buffered exit and reported the brand-new shell as `exitedBeforeAttach` — the
pane never bound a PTY and the tab came up blank forever.
Date every buffered record with a monotonic sequence and fence a fresh spawn
against it: state recorded before the spawn request left the renderer belongs
to the id's earlier owner. State recorded after it is kept, so the real
pre-attach race (a shell that dies instantly, or writes before the pane
registers its handler) still works.
Makes ssh-lost-kill-tab-resurrection.spec.ts:178 pass; it failed in SETUP.
`isRemoteExecutionHostPtyId(id)` (= paired-runtime PTY or direct-SSH app PTY,
i.e. "this request crosses a link") was named in #16941 but only used at its own
call site. Five inline copies of the same disjunction remained.
Moves the helper up out of `pty-connection/` — three of its six call sites are
parking/retention/watcher modules that have nothing to do with pty-connection —
and replaces all five copies. Behaviour-preserving: both prefix-form
`isRemoteRuntimePtyId` implementations (`paired-parked-terminal-restore` and
`runtime-terminal-inspection`) are `startsWith('remote:')`, and
`parseAppSshPtyId(x)` returns an object or null so the truthy and `!== null`
forms agree. The negated site keeps its `ptyId !== null` guard (De Morgan on
`!A && !B`), and the retention site keeps `!ptyId` so empty ids still bail.
* refactor(runtime-status): fold the reconnect re-probe into the refresh action
The reconnect re-probe was never a new concept -- only one policy of the
existing status refresh -- but it duplicated the store action's publish and
both recovery follow-ups. Give the action a publishUnreachable option
(default true, unchanged for the user-initiated check), pass false from the
reconnect path, and delete the wrapper.
* test(runtime-status): name the reconnect-policy follow-up tests for what they assert
* fix(ssh): a host tab row this client cannot place is unverifiable, not absent
A degraded listLineage leaves worktreesByRepo empty, so exactTargetWorktreeIds
returns nothing, every host path fails to resolve, and importRemoteWorkspaceSession
silently dropped every tab row. The apply then marked the target hydrated and
'synced', freezing that emptiness in permanently — nothing re-pulls a hydrated
target (STA-3593).
The importer now reports unplaceable rows, the apply claims authority only when
every row landed, and a bounded chain re-pulls the missing input (catalog +
lineage). On exhaustion it settles back to the pre-fix behaviour so a genuinely
unplaceable path is never left worse off than today.
* fix(ssh): keep the re-pull chain bounded, unwedgeable, and announced once
Five defects in the first cut of the chain, all found before merge:
1. the caller owned the attempt counter, so an unsolicited host push re-armed
it at 0 and the chain never exhausted - an unbounded workspace.get loop;
2. exhaustion never cleared the counter, so after one bad connection every
later reconnect re-exhausted instantly and the retry was silently dead;
3. the exhaustion check preceded the armed-timer guard, letting a concurrent
report cancel the still-pending final retry;
4. a rejected host read left no timer armed and nothing rescheduled, stranding
the target on 'pulling' and un-hydrated forever - and an un-hydrated target
never uploads again, the exact permanent degradation this design avoids;
5. exhaustion re-announced on every later report, re-marking hydrated and
rewriting status on each host push.
The module now owns the counter, resetTarget gives each connection a fresh
chain, the armed guard precedes exhaustion, the timer body always reschedules
so any failure walks to exhaustion, and exhaustion is announced once.
* fix(ssh): never authorise uploads from a picture known to be incomplete
Reversal of this branch's own exhaustion fallback, on evidence.
Hydration authorises uploads (use-app-session-persistence.ts), and an upload is
a workspace.patch of kind 'replace-session' (remote-workspace-relay-sync.ts:66)
which wholesale replaces the host snapshot (relay/workspace-session-handler.ts).
So marking a target hydrated on a picture we know is missing rows does not
'settle back to the old behaviour' - it uploads an empty projection that DELETES
the host tabs we failed to adopt. Suppressed uploads are recoverable; a wiped
host snapshot is not. That data loss is reachable on main today, because today
the apply marks hydrated immediately.
Exhaustion therefore reports 'error' and leaves the target un-hydrated, so
terminal authority stays 'unverifiable' and no upload can be built from it.
Also closes two chain-lifecycle gaps found in review:
- the callback dropped its timer guard before awaiting the host, leaving a gap
in which a concurrent report armed a second overlapping chain and could trip
exhaustion before the pending apply resolved; an in-flight guard now spans it;
- resetTarget could not cancel a callback already past its await, so a stale one
rescheduled on top of the new connection's chain; chains are now generation
stamped and a stale callback exits.
* fix(ssh): scope re-pull in-flight ownership to a generation
Two races found in review of the previous commit:
- schedule(target,'placed') cleared timer/count/exhaustion but did not
invalidate an apply already in flight. When that apply later resolved
'unplaced' its generation still matched, so it started a fresh chain from
attempt 0; a host repeating placed pushes during each in-flight retry could
reset the budget indefinitely. Retirement now bumps the generation too.
- the in-flight marker was a bare Set, so a superseded callback's finally
deleted whichever marker was present - including one a newer generation had
since taken. A later report could then arm an overlapping timer while that
newer apply was still running. Ownership is now a target -> generation map and
a callback releases only the marker it still owns.
resetTarget deliberately no longer drops the marker: its owner is the only party
that may release it, and clearing it there would let a new chain arm while the
superseded apply is still running.
* fix(ssh): replay an unplaced report that was blocked by a superseded apply
Regression from the previous commit. Keeping the stale in-flight marker across
resetTarget stops overlap, but it also swallows the new connection's result: the
new apply reports 'unplaced', hits the guard because the superseded apply still
owns the marker, and the superseded callback then exits on its stale generation
without scheduling. Nothing replayed the dropped report, so no chain started -
the retry silently never ran for that connection.
A blocked unplaced report is now recorded, and the marker's owner replays it on
release. Only the stale path reaches the finally still owning the marker, so the
normal path - which released early and scheduled its own outcome - cannot replay
twice.
* refactor(ssh): drop the re-pull retry chain, keep the fix
The chain produced ten defects across review - unbounded retry, dead retry,
cancelled final attempt, wedged chain, repeated exhaustion, overlapping chains,
stale-callback cleanup, a lost report - every one in code that passed the full
suite at the time. It bought only faster recovery *within* one connection:
syncAfterConnect and applyUnsolicitedSnapshot already re-pull on the next
connect or host push, so dropping it costs a retry, never the data.
What remains is the part that was correct from the first commit: the importer
reports rows it could not place, and an apply that could not place them neither
marks the target hydrated nor sets 'synced'. Because hydration is what
authorises uploads, and an upload wholesale replaces the host snapshot, that
single rule is what stops a client from deleting the host tabs it failed to
adopt.
Status is now 'error' rather than 'pulling': with no chain pending, 'pulling'
claimed a request that was not in flight.
* test(ssh): name the upload-suppression case for the chainless design
* fix(ssh): revoke stale hydration and keep authority unverifiable when unplaced
Two holes in the previous commit, both found in review.
The hydrated set is add-only (ssh.ts), so withholding hydration only protects a
target that never synced. A target that synced cleanly and then reconnected with
a degraded lineage kept its flag, and hydration is what authorises uploads - so
it would still send a replace-session patch built from the incomplete picture
and delete the host tabs it had just failed to place. Hydration is now revoked,
not merely withheld.
The status phase was 'error'. workspace-terminal-host-authority.ts treats
'offline'/'error' on an un-hydrated target as its bounded floor and resolves
them to 'none' - which authorises seeding AND sleeping-agent resume, the exact
double-resume this gate exists to prevent. 'conflict' is the phase that actually
describes the situation, is excluded from uploads by use-app-session-persistence,
and is deliberately outside that floor set, so authority stays 'unverifiable'.
Both invariants are pinned by tests verified to fail when either fix is reverted
individually; the pre-existing tests passed with both reverted.
* fix(ssh): drop the mismatched message on the unplaced conflict status
The phase drives the user-visible label ('Workspace sync conflict'); carrying an
'unavailable' message alongside it only risked contradicting that wherever the
message is surfaced.
* docs+refactor(ssh): correct the authority floor's premise, drop a dead wrapper
Two findings from the post-merge correctness sweep.
The bounded floor in workspace-terminal-host-authority.ts justified itself on
'remoteWorkspaceHydratedTargetIds is add-only, clearRemoteWorkspaceHydrated has
no production caller'. This branch adds that caller, so the premise is now false
and a future reader would have been misled by it. The comment records the real
consequence: a target that later lands on offline/error reaches the floor having
demonstrably answered, so seeding is authorised over live host terminals. Not a
regression - before revocation existed the same target was marked hydrated and
synced, reaching 'none' sooner - but the floor should learn to tell a revoked
target from one that never answered. Flagged for the SSH-v3 consolidation, where
one authoritative liveness source replaces this pair.
applyUnsolicitedSnapshot had become a pass-through to applyPreparedSnapshot,
carrying a docstring about a re-pull chain that no longer exists. The two
collapse back into one function.
* refactor(ssh): delete the DirectSshSnapshotPlacement union
Consolidation pass finding. The union was exported and threaded through two
modules, but no production consumer ever read it: remote-workspace-ipc-bridge.ts
discards the promise's value and syncAfterConnect ignored it. 'not-applied' was
not a placement at all, only 'this apply did not happen'.
That is a parallel verdict concept with no consumer - precisely what the SSH-v3
consolidation would have had to unpick. It collapses to a local
hasUnplacedTerminalTabs boolean and a void return.
The one test that asserted the return value now asserts adoption instead, which
is the observable outcome rather than a proxy for it. All five unplaced oracles
still fail when the placement decision is forced, verified individually.
* docs(ssh): compress the tombstone rationale to its load-bearing WHY
Elegance pass. Kept the two non-obvious claims - absence cannot distinguish
'never told' from 'user closed', and uuid tab ids make a tombstoned id safe to
drop - and cut the incident narrative around them. The twice-reverted history in
remote-workspace-session-merge.ts is deliberately left alone: that one is
institutional memory about regressions, not restatement of the code.
* test(ssh): pin the fixed behaviour instead of the defect it replaced
The spec was a characterization test whose own title said 'because hydration is
marked even when adoption wrote nothing', and whose comment described exactly
the defect this branch fixes: markRemoteWorkspaceHydrated ran unconditionally
after the hydrate calls, so in the same tick adoption yielded zero, authority
flipped unverifiable -> none, and Terminal.tsx seeded a phantom tab. It polled
for hydrated === true, so the fix turned it red.
It now asserts hydrated === false, phase === 'conflict', and zero tabs - the
count measured before asserting rather than assumed, confirming the phantom seed
is gone. The phase is re-read after the tabs settle and asserted a second time,
because a conflict verdict a later apply flipped back would silently re-authorise
seeding and a single poll would miss it.
The fixme stays a fixme: this branch stops the client claiming false authority
and overwriting the host, but adoption is still the open gap. Declining to seed
is a safe wait, not the destination.
Three-legged A/B against fork point c72afda498, spec byte-identical across the
first two legs:
branch + updated spec -> PASS
baseline + updated spec -> FAIL 'never reported the unplaced snapshot as a conflict'
baseline + original spec -> PASS (baseline actively exhibits the old behaviour)
* fix(browser-preview): require explicit preview capabilities (STA-5758)
Scope document reads to approved directories, confirm external links before opening them, revoke grants with tab lifecycle, and keep document-preview session state rollback-safe across mixed client/runtime versions.
* Harden document preview lifecycle and permissions
* Document preview DNS prefetch residual
* Make preview E2E guest focus explicit
* fix(browser-preview): entry-file-only authority for root-level docs, contained chip layout, re-issued gate paths (STA-5758)
A grant whose document directory is its own request base — a doc at the
workspace root, or outside any workspace — now reads nothing but the entry
file until the reader approves a directory, at both the lexical and the
canonical containment pass. The DNS-prefetch residual can only beacon what
the page can read, and a root-level document could previously read the
whole worktree silently.
The identity chip's host badge overflowed the chip's layout box under
squeeze (Linux CI): every row member can now shrink and truncate, verified
by a width sweep in isolated Chromium down to ~120px chips.
The Allow banner says what it grants: 'Allow folder', reading files in the
named directory, for the life of the preview.
The reliability-gate manifest command, testFiles entry, assertion refs and
dated evidence naming the deleted doc-preview-external-link-bridge.test.ts
are re-issued at doc-preview-external-link-confirmation.test.ts with a
fresh 189/189 run; the focus-gate assertion text follows the shipped gate.
* fix(browser-preview): hide the chip identity row below 24rem instead of clipping it, ellipsize the host badge, catalog the new i18n keys (STA-5758)
CI's preview pane leaves the chip ~40px: no truncation shows anything
there, so the Workspace-file label and host badge now hide whole below a
24rem container threshold sized so that visible implies contained. The
badge text gains an inner text box — text directly inside the flex pill
clipped both ends with no ellipsis. The e2e geometry oracle asserts
containment when the row shows and the threshold when it does not.
verify:localization-catalog: the hardening's new preview keys (and the
renamed allowDirectory) join en.json via sync:localization-catalog.
* feat(browser-preview): batch blocked folders into one access decision (STA-5758)
Sequential per-folder banners trained the allow reflex without adding
judgment — a reader cannot weigh assets/ against data/. The banner now
accumulates every folder a load surfaces, names them (three, then a
count, full list in the title), and grants exactly that set with one
Allow-N-folders click and one reload. Dismiss fences the whole named
set. The map lives behind a ref with a version tick so a dismissal
fences an offer landing in the same event batch.
* feat: select parent worktree for nesting when creating workspace
Enable users to pick a parent workspace in the composer's Advanced drawer,
organizing newly created worktrees hierarchically in the sidebar. The backend
validates lineage relationships and gracefully retries without the parent if
it becomes unavailable during creation.
* feat: select parent worktree for nesting when creating workspace
- Record app-picked parents as manual actions, not CLI-flag equivalents,
ensuring the same user action carries consistent cleanup semantics across hosts
- Gracefully retry without parent if the selection goes stale, warn the
user instead of failing
- Refactor create into modules: parent resolution, payload building,
state merge
* Allow selecting parent worktree when creating workspace
- New parent worktree picker filtered by execution host and project
- Preserve concurrent local writes to lineage by comparing per-record state instead of key-set membership
* Allow selecting parent worktree when creating workspace
- Rename "Parent workspace" label to "Parent worktree"
- Filter candidates by execution host and project to prevent nesting across hosts
- Wire parentWorktreeId through composer state and creation request pipeline
- Update translations and add new copy for nesting-related messages
* fix(i18n): use 工作树 consistently for parent worktree strings in zh locale
Addresses PR #15420 review feedback: parent worktree strings mixed
工作区 (workspace) and 工作树 (worktree) terminology.