fix(ssh): stop reporting a confirmed kill when the SSH provider is gone (#14977)

* fix(ssh): stop reporting a confirmed kill when the SSH provider is gone

A detached relay PTY is designed to outlive the provider that addressed it
(it ignores SIGHUP and ships with an unlimited grace), so "the SSH provider
is no longer registered" is lost contact, never evidence the remote process
stopped. Both stop primitives in the PTY controller returned `true` from
that branch, and every caller downstream reported the fabricated success:
the CLI printed "PTY killed.", worker-stop settled the dispatch as stopped,
and — because the stop "succeeded" — the unstopped-PTY gate never ran, so
worktree removal walked straight past a live remote agent.

`kill`/`stopAndWait` now still tombstone the local lease but report an
unconfirmed stop and record why, using the three-verdict vocabulary the
worktree teardown gate already spoke (`live` / `unverifiable` / `exited`),
promoted out of that module into `src/shared/pty-liveness-verdict.ts`.
The close receipt, the CLI wording, worker-stop and the removal gate all
read that verdict instead of inferring an exit from silence.

The same rule fixes the mirror-image defect: the aggregate inventory only
enumerates registered providers, so a dropped relay clears `connected` for
every remote PTY at once. The sweep now separates the provider answering
"absent" (an exit) from no provider being able to answer (lost contact), so
worker-stop stops claiming `exited` from a disconnect.

The `connected` wire field is unchanged in meaning and shape.

* fix(orchestration): apply the same honesty to the federation stop path

The federation host runs its own copy of the worker observation and stop
logic, with the same two defects: `inspectRemoteAttachment` read a dropped
relay's `connected: false` as `exited`, and `federationStop` settled the
dispatch as stopped from a close it never confirmed — relaying a fabricated
success all the way home to the coordinator.

Two guards also had to move so the honest verdict does not become a new
refusal. `federationRead` gated on `status !== 'running'`, which would have
rejected a connected terminal the moment a stop lost contact with it; it now
gates on `status === 'exited'`, which is equivalent for every pre-existing
status given the two guards beside it. Local `workerStop` likewise still
attempts the close when the verdict is `unverifiable` — losing contact is a
reason to report the outcome honestly, never a reason to stop trying.

The show observations now carry the reason alongside the status, so a bare
`unverifiable` is actionable. Both are new optional fields.

* fix(ssh): preserve unconfirmed stop verdicts across consumers

* fix(ssh): use canonical live verdict wording

* fix(ssh): refuse wrong-host teardown verification

* test(orchestration): confirm worker release teardown

* fix(orchestration): negotiate honest worker stop receipts

* fix(agent-teams): fence uncertain teammate respawns

* fix(ssh): avoid duplicate missing-provider teardown

* fix(orchestration): preserve archives across release retries

* fix(ssh): preserve verdicts across synthetic kill exits

* fix(ssh): preserve liveness evidence across teardown

* fix(agent-teams): replace panes only after confirmed stop

* fix(ssh): distinguish host exits from relay loss

* fix(ssh): narrow concurrent inventory verdicts

* fix(orchestration): serve archives after uncertain release

* fix(orchestration): expose unverifiable read liveness

* test(ssh): align liveness assertions with verdicts

* fix(ssh): preserve host scope across inventory failures
This commit is contained in:
Brennan Benson
2026-08-17 00:11:19 -07:00
committed by GitHub
parent b279f66c96
commit 7afce2ea41
46 changed files with 1978 additions and 161 deletions
@@ -1,6 +1,7 @@
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { AgentType, NativeChatMessage } from './native-chat-types'
import type { RuntimeTerminalRead, RuntimeTerminalState } from './runtime-types'
import type { PtyLivenessVerdict } from './pty-liveness-verdict'
export const ORCHESTRATION_WORKER_READ_SOURCES = ['auto', 'transcript', 'terminal'] as const
export type OrchestrationWorkerReadSource = (typeof ORCHESTRATION_WORKER_READ_SOURCES)[number]
@@ -41,6 +42,7 @@ export type OrchestrationWorkerReadTranscriptResult = {
status: {
worker: string
terminal: RuntimeTerminalState
liveness?: PtyLivenessVerdict['status']
}
fallbackReason: null
warnings: string[]
@@ -57,6 +59,7 @@ export type OrchestrationWorkerReadTerminalResult = {
status: {
worker: string
terminal: RuntimeTerminalState
liveness?: PtyLivenessVerdict['status']
}
fallbackReason: OrchestrationWorkerReadFallbackReason | null
warnings: string[]
+3
View File
@@ -45,6 +45,8 @@ export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY =
'orchestration.federation-control-mail.v1' as const
export const ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_RUNTIME_CAPABILITY =
'orchestration.federation-lifecycle-settlement.v1' as const
export const ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY =
'orchestration.worker-stop-verdict.v1' as const
export const ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY =
'orchestration.worker-launch-preferences.v1' as const
export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION = 2 as const
@@ -115,6 +117,7 @@ export const RUNTIME_CAPABILITIES = [
ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY,
ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY,
ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_RUNTIME_CAPABILITY,
ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY,
ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY,
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
BROWSER_SCREENCAST_RUNTIME_CAPABILITY,
+34
View File
@@ -0,0 +1,34 @@
/**
* The one vocabulary Orca uses to talk about whether a PTY is live.
*
* `exited` requires positive evidence of absence from the owning host. Losing
* contact with that host — an unregistered SSH provider, a dropped relay, an
* inventory that only enumerates registered providers — is `unverifiable`, never
* a death certificate and never a successful stop.
*/
export type PtyLivenessVerdict =
| { status: 'exited' }
| { status: 'live'; ptyIds: string[] }
| { status: 'unverifiable'; reason: string }
export const SSH_PROVIDER_UNREGISTERED_REASON = 'its SSH provider is no longer registered'
export const NO_OBSERVING_PROVIDER_REASON = 'no registered provider can observe its host'
export const SSH_EXIT_UNCONFIRMED_REASON = 'the owning SSH host did not confirm the PTY exit'
export const PTY_LIVE_NOTE = 'The PTY is live.'
/** The one sentence every surface uses to admit a stop was not confirmed. */
export function describeUnconfirmedStop(reason: string): string {
return `The PTY was not confirmed stopped: ${reason}.`
}
/** Words a close whose PTY teardown was never confirmed, for a stop receipt. */
export function describeUnconfirmedAgentStop(close: {
ptyStopVerdict?: 'live' | 'unverifiable'
ptyStopReason?: string
}): string {
const detail =
close.ptyStopVerdict === 'live'
? 'it is live'
: (close.ptyStopReason ?? 'the stop outcome could not be verified')
return `The agent terminal was closed but its process could not be confirmed stopped: ${detail}.`
}
+8
View File
@@ -734,6 +734,14 @@ export type RuntimeTerminalClose = {
/** Present for the durable whole-tab lifecycle without changing legacy receipts. */
closeMode?: 'tab'
ptyKilled: boolean
/**
* Why the PTY was not killed, when we know. Absent means today's answer —
* nothing observed either way — so older clients reading only `ptyKilled` are
* unaffected. `exited` never appears here: that is what `ptyKilled` reports.
*/
ptyStopVerdict?: 'live' | 'unverifiable'
/** Set with `ptyStopVerdict: 'unverifiable'`; names what we lost contact with. */
ptyStopReason?: string
}
export type RuntimeTerminalWaitCondition = 'exit' | 'tui-idle'