fix(relay): don't re-run an agent resume against a relay that never held the terminal

When a stable pane's reattach fails with positive absence, the fallback re-spawns
using the original request's spawn options, replaying `launchAgent` / resume argv /
`command`. On a cold restore that is right. After a relay daemon replacement it is
not: the binding may name a PTY from a previous daemon whose shell is still alive as
an orphan, so the replay starts a SECOND agent against the same worktree.

`relay.status` already reports `uptimeMs`. A daemon whose uptime is shorter than the
age of the binding it just answered about demonstrably never owned that binding, so
its positive absence is `unverifiable`, not `exited`. On that verdict the fallback
still spawns a shell — the spawn is never gated — but drops the startup intent and
sets the existing `agentResumeUnavailable` flag so the pane shows the
resume-unavailable banner instead of claiming the old session came back.

Loss of contact produces no verdict: a missing host RPC, a rejected or timed-out
`relay.status`, a non-object reply, an absent or unusable `uptimeMs`, and a binding
nothing can date all leave today's behavior exactly as-is.

Client-side policy only — no new wire field and no new stream opcode.

STA-5698
This commit is contained in:
Brennan Benson
2026-08-28 01:01:11 -07:00
parent 4bb337741c
commit 24614bdf6c
6 changed files with 420 additions and 5 deletions
+3 -2
View File
@@ -130,7 +130,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
typeof ctx.launchCommand === 'string' ? ctx.launchCommand : null
)
}
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) {
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner && !ctx.result.agentResumeUnavailable) {
markClaudePtySpawned(ctx.result.id)
}
// Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit.
@@ -179,7 +179,8 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
})
}
// Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it).
if (args.telemetry && !ctx.stablePaneOwner) {
// Why: a suppressed resume produced a plain shell, so there is no agent launch to report.
if (args.telemetry && !ctx.stablePaneOwner && !ctx.result.agentResumeUnavailable) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
@@ -0,0 +1,145 @@
import type { Store } from '../../../persistence'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types'
import { parseAppSshPtyId, toRelaySshPtyId } from '../../../providers/ssh-pty-id'
/** Bounded so a slow relay leaves the fallback spawn on today's timing, not blocked behind a probe. */
export const RELAY_AGE_STATUS_TIMEOUT_MS = 2_000
/**
* Slack between two independently measured elapsed times — the client's clock over the binding and
* the host's own uptime counter. Both are durations, so no clock-offset correction is possible or
* needed; this only absorbs jitter and the gap between daemon start and the first binding write.
*/
export const RELAY_AGE_CLOCK_SLACK_MS = 30_000
/**
* Startup intent replayed by the absence fallback. Mirrors the set `attachStablePaneOwner` already
* strips for an attach: everything that would run an agent, a resume argv, or a claim in the shell.
*/
export function stripStartupIntent(options: PtySpawnOptions): PtySpawnOptions {
return {
...options,
command: undefined,
commandDelivery: undefined,
startupCommandDelivery: undefined,
launchAgent: undefined,
startupIngress: undefined,
agentSessionEnsure: undefined,
agentSessionCreateOperationId: undefined
}
}
export function carriesStartupIntent(options: PtySpawnOptions): boolean {
return Boolean(
options.command ||
options.launchAgent ||
options.startupIngress ||
options.agentSessionEnsure ||
options.agentSessionCreateOperationId
)
}
/** Age of the lease that recorded this SSH binding, or null when nothing dates it. */
function bindingAgeMs(
store: Store | undefined,
connectionId: string,
ptyId: string,
now: number
): number | null {
if (!store || typeof store.getSshRemotePtyLeases !== 'function') {
return null
}
const parsed = parseAppSshPtyId(ptyId)
if (!parsed || parsed.connectionId !== connectionId) {
return null
}
let createdAt: number | null = null
for (const lease of store.getSshRemotePtyLeases(connectionId)) {
if (
toRelaySshPtyId(connectionId, lease.ptyId) !== parsed.relayPtyId ||
typeof lease.createdAt !== 'number' ||
!Number.isFinite(lease.createdAt)
) {
continue
}
createdAt = createdAt === null ? lease.createdAt : Math.min(createdAt, lease.createdAt)
}
if (createdAt === null) {
return null
}
const age = now - createdAt
return Number.isFinite(age) && age > 0 ? age : null
}
/**
* Does the relay that just answered "that PTY is absent" predate the binding it answered about?
*
* A daemon whose reported uptime is shorter than the binding's age cannot have owned the bound
* process, so its positive absence is `unverifiable` rather than `exited`
* (docs/reference/ssh-execution-boundary.md) — the shell it named may still be alive as an orphan
* of the daemon it replaced. Only an answer that ARRIVES and is demonstrably younger says so:
* a missing provider, a rejected or timed-out `relay.status`, an absent or unusable `uptimeMs`,
* and an undatable binding all return false, leaving the caller on today's behavior. Silence is
* never promoted to a verdict here.
*
* Reads only fields `relay.status` already publishes; adds no request field and no opcode.
*/
export async function answeringRelayPredatesBinding(args: {
provider: IPtyProvider
store?: Store | undefined
connectionId?: string | null
ptyId: string
now?: () => number
}): Promise<boolean> {
const { provider, connectionId, ptyId } = args
if (!connectionId) {
return false
}
const age = bindingAgeMs(args.store, connectionId, ptyId, (args.now ?? Date.now)())
if (age === null) {
return false
}
let status: unknown
try {
// Optional call: a provider with no host RPC yields undefined, which is not an answer.
status = await provider.requestHostRpc?.(
'relay.status',
{},
{
timeoutMs: RELAY_AGE_STATUS_TIMEOUT_MS
}
)
} catch {
return false
}
if (!status || typeof status !== 'object') {
return false
}
const uptimeMs = (status as { uptimeMs?: unknown }).uptimeMs
if (typeof uptimeMs !== 'number' || !Number.isFinite(uptimeMs) || uptimeMs < 0) {
return false
}
return age > uptimeMs + RELAY_AGE_CLOCK_SLACK_MS
}
/**
* The absence fallback's spawn, minus the startup intent a replacement relay never held.
*
* Returns null whenever nothing proves a replacement, leaving the caller on its unchanged path —
* the spawn itself is never gated here, only the intent replayed into it.
*/
export async function spawnWithoutReplayedIntent(args: {
provider: IPtyProvider
store?: Store | undefined
connectionId?: string | null
ptyId: string
spawnOptions: PtySpawnOptions
onFreshSpawn?: (result: PtySpawnResult) => void
}): Promise<PtySpawnResult | null> {
if (!carriesStartupIntent(args.spawnOptions) || !(await answeringRelayPredatesBinding(args))) {
return null
}
const result = await args.provider.spawn(stripStartupIntent(args.spawnOptions))
args.onFreshSpawn?.(result)
return { ...result, agentResumeUnavailable: true as const }
}
+23 -1
View File
@@ -12,6 +12,8 @@ import {
} from '../../../daemon/daemon-errors'
import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state'
import { isPtyAlreadyGoneError } from '../provider/liveness'
import { isSshPtyAbsentFromRelayError } from '../../../providers/ssh-pty-errors'
import { spawnWithoutReplayedIntent } from './relay-replacement-resume-suppression'
import { clearProviderPtyState } from '../provider/state-cleanup'
export type StablePaneOwner = {
@@ -166,6 +168,8 @@ export type StablePaneSpawnContext = {
connectionId?: string | null
resolveOwner?: () => StablePaneOwner | null
onFreshSpawn?: (result: PtySpawnResult) => void
/** Fired when a reachable relay positively answered this exact PTY id absent. */
onRelayPositiveAbsence?: () => void
}
export function stablePanePersistenceFence(
@@ -242,6 +246,9 @@ export async function attachStablePaneOwner(
if (!isPtyAlreadyGoneError(error)) {
throw error
}
if (isSshPtyAbsentFromRelayError(error)) {
args.onRelayPositiveAbsence?.()
}
const ownerBeforeRetire = args.resolveOwner?.()
if (
ownerBeforeRetire &&
@@ -282,10 +289,25 @@ export async function spawnForStablePane(
args: StablePaneSpawnContext
): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> {
if (args.owner) {
const attached = await attachStablePaneOwner({ ...args, owner: args.owner })
let relayAnsweredAbsent = false
const attached = await attachStablePaneOwner({
...args,
owner: args.owner,
onRelayPositiveAbsence: () => {
relayAnsweredAbsent = true
}
})
if (attached) {
return attached
}
// Why: the user still needs a terminal, so the spawn itself is never gated — only the startup
// intent is, and only when the answering relay proves it never held the binding it answered for.
const plain = relayAnsweredAbsent
? await spawnWithoutReplayedIntent({ ...args, ptyId: args.owner.ptyId })
: null
if (plain) {
return { result: plain, owner: null }
}
}
const result = await args.provider.spawn(args.spawnOptions)
args.onFreshSpawn?.(result)
@@ -0,0 +1,243 @@
import { describe, expect, it, vi } from 'vitest'
import {
SSH_SESSION_EXPIRED_ERROR,
SshPtyAbsentFromRelayError
} from '../../../providers/ssh-pty-errors'
import type { Store } from '../../../persistence'
import type { IPtyProvider, PtySpawnOptions } from '../../../providers/types'
import { spawnForStablePane, type StablePaneOwner } from './stable-owner'
const LEAF = '1b3f2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e'
const CONNECTION = 'conn-1'
const PTY_ID = `ssh:${CONNECTION}@@pty-1`
const OWNER: StablePaneOwner = {
tabId: 'tab-1',
leafId: LEAF,
ptyId: PTY_ID,
hasPersistedBinding: true
}
const BINDING_AGE_MS = 2 * 60 * 60_000
/** Only the lease read matters here; the retirement path is covered by the absence-respawn suite. */
function leaseStore(createdAt: number | undefined): Store {
return {
getSshRemotePtyLeases: () =>
createdAt === undefined
? []
: [
{
targetId: CONNECTION,
ptyId: 'pty-1',
state: 'detached',
createdAt,
updatedAt: createdAt
}
],
getWorkspaceSession: () => ({}) as never,
setWorkspaceSession: () => {},
flushOrThrow: () => {}
} as unknown as Store
}
function runFallback(options: {
spawnOptions: PtySpawnOptions
relayStatus: () => Promise<unknown>
leaseCreatedAt?: number | undefined
now?: number
}): {
run: () => ReturnType<typeof spawnForStablePane>
spawn: ReturnType<typeof vi.fn>
requestHostRpc: ReturnType<typeof vi.fn>
} {
const now = options.now ?? Date.now()
const spawn = vi
.fn()
.mockRejectedValueOnce(new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`))
.mockResolvedValueOnce({ id: `ssh:${CONNECTION}@@pty-2`, isReattach: false })
const requestHostRpc = vi.fn().mockImplementation(() => options.relayStatus())
return {
spawn,
requestHostRpc,
run: () =>
spawnForStablePane({
runtime: undefined,
store: leaseStore(
'leaseCreatedAt' in options ? options.leaseCreatedAt : now - BINDING_AGE_MS
),
provider: { spawn, requestHostRpc } as unknown as IPtyProvider,
spawnOptions: options.spawnOptions,
owner: OWNER,
worktreeId: 'worktree-1',
connectionId: CONNECTION,
resolveOwner: () => null
})
}
}
/** A replacement relay: up 5 minutes against a 2-hour-old binding. */
const youngerRelay = () => Promise.resolve({ uptimeMs: 5 * 60_000, pid: 4242 })
/** The relay that could have owned the binding: up 4 hours. */
const olderRelay = () => Promise.resolve({ uptimeMs: 4 * 60 * 60_000, pid: 4242 })
const RESUME_ARGV: PtySpawnOptions = {
cols: 80,
rows: 24,
command: 'claude --resume abc123',
commandDelivery: 'provider'
}
const LAUNCH_AGENT: PtySpawnOptions = { cols: 80, rows: 24, launchAgent: 'claude' }
const BARE_COMMAND: PtySpawnOptions = { cols: 80, rows: 24, command: 'npm run dev' }
describe('absence fallback against a relay younger than the binding', () => {
it('spawns a shell but drops a resume argv the replacement relay never held', async () => {
const { run, spawn } = runFallback({ spawnOptions: RESUME_ARGV, relayStatus: youngerRelay })
const { result } = await run()
expect(spawn).toHaveBeenCalledTimes(2)
expect(spawn.mock.calls[1]![0]).toMatchObject({
command: undefined,
commandDelivery: undefined
})
expect(result.id).toBe(`ssh:${CONNECTION}@@pty-2`)
expect(result.agentResumeUnavailable).toBe(true)
})
it('drops launchAgent rather than starting a second agent over the same worktree', async () => {
const { run, spawn } = runFallback({ spawnOptions: LAUNCH_AGENT, relayStatus: youngerRelay })
const { result } = await run()
expect(spawn).toHaveBeenCalledTimes(2)
expect(spawn.mock.calls[1]![0]).toMatchObject({ launchAgent: undefined })
expect(result.agentResumeUnavailable).toBe(true)
})
it('drops a startup command', async () => {
const { run, spawn } = runFallback({ spawnOptions: BARE_COMMAND, relayStatus: youngerRelay })
const { result } = await run()
expect(spawn.mock.calls[1]![0]).toMatchObject({ command: undefined })
expect(result.agentResumeUnavailable).toBe(true)
})
it('never gates the spawn itself — a shell is always produced', async () => {
for (const spawnOptions of [RESUME_ARGV, LAUNCH_AGENT, BARE_COMMAND]) {
const { run, spawn } = runFallback({ spawnOptions, relayStatus: youngerRelay })
const { result } = await run()
expect(result.id).toBe(`ssh:${CONNECTION}@@pty-2`)
expect(spawn).toHaveBeenCalledTimes(2)
}
})
})
describe('absence fallback when nothing proves the relay was replaced', () => {
it('replays the resume when the answering relay predates the binding', async () => {
const { run, spawn } = runFallback({ spawnOptions: RESUME_ARGV, relayStatus: olderRelay })
const { result } = await run()
expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'claude --resume abc123' })
expect(result.agentResumeUnavailable).toBeUndefined()
})
it('replays the resume when relay.status rejects — silence is not a verdict', async () => {
const { run, spawn } = runFallback({
spawnOptions: RESUME_ARGV,
relayStatus: () => Promise.reject(new Error('relay status failed'))
})
const { result } = await run()
expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'claude --resume abc123' })
expect(result.agentResumeUnavailable).toBeUndefined()
})
it('replays the resume when relay.status answers with a non-object', async () => {
const { run, spawn } = runFallback({
spawnOptions: RESUME_ARGV,
relayStatus: () => Promise.resolve(null)
})
const { result } = await run()
expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'claude --resume abc123' })
expect(result.agentResumeUnavailable).toBeUndefined()
})
it('replays the resume when relay.status omits uptimeMs', async () => {
const { run, spawn } = runFallback({
spawnOptions: RESUME_ARGV,
relayStatus: () => Promise.resolve({ pid: 4242 })
})
const { result } = await run()
expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'claude --resume abc123' })
expect(result.agentResumeUnavailable).toBeUndefined()
})
it('replays the resume on a cold restore with no lease to date the binding', async () => {
const { run, spawn, requestHostRpc } = runFallback({
spawnOptions: RESUME_ARGV,
relayStatus: youngerRelay,
leaseCreatedAt: undefined
})
const { result } = await run()
expect(requestHostRpc).not.toHaveBeenCalled()
expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'claude --resume abc123' })
expect(result.agentResumeUnavailable).toBeUndefined()
})
it('replays the resume when the relay is younger only inside the clock slack', async () => {
// Two independently measured elapsed times; a sub-slack gap is jitter, not a replacement.
const { run, spawn } = runFallback({
spawnOptions: RESUME_ARGV,
relayStatus: () => Promise.resolve({ uptimeMs: BINDING_AGE_MS - 10_000 })
})
const { result } = await run()
expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'claude --resume abc123' })
expect(result.agentResumeUnavailable).toBeUndefined()
})
it('replays the resume for a provider with no host RPC to ask', async () => {
const spawn = vi
.fn()
.mockRejectedValueOnce(new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`))
.mockResolvedValueOnce({ id: `ssh:${CONNECTION}@@pty-2`, isReattach: false })
const { result } = await spawnForStablePane({
runtime: undefined,
store: leaseStore(Date.now() - BINDING_AGE_MS),
provider: { spawn } as unknown as IPtyProvider,
spawnOptions: RESUME_ARGV,
owner: OWNER,
worktreeId: 'worktree-1',
connectionId: CONNECTION,
resolveOwner: () => null
})
expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'claude --resume abc123' })
expect(result.agentResumeUnavailable).toBeUndefined()
})
it('asks nothing at all when the request carried no startup intent', async () => {
const { run, spawn, requestHostRpc } = runFallback({
spawnOptions: { cols: 80, rows: 24 },
relayStatus: youngerRelay
})
const { result } = await run()
expect(requestHostRpc).not.toHaveBeenCalled()
expect(spawn).toHaveBeenCalledTimes(2)
expect(result.agentResumeUnavailable).toBeUndefined()
})
})
+3 -2
View File
@@ -225,10 +225,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
if (!ctx.stablePaneOwner) {
ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null)
}
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) {
if (ctx.isClaudeLaunch && !ctx.stablePaneOwner && !ctx.result.agentResumeUnavailable) {
markClaudePtySpawned(ctx.result.id)
}
if (args.telemetry && !ctx.stablePaneOwner) {
// Why: a suppressed resume produced a plain shell, so there is no agent launch to report.
if (args.telemetry && !ctx.stablePaneOwner && !ctx.result.agentResumeUnavailable) {
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
+3
View File
@@ -57,6 +57,9 @@ export type PtySpawnResult = {
snapshotTerminalOwner?: TerminalOwner
/** True when the spawn reattached to an existing daemon session. */
isReattach?: boolean
/** Main dropped this spawn's resume/launch intent, so the pane is a NEW session. Set by main,
* not the provider; the renderer swaps the restored banner for "resume unavailable". */
agentResumeUnavailable?: true
/** Last OSC title tracked by the daemon session the snapshot came from.
* Seeds main's terminal title records after a relaunch; never replayed
* into a terminal. */