fix(terminal): make remote agent sessions host-authoritative (#9687)

This commit is contained in:
OrcaWin
2026-07-21 20:51:28 -07:00
committed by GitHub
parent 2a32c5c9a1
commit b232df732b
161 changed files with 16007 additions and 1583 deletions
+9 -1
View File
@@ -1 +1,9 @@
[]
[
{
"filePath": "src/renderer/src/lib/agent-background-session-test-state.ts",
"kind": "object-property:title",
"text": "Terminal 1",
"dynamic": false,
"count": 1
}
]
+164 -5
View File
@@ -920,7 +920,7 @@
"providers": ["daemon", "runtime", "ssh", "wsl"],
"coveredPlatforms": ["windows"],
"coveredProviders": ["daemon", "runtime"],
"coverageNotes": "A native Windows v21/v22/v23/v24/v25 named-pipe harness calls the production desktop legacy-adapter scanner and covers warm reattach, current-to-next upgrade, parallel clients, repeated reconnects, client relaunch, exact process identity, bounded failed disposal, and PTY-root/descendant survival. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, missing intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.",
"coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The current harness additionally includes the v26 agent-authority boundary while retaining v24 clean-disconnect and v25 startup-ingress coverage; that six-generation Windows rerun remains to be collected. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, authenticated legacy and unattributed intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/9749",
"https://github.com/stablyai/orca/issues/8871",
@@ -928,7 +928,7 @@
"https://github.com/stablyai/orca/issues/9229"
],
"invariant": "Reconnect, replay, or lifecycle observations must never kill a live PTY. Destructive close requires explicit user intent; lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Missing or incompatible evidence keeps and audits. Legacy daemon hello and warm reattachment remain non-destructive.",
"oracle": "Start five isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v24 from a v25 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, explicit user close to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.",
"oracle": "Start six isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v25 from a v26 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, authenticated legacy and explicit user closes to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
@@ -949,7 +949,7 @@
{
"file": "tests/e2e/daemon-generation-reconnect-safety.spec.ts",
"assertions": [
"the production scanner discovers v21/v22/v23/v24 from v25 and every generation accepts repeated client hellos while every exact daemon, PTY-root, and descendant incarnation remains alive",
"the production scanner discovers v21/v22/v23/v24/v25 from v26 and every generation accepts repeated client hellos while every exact daemon, PTY-root, and descendant incarnation remains alive",
"desktop and two remote profiles repeat lifecycle closes before and after client relaunch with zero session-killed events",
"shutdown-dispose-failed drops named-pipe authority within the deadline and exact fixture cleanup leaves no process tree"
]
@@ -957,9 +957,9 @@
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"live, unknown, stale, missing-intent, and non-owner lifecycle closes invoke neither PTY kill nor renderer close",
"live, unknown, stale, missing-intent, non-owner, and inventory-proven but not yet pane-bound lifecycle closes invoke neither PTY kill nor renderer close",
"dead whole-headless retirement removes stale state without signalling a retained PTY id",
"explicit user close remains destructive"
"explicit and authenticated legacy user closes remain destructive"
]
},
{
@@ -1377,6 +1377,165 @@
],
"demotionRule": "Demote or quarantine if failures are non-actionable or if a duplicate resume escape occurs outside the modeled matrix."
},
{
"id": "agent-session.remote-host-authority",
"title": "Remote agent sessions have one host-authoritative PTY and durable surface lifecycle",
"maturity": "experimental",
"protection": "partial",
"owner": "agent-session",
"layer": "runtime-controller-provider-renderer-contract",
"surfaces": [
"remote agent launch and explicit resume",
"multi-client remote runtime sessions",
"daemon and relay reconnect",
"terminal exit retirement and restart restore",
"mixed-version fallback"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"daemon",
"ssh",
"remote-runtime"
],
"coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, and durable terminal retirement. The real repro runs two independent clients against one headless remote Orca runtime over the encrypted pairing path and a real daemon-backed PTY. SSH coverage is contract/fault-injection coverage; WSL and live SSH hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8878",
"https://github.com/stablyai/orca/issues/9352",
"https://github.com/stablyai/orca/pull/9687"
],
"invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
"oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, and old daemon/relay capabilities; assert safe adoption or explicit failure without a second spawn. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"pnpm test:repro:remote-agent-session"
],
"testFiles": [
"src/shared/claimed-agent-pty-owner.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts",
"src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts",
"src/main/runtime/orca-runtime-agent-session-operation.test.ts",
"src/main/runtime/remote-agent-session-host-authority.integration.test.ts",
"src/main/runtime/orca-runtime-terminal-retirement.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"src/renderer/src/runtime/web-session-tabs-sync.test.ts"
],
"assertionRefs": [
{
"file": "src/shared/claimed-agent-pty-owner.test.ts",
"assertions": [
"concurrent exact claims spawn once and later callers adopt the canonical owner",
"same identity in another worktree conflicts and cannot be found as the current scope's owner",
"generation-guarded exit and authoritative reconciliation cannot retire a replacement owner"
]
},
{
"file": "src/main/runtime/orca-runtime-agent-session-operation.test.ts",
"assertions": [
"old execution owners select exact legacy fallback before trust, spawn, or ledger mutation",
"nested SSH Pi resume selects legacy before reading the remote-only transcript path locally",
"fresh operation retries replay one result and retain a fence after an ambiguous physical commit"
]
},
{
"file": "src/main/runtime/remote-agent-session-host-authority.integration.test.ts",
"assertions": [
"independent runtime clients converge on one canonical live agent-session owner",
"retries and concurrent requests cannot create a second physical PTY"
]
},
{
"file": "src/main/runtime/orca-runtime-terminal-retirement.test.ts",
"assertions": [
"an exact PTY exit retires host membership and stale topology cannot recreate the surface",
"incarnation fencing prevents an old delayed exit from retiring a replacement PTY"
]
},
{
"file": "src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts",
"assertions": [
"a causally post-operation inventory waits out an older request and concurrent confirmations share the fresh request"
]
},
{
"file": "src/renderer/src/runtime/web-runtime-session.test.ts",
"assertions": [
"a causally post-create list confirms only the exact provisional tab and terminal-handle generation when another create is in flight"
]
},
{
"file": "src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"assertions": [
"only an exact structured-create handoff retires its provisional tab",
"an absent host tab retires its exact provisional handoff only after a causally post-create snapshot while unrelated tabs remain"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-21",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"result": "passed",
"durationSeconds": 7.67,
"summary": "Ten focused files and 325 tests passed after the final review fixes, covering claim scope, mixed-version Pi/SSH fallback ordering, operation replay, terminal retirement, causal inventory fencing, exact concurrent handoff confirmation, daemon-generation integration, transport behavior, and remote host integration."
},
{
"date": "2026-07-21",
"runner": "local",
"platform": "macos",
"command": "pnpm test:repro:remote-agent-session",
"result": "passed",
"durationSeconds": 48.47,
"summary": "The build-backed headless remote Orca harness passed over encrypted WebSocket pairing with two independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "focused ownership, compatibility, lifecycle, and renderer handoff tests; build-backed repro tracked separately"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New experimental gate with deterministic local coverage and no soak history yet."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The motivating remote-client duplicate-resume and exited-surface repros are encoded in deterministic lower-layer tests and the real remote harness; saved CI red/green artifacts are still needed."
},
"performanceBudget": {
"required": true,
"evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot."
},
"promotionCriteria": [
"Run the focused gate and remote-server repro for at least 100 consecutive passes or 14 days across required CI platforms.",
"Attach saved red/green evidence for duplicate remote resume and exit-before-snapshot retirement.",
"Add live Linux/Windows and SSH/WSL provider evidence before claiming full platform/provider coverage."
],
"knownGaps": [
"The real remote-server harness currently runs on macOS and uses a local daemon-backed execution owner; Linux and Windows runs remain uncollected.",
"SSH and relay failure ordering is deterministic contract coverage, not a live SSH-host journey; WSL has no provider-specific run.",
"Fresh-launch operation replay is memory-backed and intentionally does not survive runtime restart; a durable operation journal is a documented future extension.",
"Automatic sleep checkpoints, verified nested-SSH execution namespaces, and multi-process profile coordination remain outside v1."
],
"demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if a retry can physically spawn twice, if a stale exit/publication can replace or resurrect a terminal, or if mixed-version fallback occurs after an authority side effect."
},
{
"id": "terminal-geometry.visible-convergence",
"title": "Visible desktop terminals converge across xterm, fit, PTY, shell, and runtime mirror size",
@@ -0,0 +1,364 @@
#!/usr/bin/env node
import { execFileSync, spawn } from 'node:child_process'
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { createInterface } from 'node:readline'
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const clientScript = path.join(import.meta.dirname, 'remote-agent-session-repro-client.mjs')
const fixtureScript = path.join(import.meta.dirname, 'remote-agent-session-repro-fixture.mjs')
// Why: macOS limits Unix-domain socket paths to 104 bytes; the server profile
// creates nested daemon/runtime sockets below this disposable directory.
const scratch = mkdtempSync(path.join(os.tmpdir(), 'oa-'))
const profilePath = path.join(scratch, 'profile')
const projectPath = path.join(scratch, 'repo')
const binPath = path.join(scratch, 'bin')
const spawnMarkerPath = path.join(scratch, 'agent-spawns.txt')
const exitTriggerPath = path.join(scratch, 'exit-agent')
const childProcesses = new Set()
let server = null
try {
mkdirSync(profilePath, { recursive: true })
mkdirSync(projectPath, { recursive: true })
mkdirSync(binPath, { recursive: true })
execFileSync('git', ['init', projectPath], { stdio: 'ignore' })
execFileSync(
'git',
[
'-C',
projectPath,
'-c',
'user.name=Orca Repro',
'-c',
'user.email=orca-repro@example.invalid',
'commit',
'--allow-empty',
'-m',
'Initial repro fixture'
],
{ stdio: 'ignore' }
)
const fixtureAgentPath = installFixtureAgent(binPath)
writeFileSync(
path.join(profilePath, 'orca-data.json'),
JSON.stringify({
settings: { agentCmdOverrides: { codex: quoteFixtureAgentCommand(fixtureAgentPath) } }
})
)
const port = await reservePort()
const firstReady = await startServer(port)
const pairingCode = firstReady.pairing.url
const addedRepo = await callClient(pairingCode, 'repo.add', { path: projectPath })
assertOk(addedRepo, 'fixture repo registration')
const worktreeList = await callClient(pairingCode, 'worktree.detectedList', {
repo: `id:${addedRepo.result.repo.id}`
})
assertOk(worktreeList, 'fixture worktree discovery')
const fixtureWorktree = worktreeList.result.worktrees.find(
(candidate) => candidate.repoId === addedRepo.result.repo.id
)
if (!fixtureWorktree) {
throw new Error(
`fixture worktree was not discovered: ${JSON.stringify(worktreeList.result.worktrees)}`
)
}
const worktree = `id:${fixtureWorktree.id}`
const resumeRequest = {
kind: 'explicit',
worktree,
agent: 'codex',
providerSession: { key: 'session_id', id: 'remote-authority-repro' },
presentation: 'background'
}
const [first, second] = await Promise.all([
callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest),
callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest)
])
assertOk(first, 'first racing resume')
assertOk(second, 'second racing resume')
const dispositions = [first.result.disposition, second.result.disposition].sort()
assertJsonEqual(dispositions, ['adopted', 'created'], 'race dispositions')
assertSameTerminal(first.result.terminal, second.result.terminal)
await waitFor(() => countSpawnMarkers() === 1, 'exactly one fixture agent spawn')
const retry = await callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest)
assertOk(retry, 'resume retry')
if (retry.result.disposition !== 'adopted') {
throw new Error(`resume retry was ${retry.result.disposition}, expected adopted`)
}
assertSameTerminal(first.result.terminal, retry.result.terminal)
if (countSpawnMarkers() !== 1) {
throw new Error('resume retry started a second agent')
}
const closed = await callClient(pairingCode, 'terminal.close', {
terminal: first.result.terminal.handle
})
assertOk(closed, 'fixture terminal close')
await waitFor(async () => {
const [terminals, tabs] = await Promise.all([
callClient(pairingCode, 'terminal.list', { worktree }),
callClient(pairingCode, 'session.tabs.list', { worktree })
])
return (
terminals.ok &&
tabs.ok &&
terminals.result.terminals.length === 0 &&
tabs.result.tabs.length === 0
)
}, 'exited surface retirement')
const oldTerminal = first.result.terminal
if (oldTerminal.tabId && oldTerminal.paneKey) {
const leafId = oldTerminal.paneKey.slice(oldTerminal.paneKey.indexOf(':') + 1)
await callClient(pairingCode, 'session.tabs.updatePaneLayout', {
worktree,
tabId: oldTerminal.tabId,
root: { type: 'leaf', id: leafId, ptyId: oldTerminal.ptyId ?? undefined }
}).catch(() => null)
}
const [afterStaleTerminals, afterStaleTabs] = await Promise.all([
callClient(pairingCode, 'terminal.list', { worktree }),
callClient(pairingCode, 'session.tabs.list', { worktree })
])
assertOk(afterStaleTerminals, 'terminal list after stale publication')
assertOk(afterStaleTabs, 'tab list after stale publication')
assertJsonEqual(afterStaleTerminals.result.terminals, [], 'terminal stale-write resurrection')
assertJsonEqual(afterStaleTabs.result.tabs, [], 'tab stale-write resurrection')
await stopServer()
const restarted = await startServer(port)
const restartPairingCode = restarted.pairing.url
const [afterRestartTerminals, afterRestartTabs] = await Promise.all([
callClient(restartPairingCode, 'terminal.list', { worktree }),
callClient(restartPairingCode, 'session.tabs.list', { worktree })
])
assertOk(afterRestartTerminals, 'terminal list after restart')
assertOk(afterRestartTabs, 'tab list after restart')
assertJsonEqual(afterRestartTerminals.result.terminals, [], 'terminal resurrection after restart')
assertJsonEqual(afterRestartTabs.result.tabs, [], 'tab resurrection after restart')
process.stdout.write(
'PASS remote agent-session authority: one spawn, retry adoption, durable exit retirement, no restart resurrection\n'
)
} finally {
await stopServer().catch(() => {})
for (const child of childProcesses) {
child.kill()
}
rmSync(scratch, { recursive: true, force: true })
}
function installFixtureAgent(targetDir) {
const nodePath = process.execPath
if (process.platform === 'win32') {
const commandPath = path.join(targetDir, 'codex.cmd')
writeFileSync(commandPath, `@"${nodePath}" "${fixtureScript}" %*\r\n`)
return commandPath
}
const commandPath = path.join(targetDir, 'codex')
writeFileSync(
commandPath,
`#!/bin/sh\nexec ${shellQuote(nodePath)} ${shellQuote(fixtureScript)} "$@"\n`
)
chmodSync(commandPath, 0o755)
return commandPath
}
function quoteFixtureAgentCommand(commandPath) {
return process.platform === 'win32'
? `"${commandPath.replaceAll('"', '""')}"`
: shellQuote(commandPath)
}
function shellQuote(value) {
return `'${value.replaceAll("'", `'\\''`)}'`
}
async function reservePort() {
return await new Promise((resolve, reject) => {
const listener = net.createServer()
listener.once('error', reject)
listener.listen(0, '127.0.0.1', () => {
const address = listener.address()
const port = typeof address === 'object' && address ? address.port : 0
listener.close((error) => (error ? reject(error) : resolve(port)))
})
})
}
async function startServer(port) {
const electronPath = await import('electron').then((module) => module.default)
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'
const pathDelimiter = process.platform === 'win32' ? ';' : ':'
const env = {
...process.env,
[pathKey]: `${binPath}${pathDelimiter}${process.env[pathKey] ?? ''}`,
ORCA_DEV_USER_DATA_PATH: profilePath,
ORCA_USER_DATA_PATH: profilePath,
ORCA_REPRO_SPAWN_MARKER: spawnMarkerPath,
ORCA_REPRO_EXIT_TRIGGER: exitTriggerPath,
...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '1' } : {})
}
server = spawn(
electronPath,
[
repoRoot,
'--serve',
'--serve-json',
'--serve-port',
String(port),
'--serve-pairing-address',
`127.0.0.1:${port}`
],
{ cwd: repoRoot, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }
)
childProcesses.add(server)
let stderr = ''
server.stderr.on('data', (chunk) => {
stderr += String(chunk)
})
const lines = createInterface({ input: server.stdout })
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`server readiness timed out\n${stderr}`))
}, 30_000)
lines.on('line', (line) => {
try {
const parsed = JSON.parse(line)
if (parsed.type === 'orca_server_ready' && parsed.pairing?.url) {
clearTimeout(timeout)
resolve(parsed)
}
} catch {
// Startup diagnostics are allowed before the one structured ready line.
}
})
server.once('exit', (code) => {
clearTimeout(timeout)
reject(new Error(`server exited before readiness with code ${code}\n${stderr}`))
})
server.once('error', reject)
})
}
async function stopServer() {
const current = server
server = null
if (!current) {
return
}
childProcesses.delete(current)
if (current.exitCode !== null) {
return
}
current.kill('SIGTERM')
await new Promise((resolve) => {
const timeout = setTimeout(() => {
current.kill('SIGKILL')
resolve()
}, 8_000)
current.once('exit', () => {
clearTimeout(timeout)
resolve()
})
})
}
async function callClient(pairingCode, method, params) {
return await new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[clientScript, pairingCode, method, JSON.stringify(params)],
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }
)
childProcesses.add(child)
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout += String(chunk)
})
child.stderr.on('data', (chunk) => {
stderr += String(chunk)
})
child.once('error', reject)
child.once('exit', (code) => {
childProcesses.delete(child)
try {
const response = JSON.parse(stdout.trim())
if (code !== 0 && response.ok !== false) {
reject(new Error(`client ${method} exited ${code}: ${stderr}`))
return
}
resolve(response)
} catch (error) {
reject(
new Error(`client ${method} returned invalid JSON: ${stdout}\n${stderr}`, {
cause: error
})
)
}
})
})
}
function countSpawnMarkers() {
if (!existsSync(spawnMarkerPath)) {
return 0
}
return readFileSync(spawnMarkerPath, 'utf8').split(/\r?\n/).filter(Boolean).length
}
async function waitFor(predicate, description) {
const deadline = Date.now() + 15_000
let lastError = null
while (Date.now() < deadline) {
try {
if (await predicate()) {
return
}
} catch (error) {
lastError = error
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`timed out waiting for ${description}`, { cause: lastError })
}
function assertOk(response, description) {
if (!response?.ok) {
throw new Error(`${description} failed: ${JSON.stringify(response)}`)
}
}
function assertSameTerminal(left, right) {
assertJsonEqual(
[left.handle, left.tabId, left.paneKey, left.ptyId],
[right.handle, right.tabId, right.paneKey, right.ptyId],
'canonical terminal identity'
)
}
function assertJsonEqual(actual, expected, description) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`${description}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`
)
}
}
@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { createRequire } from 'node:module'
import path from 'node:path'
const require = createRequire(import.meta.url)
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const { parsePairingCode } = require(path.join(repoRoot, 'out', 'shared', 'pairing.js'))
const { RemoteRuntimeRequestConnection } = require(
path.join(repoRoot, 'out', 'shared', 'remote-runtime-request-connection.js')
)
const [pairingCode, method, rawParams] = process.argv.slice(2)
const pairing = pairingCode ? parsePairingCode(pairingCode) : null
if (!pairing || !method || rawParams === undefined) {
console.error('usage: remote-agent-session-repro-client <pairing> <method> <json-params>')
process.exit(2)
}
const connection = new RemoteRuntimeRequestConnection(pairing)
try {
const response = await connection.request(method, JSON.parse(rawParams), 20_000)
process.stdout.write(`${JSON.stringify(response)}\n`)
if (!response.ok) {
process.exitCode = 1
}
} finally {
connection.close()
}
@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { appendFileSync, existsSync } from 'node:fs'
const markerPath = process.env.ORCA_REPRO_SPAWN_MARKER
const exitTriggerPath = process.env.ORCA_REPRO_EXIT_TRIGGER
if (!markerPath || !exitTriggerPath) {
process.exit(2)
}
appendFileSync(markerPath, `${process.pid}:${process.ppid}\n`)
const interval = setInterval(() => {
if (!existsSync(exitTriggerPath)) {
return
}
clearInterval(interval)
try {
// Why: the agent is a child of the startup shell; terminating that shell
// produces a real PTY exit instead of merely returning to its prompt.
process.kill(process.ppid, 'SIGTERM')
} catch {
// The parent may already have exited after the trigger was observed.
}
process.exit(0)
}, 25)
process.on('SIGTERM', () => process.exit(0))
process.on('SIGINT', () => process.exit(0))
@@ -373,9 +373,10 @@ with the lifecycle request:
`killPtys:false`; a renderer-owned parent or partial split remains with its
authoritative owner. Thus a reusable tab ID or incomplete provider read
cannot become destructive authority.
6. Reasonless legacy-client closes on a new host are successful conservative
refusals with an authoritative replay. Older clients and servers therefore
degrade to a visible retained tab, not terminal loss.
6. Reasonless closes from authenticated legacy mobile or runtime clients retain
their pre-change explicit-user meaning, so upgrading only the host does not
break close. Their old lifecycle/user ambiguity remains until the client
upgrades; unattributed in-process reasonless calls are refused and replayed.
7. Renderer close intents are scoped by runtime environment and worktree, and
terminal-incarnation evidence must match that exact runtime environment.
Identical tab/worktree IDs in another profile cannot suppress or authorize
@@ -513,7 +514,7 @@ scenarios in 99.1 seconds; the practical Electron clean-relaunch check passed
again in 24.0 seconds, and mixed-version retirement/live-session preservation
passed 2/2 in 23.1 seconds.
Latest focused verification includes 14/14 host adjudication tests, 948/949
Latest focused verification includes 15/15 host adjudication tests, 948/949
focused production/RPC/renderer tests, 133/133 daemon discovery/adoption/
retirement/access-failure tests, all three typechecks, and 215
remote-runtime, multi-client, remote-server, SSH-provider, WSL-context, remote
@@ -533,8 +534,8 @@ additional contract gaps:
- a stale PTY-exit callback could borrow a replacement or sibling handle from
tab-wide state; lifecycle evidence now comes only from the exact callback PTY;
- the legacy close endpoint accepted lifecycle reasons without incarnation
evidence and refused old mobile close taps; it now accepts only explicit user
intent, with reasonless compatibility limited to authenticated mobile clients;
evidence; it now accepts only explicit user intent, with reasonless
compatibility retained for authenticated legacy mobile and runtime clients;
- keep-on-unknown preserved the PTY but could leave its client mirror hidden;
the host now republishes unchanged authority when inventory is unavailable.
@@ -543,7 +544,7 @@ the fixture protocol list collision-safe. The final native no-build run passed
both scenarios in 98.8 seconds, the fresh-build run passed in 137.1 seconds,
mixed-version retirement passed, and practical Electron restart/input passed
two scenarios with one intentionally skipped wedge scenario. Current-main
remote recovery (112 tests), focused close/reconnect suites, all 14 host
remote recovery (112 tests), focused close/reconnect suites, all 15 host
adjudication cases, all typechecks, reliability gates, max-lines, changed lint,
formatting, and diff checks pass. The full lint command remains blocked only by
pre-existing current-main switch-exhaustiveness and localization findings; no
@@ -600,7 +601,7 @@ source-text assertion that does not match CRLF on Windows.
| Reconnect bursts, app quit/relaunch, simultaneous clients, repeated IDs, profile and remote-runtime boundaries | Three router rebuilds, a full reconnect-client process exit/relaunch, parallel direct clients, and six desktop/two-profile lifecycle attempts per persisted ID are native. Environment-scoped close-intent and remote-runtime transport suites supply deterministic profile-switch/remote-server boundary proof. | Proven (native transport plus deterministic profile/provider boundaries) |
| `shutdown-dispose-failed` has a bounded non-authoritative state without conflating process death | The native refusal fixture loses pipe authority within the deadline, rejects a late client, logs the failure, proves its daemon/root/descendant still live, then cleans only exact recorded fixture incarnations. | Proven |
| Smallest immediate fix preserves legacy adoption and separates broader retirement/descendant cleanup | Additive `session.tabs.closeLifecycle`, host liveness/incarnation adjudication, no destructive fallback, and state-only dead-headless retirement leave hello/adoption, #9138/#9229 retirement, and #9704/#9752 descendant semantics unchanged. | Proven |
| Missing evidence keeps/audits; retirement is incarnation/profile safe; one owner has destructive authority | Host tests cover unavailable inventory, stale publication/handle, live split siblings, renderer ownership, and reasonless clients. Renderer tests cover exact environment handles and cross-profile intent isolation. Lifecycle requests never signal a PTY or relay renderer teardown. | Proven |
| Missing evidence keeps/audits; retirement is incarnation/profile safe; one owner has destructive authority | Host tests cover unavailable inventory, stale publication/handle, live split siblings, renderer ownership, and authenticated legacy versus unattributed reasonless callers. Renderer tests cover exact environment handles and cross-profile intent isolation. Lifecycle requests never signal a PTY or relay renderer teardown. | Proven |
| Windows identity, dead-parent/never-adopted, ACL/access failure, rapid reconnect, and multi-client behavior | Native CIM `CreationDate` identity is exact; the five-generation run covers rapid reconnect and concurrent clients. The 133-test daemon group covers never-adopted retirement, admission fencing, overlapping clients, and EACCES/EPERM process-signal failures. | Proven (native identity/reconnect; deterministic ACL failure) |
| No production PowerShell/CIM hot path, polling/listener/handle leak, or reconnect storm | Process enumeration exists only in fixture helpers; production adds no subprocess or timer. Refreshes coalesce by environment/worktree, listener ownership is unchanged, every fixture allocation has bounded cleanup, and 25-burst stress evidence is recorded above. | Proven |
| Cross-platform, SSH, WSL, remote-server, and multiple-client compatibility | 215 deterministic tests cover remote runtime/server, shared control, SSH provider, WSL host context, and PTY transport. Platform-specific fixture behavior is runtime-gated. | Deterministic proof complete; live Linux SSH/WSL unavailable |
@@ -0,0 +1,456 @@
# Remote agent-session host authority
Status: implemented single-PR v1 design for issues #8878 and #9352; deterministic validation complete.
## Reliability contract
- **Invariant (`agent-session.remote-host-authority`):** one provider-session identity has at most one live PTY owner and canonical host surface on every claim-capable route; exact exit retires that incarnation durably.
- **Failure source:** issues #8878 and #9352, including concurrent remote clients, ambiguous replies, exit-before-publication, and stale client snapshots.
- **Oracle:** the focused ownership/lifecycle matrix and `pnpm test:repro:remote-agent-session` prove one physical spawn, canonical retry adoption, exact exit retirement, stale-publication rejection, and no restart resurrection.
- **Gate:** the experimental `agent-session.remote-host-authority` entry in `config/reliability-gates.jsonc`.
- **Coverage:** deterministic macOS local/daemon/remote-runtime evidence plus SSH/relay fault-injection; Linux, Windows, WSL, and live SSH remain explicit gaps.
- **Performance budget:** no polling or terminal-output work; admission-only provider reconciliation is inflight-deduped, and operation state is capped and expiring.
- **Diagnostics:** structured RPC error codes, PTY incarnation IDs, owner generations, operation dispositions, and the repro artifact distinguish fallback, adoption, conflict, and retirement failures.
- **Residual gaps:** durable fresh-operation journaling, automatic sleep checkpoints, verified nested-SSH namespaces, and multi-process profile coordination are documented under Future extensions.
## Summary
A remote Orca host, not an attached renderer, decides whether a provider agent
session already has a live PTY. Clients send structured intent (fresh launch or
explicit provider identity); the host returns one canonical terminal surface.
This fixes two related failures:
- A paired client could consume its own persisted sleep record and launch a
second TUI while the remote host still owned the first one (#8878).
- An exited host terminal could remain in `session.tabs.list` as a handle-less
placeholder, get persisted by clients, and return as a ghost tab (#9352).
The v1 protocol deliberately fails closed after authority side effects begin.
At mixed-version boundaries the host may return
`agent_session_legacy_required`, but only after a read-only execution-owner
check and before trust, claim, spawn, or any retained replay fence. The client can then run
its retained exact legacy request, so upgrading any subset of clients, hosts,
daemons, or relays does not remove workflows that worked before the upgrade.
## Scope and guarantees
This change guarantees:
1. Runtime-owned worktrees always queue renderer sleep records into the normal
transport. A fully capable route turns that intent into an authoritative
ensure/adoption; mixed-version routes preserve the legacy wake behavior.
2. On capable hosts, known provider sessions resume through a structured
`terminal.ensureAgentSession` request.
3. On claim-capable execution routes, concurrent or repeated ensures for the
same canonical provider identity return one execution owner and one
canonical terminal surface.
4. A fresh launch uses `terminal.createAgentSession` with a caller-scoped
operation ID. Stable runtime surface identity, daemon session identity, and
relay operation identity prevent response-loss retries from creating a
second process while their respective owner remains alive.
5. New hosts continue accepting agent-bearing legacy terminal-create RPCs, so
old clients behave exactly as they did before the host upgrade.
6. Structured explicit resume returns `agent_session_legacy_required` before
side effects when a daemon is old or an SSH route cannot attest its execution
namespace. A claim-capable route still fails closed on malformed,
conflicting, or unknown ownership after dispatch.
7. Natural or explicit PTY exit retires only the exact PTY incarnation and
terminal surface from both `terminal.list` and `session.tabs.list`, repairs
active/group topology, and removes durable host persistence.
8. New clients use structured authority only when advertised. Capability
absence or a transient read-only probe failure selects the exact legacy
payload; protocol incompatibility remains blocked.
The following are not v1 guarantees:
- automatic resume of an intentionally sleeping remote agent;
- fresh-launch exactly-once behavior across a full runtime process restart;
- host-authoritative deduplication of resumes through an unverified
direct/nested SSH execution namespace (those launches retain legacy behavior);
- supervising a provider process after its owning PTY exits;
- coordinating multiple independent Orca main processes for one profile;
- preventing a nonconforming new client from deliberately sending the same
legacy wire request as an old client; authenticated request-level capability
negotiation does not yet exist, so the server cannot distinguish them.
Those constraints are explicit so future work can extend the protocol without
weakening the v1 safety boundary.
## Authority model
There are three layers:
| Layer | Responsibility |
| --------------------------------- | --------------------------------------------------------------------------------------- |
| Client/renderer | Sends structured intent and mirrors host snapshots |
| Runtime/controller | Resolves worktree and provider identity, signs a claim, publishes the canonical surface |
| Execution owner (daemon or relay) | Atomically claim-or-spawn, prove liveness, and recover live claims from listings |
The execution owner is the lowest process that can atomically answer “is there
already a live PTY for this agent identity?” Keeping the registry there closes
the race between multiple runtime calls. The controller also keeps a registry
above providers so separate local/SSH routes cannot independently claim the
same identity.
## Structured requests
### Explicit resume
`terminal.ensureAgentSession` accepts only a supported agent and normalized
provider identity:
```ts
{
kind: 'explicit'
worktree: string
agent: ResumableTuiAgent
providerSession: {
key: 'session_id' | 'conversation_id'
id: string
transcriptPath?: string
}
agentArgs?: string | null
launchPreferences?: { model?: string; effort?: string; mode?: string }
presentation?: 'focused' | 'background'
placement?: { tabId?: string; leafId?: string }
}
```
The host canonicalizes the provider identity, binds it to the execution
namespace and canonical worktree, and signs a digest claim. Raw resume commands
do not cross a claim-capable boundary; compatibility-selected legacy and
unverified nested SSH paths retain their prior opaque command behavior.
The execution owner performs one atomic operation:
```text
claim absent -> reserve -> spawn -> publish live owner -> created
claim live -> prove PTY liveness -> return canonical owner -> adopted
claim unknown -> fail closed; do not spawn
claim conflict -> fail closed; do not spawn
```
Only an adopted owner may override the requested tab, leaf, handle, or PTY ID.
A fresh provider result must match the surface requested by the host.
### Fresh launch
`terminal.createAgentSession` accepts structured agent, prompt-delivery mode,
launch preferences, optional explicit agent arguments, and a cryptographically
random client operation ID. Draft prompts remain drafts; submitted prompts use
the normal startup-delivery path. Omitted agent arguments preserve host
defaults, while an explicit string is preserved as a client override and an
explicit null/empty value clears host argument defaults. Free-form client
environment variables are deliberately not accepted because PATH, loaders, and
other process authority remain host-owned.
The runtime reserves the caller-scoped operation before any asynchronous
workspace or capability preflight, then fingerprints the host-resolved request
under the authenticated device identity:
- same caller + operation ID + same fingerprint returns `replayed`;
- same caller + operation ID + different fingerprint fails;
- malformed, future-dated, expired, or over-capacity operations fail closed.
The operation ledger is memory-bound and retained for 24 hours. Pre-spawn
failures release the entry for a safe retry. Once PTY creation commits, or the
provider reports an unknown physical outcome, the same rejected promise remains
as the replay fence. This handles response loss and ordinary reconnects to the
same running host. It does not claim exactly-once creation after the runtime
process itself restarts.
Physical commit is the native-spawn boundary, not listener registration or
surface publication. The in-process provider reports it immediately after
`node-pty` returns; daemon and relay paths report it when their lower owner
returns from spawn/create-or-attach. Commit reporting is one-shot across these
layers. Any later error retains the operation fence because the PTY may already
exist even if publication failed.
The runtime derives the execution-operation ID, tab ID, leaf ID, and terminal
handle deterministically from the authenticated operation. A daemon-backed
spawn derives a legacy-length session ID from that execution operation, so
`createOrAttach` returns the same PTY after a lost response without shrinking
the accepted worktree-ID boundary or skipping first-spawn setup. An SSH
provider performs a bounded read-only relay probe before structured work:
- a relay advertising `agentSessionCreateOperationVersion: 1` receives the
operation ID and replays one successful spawn result for 24 hours;
- an older, malformed, or temporarily unreachable relay makes the host return
`agent_session_legacy_required`, after which the client sends its unchanged
legacy payload;
- negative relay capability results are not pinned, so an in-place upgrade is
observed on the next request.
Relay operation-owned PTYs survive stale request contexts so the retry can
recover the same PTY and incarnation. Ordinary stale shell spawns keep the
existing cleanup behavior.
## Claim identity
The claim contains no raw provider session ID. A host-only signer hashes:
- normalized agent/provider identity;
- canonical worktree scope;
- execution machine and principal;
- container/runtime namespace;
- a conservative provider-root bucket. The v1 implementation deliberately
merges account roots for an agent, which can produce a safe conflict but
cannot authorize duplicate execution.
The wire binding includes a key ID, digest version, identity digest, worktree
scope digest, and agent kind. Owner state adds a random generation, PTY ID, and
canonical surface.
Generation and PTY-incarnation guards prevent a late exit or liveness result
from releasing, retiring, or adopting a replacement owner that reused the same
PTY ID.
## Recovery and failure semantics
Before every claimed ensure, the controller stages complete listings from local
and registered SSH providers, validates every owner, and atomically replaces
the authoritative portion of its registry. Absent owners are pruned only for
providers whose listing is authoritative; disconnected scopes retain their
fence. Active reservations survive reconciliation. Valid metadata also rebuilds
PTY-to-provider routing.
Controller-owned in-process fallback claims are intentionally not serialized
in ordinary local process listings. Their listing absence is therefore not
authoritative: the controller keeps the claim while the exact PTY incarnation
remains listed and releases it through the normal exit path. Daemon routers and
degraded providers likewise advertise listing authority only for a proven
PTY-to-provider route; an unknown ID never falls through to an unrelated
current/fallback provider for this decision.
Recovery is fail closed:
- owner PTY differs from the listed session: `agent_session_ownership_unknown`;
- two listings disagree about an identity or generation:
`agent_session_conflict`;
- the recorded provider is disconnected or unregistered:
`execution_owner_unavailable`;
- a claim-bearing spawn reaches a daemon/relay without execution-owner claim
protocol v2 (including PTY incarnation proof):
`agent_session_claim_unavailable`. Nested SSH routes that cannot construct a
claim are selected into legacy behavior before this boundary.
Unknown liveness never means dead. A transient relay outage therefore retains
the claim and cannot authorize a replacement agent.
Daemon adoption is attach-only. If the owner exits between liveness proof and
attach, the request fails rather than falling through to a new unclaimed shell.
Serialized relay shell state intentionally omits provider claims. Spawn-based
revival creates a new shell and cannot inherit authority from the old process.
## Renderer behavior
Runtime-owned worktrees queue cached resume evidence through the same mounted
pane transport regardless of capability-cache timing. A capable host adopts or
creates one canonical owner; an old host or execution owner receives the exact
legacy launch. A cold or expired cache therefore cannot bypass authority or
remove the pre-change workflow.
AI Vault resumes use provider metadata for agents with a structured identity,
including Antigravity conversation IDs and Pi transcript/session paths. If
metadata or host capability is absent, Orca preserves the prior opaque legacy
resume request instead of blocking the user.
Background launches, quick launches, and mounted remote panes use
`terminal.createAgentSession` on capable hosts. Otherwise each call site sends
the exact pre-change `terminal.create` or `session.tabs.createTerminal` payload.
The common router calls legacy after only these safe outcomes: the host capability
is unavailable before dispatch; the structured RPC returns the stable
pre-side-effect `agent_session_legacy_required` code; or a replaced old host
returns `method_not_found`, proving it never recognized the structured request.
Timeouts, malformed results, and every other structured error never downgrade.
Structured create/ensure responses record an exact
environment/worktree/provisional-tab to canonical-host-tab handoff. Snapshot
reconciliation removes a provisional pane only when its requested tab ID is
mirrored or that explicit handoff points to a host tab in the snapshot; agent
kind alone is never identity. This prevents an unrelated Claude/Codex session
from deleting a same-agent automatic-resume pane. The matched pane's pending
startup and automatic-resume claim are removed atomically, and the client
re-accepts the current host snapshot in case it arrived before the response.
Every structured result is host-owned. If snapshot handoff destroys the
provisional transport while create/ensure is in flight, late completion cannot
close the canonical PTY even when reconciliation is still catching up.
After the host accepts creation, a later tab-move or snapshot-refresh failure
still returns `created`. Reporting the launch as failed would invite a retry
with a new operation ID and could duplicate the fresh agent.
## Exit and persistence lifecycle
PTY exit is terminal authority. A generic persisted `sleeping` row is not a
reason to preserve a surface. Only an exact, runtime-owned stop transaction may
temporarily preserve the intentional handle-less surface. Otherwise the
runtime:
1. verifies the PTY incarnation and identifies the exact worktree/tab/leaf;
2. removes that leaf from the host snapshot;
3. removes an empty parent tab;
4. repairs split groups, active group/tab, recent order, and layout;
5. removes the terminal binding from the persisted host workspace session and
advances that repo's host topology revision;
6. synchronously flushes that retirement before publishing the in-memory
absence;
7. rebases later renderer writes onto the host's current terminal membership,
so metadata and layout edits remain writable but missing/live panes cannot
be added or removed by a stale client.
The durable fence is one monotonic revision per affected repo, not one record
per historical close or deleted worktree. Its storage is therefore proportional
to repos plus current terminal surfaces. A real host-admitted spawn advances
the revision when it adds a tab or leaf, allowing fresh terminals after a
retirement while an older renderer snapshot remains unable to revive the old
surface. Legacy per-pane tombstones are accepted for mixed-version recovery
and collapsed into the repo revision on the next normalized write.
The revision remains private to each execution host: renderer hydration and
writes omit it, avoiding collisions when different hosts contain the same repo
ID. Each host preserves the revision while rebasing client session writes.
An exit can also beat initial terminal registration. The runtime records that
PTY/incarnation before any surface exists, rejects registration of the same or
unproven incarnation before mutating provider/output sequence, execution
context, ownership, lease, binding, handle, terminal, or tab state. The native
callback or successful lower-owner return still reports physical-spawn commit
before this admission check so a lost provider response cannot authorize a
second fresh agent. A proven different incarnation, or an explicit new local
lifecycle for a provider that cannot report incarnation identity, clears the
fence. When registration rejects the recorded incarnation, that specific
caller's fence is released after rejection so repeated early-exit failures do
not accumulate process memory.
Registration intent is explicit: the controller marks the expected PTY before
dispatch and clears that intent on every success or failure path. Surface
absence is never treated as evidence that registration is still in flight.
SSH and daemon providers also settle an attach/create response against any exit
that arrived in the same transport batch before returning control upward. This
keeps response/exit ordering and incarnation comparison at the layer that can
observe both events.
“Explicitly killed” is treated as a normal terminal-gone lifecycle outcome in
the remote transport, not as an unexpected product-error toast.
## Protocol and compatibility
The runtime protocol remains v3, with minimum compatible client and server v2.
This change adds optional RPCs and fields, so a protocol fence would make a
rolling upgrade worse without providing an authorization boundary. The runtime
advertises `agent-session.host-authority.v1`; clients negotiate that capability
before choosing a launch path.
| Client | Host | Result |
| ------ | ---- | -------------------------------------------------------------------------------------------------------- |
| New | New | Structured authority is enabled when the resolved execution owner also supports it. |
| New | Old | The client selects legacy before spawn, or falls back on safe `method_not_found`; behavior is unchanged. |
| Old | New | The host still accepts legacy agent-bearing terminal creates; behavior is unchanged. |
| Old | Old | Unchanged legacy behavior. |
Capability probing is read-only. A transient probe failure may select legacy,
because no structured side effect has started. A real protocol compatibility
block is still surfaced and never bypassed. A capable host then checks the
resolved daemon or relay. Only `agent_session_legacy_required`, emitted before
trust, claim, spawn, or any retained replay fence, permits legacy; every later error stays on
the structured path. The other post-dispatch exception is `method_not_found`
from an old host, which proves the method could not have started. Host and
lower-owner unsupported verdicts are not pinned, and observing a new runtime ID
invalidates a predecessor's positive verdict, so rolling upgrades and process
replacement re-probe promptly. The SSH probe is bounded below the client RPC
timeout, concurrent callers have independent cancellation, successful
structured creates require PTY and incarnation identity, and request
cancellation is checked at the real provider seams: after asynchronous
capability/connection preflight and immediately before local native spawn,
daemon `createOrAttach`, or SSH `pty.spawn`. Once SSH dispatch begins, an
operation failure is treated as an unknown physical outcome rather than a safe
fresh retry.
The same monotonic rule applies below the runtime:
| Runtime/controller | Execution owner | Result |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------- |
| New | New daemon | Stable operation-derived session ID makes retry attach to the same PTY. |
| New | Old daemon | The host returns `legacy_required` before side effects; the client sends its exact old resume/create request. |
| New | New relay | Relay operation ledger replays the same PTY and incarnation. |
| New | Old relay | The host returns `legacy_required` before side effects; the client sends its exact old spawn request. |
| Old | New daemon/relay | New optional fields are absent, so pre-change behavior is unchanged. |
This contract is monotonic: upgrading any subset never removes a workflow that
worked before. The bug fix activates only where every authority layer required
for that specific path can prove support.
## Deterministic reproduction harness
Run:
```sh
pnpm test:repro:remote-agent-session
```
The harness builds Orca, starts a real headless Electron `orca serve` process on
an ephemeral port, and connects independent Node client processes over the
normal encrypted WebSocket pairing path. It creates and registers a real Git
repository in an isolated profile and uses the real daemon claim registry with
a controlled agent subprocess. No installed agent, external service, fixed
port, timing race, or Docker daemon is needed.
It asserts:
- two clients race the same structured resume;
- exactly one daemon subprocess is spawned;
- both clients receive the same canonical handle, tab, pane, and PTY;
- a retry that may have lost its earlier response adopts that owner;
- a real `terminal.close` produces PTY exit and both `terminal.list` and
`session.tabs.list` omit the surface;
- a stale layout publication cannot recreate the retired surface;
- restarting the serve process with the same profile cannot resurrect the
terminal or tab.
Lower-level tests separately cover daemon attach races, controller recovery,
provider disconnects, conflicting listings, old SSH relays, malformed SSH
claim results, cancellation at physical provider seams, pre-publication native
spawn failures, exact provisional handoff, early exit before registration, and
exit-driven durable retirement.
## Future extensions
### Host-owned automatic sleep checkpoints
Automatic remote sleep/resume should be added only as a host transaction:
1. persist a random, generation-bound checkpoint before stopping;
2. publish a non-connectable transition state;
3. stop and verify the exact owner;
4. commit sleeping state only after the owner is gone;
5. consume the checkpoint atomically during ensure.
Until this exists, renderer-local records may trigger resume intent but cannot
authorize a second owner on a claim-capable route. Compatibility-selected
legacy mode keeps the pre-change behavior.
### Durable fresh-operation journal
If fresh-launch exactly-once behavior must survive runtime restart, replace the
memory ledger with a profile-scoped durable journal. It must persist the caller,
operation ID, request fingerprint, canonical result/tombstone, and retention
deadline before returning success. Capacity must reject rather than evict an
unexpired tombstone.
### Verified SSH execution namespaces
Direct or nested SSH agent-session authority requires relay-attested machine,
principal, container, and provider-root identity plus a separately versioned
claim capability. Connection labels or target aliases are not proof. Until
that attestation exists, the host requests exact legacy fallback before spawn;
v1 does not claim deduplication for that route.
### Multi-process coordination
Supporting multiple Orca main processes against one profile requires an
OS-held coordinator lease around claim and journal mutation. The current v1
contract coordinates clients of one runtime/controller process and its daemon
or registered relays.
+1
View File
@@ -17,6 +17,7 @@
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
"prepare": "husky",
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
@@ -1,5 +1,7 @@
import type { TuiAgent } from '../../shared/types'
import type { ShellReadyState, TerminalSnapshot } from './types'
import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DaemonCreateOrAttachResult = {
isNew: boolean
@@ -10,6 +12,8 @@ export type DaemonCreateOrAttachResult = {
launchAgent?: TuiAgent
/** Undefined only when talking to a daemon predating WSL session context. */
wslDistro?: string | null
agentSessionEnsure?: AgentSessionClaimedSpawnResult
incarnationId?: PtyIncarnationId
}
export function getDaemonSessionResultMetadata(session: {
@@ -3,10 +3,11 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(25)
expect(PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(25)
})
})
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION
} from './daemon-protocol-version'
describe('daemon protocol version', () => {
it('ships claim and incarnation authority after startup-ingress generations', () => {
expect(PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 25 }, (_, index) => index + 1)
)
})
})
+4 -2
View File
@@ -1,10 +1,12 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
export const PROTOCOL_VERSION = 25
export const PROTOCOL_VERSION = 26
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26
export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
+140 -1
View File
@@ -129,6 +129,16 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown')
})
it('reports whether its daemon protocol can participate in agent claims', () => {
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 23 })
expect(adapter.supportsAgentSessionClaims()).toBe(true)
expect(legacy.supportsAgentSessionClaims()).toBe(false)
expect(adapter.supportsAgentSessionCreateOperations()).toBe(true)
expect(legacy.supportsAgentSessionCreateOperations()).toBe(false)
legacy.dispose()
})
afterEach(async () => {
adapter?.dispose()
await server?.shutdown()
@@ -206,6 +216,135 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
})
it('does not republish adapter state when stream exit beats the create reply', async () => {
const sessionId = 'exit-before-create-reply'
const exits: { id: string; incarnationId?: string }[] = []
adapter.onExit((payload) => exits.push(payload))
const client = (
adapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
const originalRequest = client.request.bind(client)
vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => {
const response = await originalRequest(type, payload)
if (type === 'createOrAttach') {
const exitCount = exits.length
lastSubprocess._simulateExit(0)
await waitFor(() => exits.length === exitCount + 1)
}
return response
})
await adapter.spawn({ cols: 80, rows: 24, sessionId })
await adapter.spawn({ cols: 80, rows: 24, sessionId })
expect(exits).toHaveLength(2)
expect(exits[0]?.incarnationId).toBeDefined()
expect(exits[1]?.incarnationId).toBeDefined()
expect(exits[1]?.incarnationId).not.toBe(exits[0]?.incarnationId)
const internals = adapter as unknown as {
activeSessionIds: Set<string>
sessionIncarnations: Map<string, string>
pendingSpawnOperationsBySessionId: Map<string, unknown>
}
expect(internals.activeSessionIds.has(sessionId)).toBe(false)
expect(internals.sessionIncarnations.has(sessionId)).toBe(false)
expect(internals.pendingSpawnOperationsBySessionId.has(sessionId)).toBe(false)
})
it('does not republish an adopted canonical id when its exit beats the reply', async () => {
const claim = {
digestVersion: 1 as const,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex' as const
}
const surface = {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
const canonicalId = 'canonical-claimed-session'
const first = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: canonicalId,
agentSessionEnsure: { claim, surface }
})
expect(first.agentSessionEnsure?.disposition).toBe('created')
const exits: { id: string; incarnationId?: string }[] = []
adapter.onExit((payload) => exits.push(payload))
const client = (
adapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
const originalRequest = client.request.bind(client)
vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => {
const response = await originalRequest(type, payload)
if (type === 'createOrAttach') {
const exitCount = exits.length
lastSubprocess._simulateExit(0)
await waitFor(() => exits.length === exitCount + 1)
}
return response
})
const adopted = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'different-requested-session',
agentSessionEnsure: {
claim,
surface: { ...surface, terminalHandle: 'term_retry' }
}
})
expect(adopted.id).toBe(canonicalId)
expect(adopted.agentSessionEnsure?.disposition).toBe('adopted')
expect(adapter.didExitBeforeSpawnReply(adopted)).toBe(true)
const internals = adapter as unknown as {
activeSessionIds: Set<string>
sessionIncarnations: Map<string, string>
pendingSpawnOperationsBySessionId: Map<string, unknown>
pendingClaimSpawnOperations: Set<unknown>
}
expect(internals.activeSessionIds.has(canonicalId)).toBe(false)
expect(internals.sessionIncarnations.has(canonicalId)).toBe(false)
expect(internals.pendingSpawnOperationsBySessionId.has('different-requested-session')).toBe(
false
)
expect(internals.pendingClaimSpawnOperations.size).toBe(0)
})
it('does not dispatch createOrAttach when cancellation wins during preflight', async () => {
let finishPreflight: (() => void) | undefined
const preflight = new Promise<void>((resolve) => {
finishPreflight = resolve
})
const internals = adapter as unknown as {
ensureConnected(): Promise<void>
client: { request: (...args: unknown[]) => Promise<unknown> }
}
const ensureConnected = vi
.spyOn(internals, 'ensureConnected')
.mockImplementation(() => preflight)
const request = vi.spyOn(internals.client, 'request')
const abort = new AbortController()
const spawning = adapter.spawn({ cols: 80, rows: 24, signal: abort.signal })
await waitFor(() => ensureConnected.mock.calls.length === 1)
abort.abort()
finishPreflight?.()
await expect(spawning).rejects.toThrow('client_disconnected')
expect(request).not.toHaveBeenCalledWith('createOrAttach', expect.anything())
})
it('uses worktreeId as session prefix when provided', async () => {
const result = await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' })
expect(result.id).toContain('wt-1')
@@ -656,7 +795,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastSubprocess._simulateExit(42)
await waitFor(() => exits.length > 0)
expect(exits[0]).toEqual({ id, code: 42 })
expect(exits[0]).toEqual({ id, code: 42, incarnationId: expect.any(String) })
})
})
+211 -14
View File
@@ -10,6 +10,8 @@ import { supportsPtyStartupBarrier } from './shell-ready'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PROTOCOL_VERSION,
supportsPtyStartupIngress,
@@ -20,6 +22,11 @@ import {
type SessionInfo,
type TakePendingOutputResult
} from './types'
import {
isAgentSessionClaimedSpawnResult,
isAgentSessionOwnerBinding,
type AgentSessionOwnerBinding
} from '../../shared/agent-session-host-authority'
import type {
IPtyProvider,
PtyBackgroundStreamEvent,
@@ -34,6 +41,7 @@ import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
type ColdRestorePayload = {
@@ -44,6 +52,12 @@ type ColdRestorePayload = {
oscLinks?: TerminalOscLinkRange[]
}
type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
ignoredExitIncarnationIds: Set<string>
ignoreNextExit: boolean
}
function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null {
// Why: alt-screen snapshots are the TUI buffer; prefer its normal scrollback so a dead TUI isn't revived as the fresh shell's active screen.
return restoreInfo.modes.alternateScreen
@@ -108,7 +122,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
transformed?: boolean
seq?: number
}) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
private exitListeners: ((payload: {
id: string
code: number
incarnationId?: PtyIncarnationId
}) => void)[] = []
private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
private removeEventListener: (() => void) | null = null
private initialCwds = new Map<string, string>()
@@ -119,6 +137,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
private coldRestoreCache = new Map<string, ColdRestorePayload>()
private sleepRestoreSessionIds = new Set<string>()
private activeSessionIds = new Set<string>()
private sessionIncarnations = new Map<string, string>()
private pendingSpawnOperationsBySessionId = new Map<string, Set<PendingDaemonSpawnOperation>>()
private pendingClaimSpawnOperations = new Set<PendingDaemonSpawnOperation>()
private dirtySessionVersions = new Map<string, number>()
// Why: a cold-restored session is a fresh shell atop a pre-crash log; incremental appends would be rejected on restore, so the first tick re-anchors with a full snapshot.
private sessionsNeedingFullCheckpoint = new Set<string>()
@@ -179,12 +200,54 @@ export class DaemonPtyAdapter implements IPtyProvider {
return this.historyManager
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
return this.withDaemonRetry(() => this.doSpawn(opts))
supportsAgentSessionClaims(): boolean {
return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
}
private async doSpawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
providesAgentSessionOwnerListings(_ptyId: string): boolean {
return this.supportsAgentSessionClaims()
}
supportsAgentSessionCreateOperations(): boolean {
// Why: old daemons never advertised the lower-owner protocol, so preserve their legacy launch.
return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId)
const operation = {
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
ignoredExitIncarnationIds: new Set<string>(),
ignoreNextExit: false
}
const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set()
operations.add(operation)
this.pendingSpawnOperationsBySessionId.set(sessionId, operations)
if (opts.agentSessionEnsure) {
this.pendingClaimSpawnOperations.add(operation)
}
try {
return await this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation))
} finally {
this.pendingClaimSpawnOperations.delete(operation)
operations.delete(operation)
if (operations.size === 0) {
this.pendingSpawnOperationsBySessionId.delete(sessionId)
}
}
}
private async doSpawn(
opts: PtySpawnOptions,
operation: PendingDaemonSpawnOperation
): Promise<PtySpawnResult> {
if (
opts.agentSessionEnsure &&
this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
) {
throw new Error('agent_session_claim_unavailable')
}
let sessionId = opts.sessionId!
let wslDistro = resolveWslSessionContext({
cwd: opts.cwd,
sessionId,
@@ -251,8 +314,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
? CODEX_SHELL_READY_TIMEOUT_MS
: undefined
const createOrAttach = (historySeed: string | null) =>
this.client.request<CreateOrAttachResult>('createOrAttach', {
const createOrAttach = (historySeed: string | null) => {
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
return this.client.request<CreateOrAttachResult>('createOrAttach', {
sessionId,
cols: effectiveCols,
rows: effectiveRows,
@@ -271,11 +337,31 @@ export class DaemonPtyAdapter implements IPtyProvider {
...(historySeed ? { historySeed } : {}),
...(this.supportsStartupIngress && opts.startupIngress
? { startupIngress: opts.startupIngress }
: {})
: {}),
...(opts.agentSessionEnsure ? { agentSessionEnsure: opts.agentSessionEnsure } : {})
})
}
let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
let result = await createOrAttach(scrollback)
if (opts.agentSessionEnsure && !isAgentSessionClaimedSpawnResult(result.agentSessionEnsure)) {
// Why: a claim-incapable owner may already have spawned before returning
// a malformed response; retire only this requested session before failing closed.
await this.client.request('kill', { sessionId }).catch(() => {})
throw new Error('agent_session_claim_unavailable')
}
sessionId = result.agentSessionEnsure?.owner.ptyId ?? sessionId
const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
if (exitedResult) {
return exitedResult
}
if (result.incarnationId) {
this.sessionIncarnations.set(sessionId, result.incarnationId)
}
const claimResult = (): Pick<PtySpawnResult, 'agentSessionEnsure'> | Record<string, never> =>
result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}
const incarnationResult = (): Pick<PtySpawnResult, 'incarnationId'> | Record<string, never> =>
result.incarnationId ? { incarnationId: result.incarnationId } : {}
let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
// Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons.
wslDistro = providerWslDistro ?? undefined
@@ -304,7 +390,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
coldRestore: cachedRestore,
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
@@ -318,12 +406,24 @@ export class DaemonPtyAdapter implements IPtyProvider {
restoreInfo = detectColdRestore({ ignoreCleanEnd: true })
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
if (restoreInfo && scrollback) {
// Why: the aliveness probe raced with session death, so the first create lacked recovery bytes; replace it before exposing the PTY.
// Why: the aliveness probe raced with session death, so the first
// create lacked recovery bytes. Replace it before exposing the PTY.
if (result.incarnationId) {
operation.ignoredExitIncarnationIds.add(result.incarnationId)
}
operation.ignoreNextExit = true
await this.client.request('kill', { sessionId, immediate: true })
effectiveCwd = restoreInfo.cwd
effectiveCols = restoreInfo.cols
effectiveRows = restoreInfo.rows
result = await createOrAttach(scrollback)
const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation)
if (exitedRetryResult) {
return exitedRetryResult
}
if (result.incarnationId) {
this.sessionIncarnations.set(sessionId, result.incarnationId)
}
providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro
wslDistro = providerWslDistro ?? undefined
if (wslDistro) {
@@ -363,7 +463,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.coldRestoreCache.set(sessionId, coldRestore)
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
coldRestore,
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
@@ -373,7 +475,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {})
@@ -405,7 +509,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (!isReattach || !result.snapshot) {
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
...(providerSequence ? { providerSequence } : {}),
@@ -422,7 +528,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags
return {
id: sessionId,
...incarnationResult(),
pid,
...claimResult(),
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
snapshot: snapshotPayload,
@@ -441,6 +549,36 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
private resultForExitBeforeSpawnReply(
sessionId: string,
result: CreateOrAttachResult,
operation: PendingDaemonSpawnOperation
): PtySpawnResult | null {
const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some(
(exit) =>
!(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) &&
(!exit.incarnationId ||
!result.incarnationId ||
exit.incarnationId === result.incarnationId)
)
if (!matchingExit) {
return null
}
// Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state.
const exitedResult: PtySpawnResult = {
id: sessionId,
exitedBeforeSpawnReply: true,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
return exitedResult
}
didExitBeforeSpawnReply(result: PtySpawnResult): boolean {
return result.exitedBeforeSpawnReply === true
}
async attach(id: string): Promise<void> {
await this.ensureConnected()
if (!this.supportsAuthoritativeBufferSnapshots) {
@@ -777,20 +915,42 @@ export class DaemonPtyAdapter implements IPtyProvider {
const { worktreeId } = parsePtySessionId(s.sessionId)
return {
id: s.sessionId,
...(s.incarnationId ? { incarnationId: s.incarnationId } : {}),
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
cwd: s.cwd ?? this.initialCwds.get(s.sessionId) ?? '',
title: 'shell',
...(worktreeId ? { worktreeId } : {}),
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {})
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}),
...this.validatedAgentSessionOwners(s.agentSessionOwners)
}
})
}
// Why keep both: the Manage Sessions panel needs full SessionInfo (pid/state/createdAt) that listProcesses drops for the IPtyProvider contract.
private validatedAgentSessionOwners(
owners: unknown
): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record<string, never> {
if (owners === undefined) {
return {}
}
if (!Array.isArray(owners) || !owners.every(isAgentSessionOwnerBinding)) {
throw new Error('agent_session_ownership_unknown')
}
return owners.length > 0 ? { agentSessionOwners: owners } : {}
}
// Why: the Manage Sessions panel needs the full SessionInfo (pid, state,
// createdAt) per session for display; listProcesses drops that detail for
// the IPtyProvider contract. Keep both in parallel rather than widening
// the provider surface.
async listSessions(): Promise<SessionInfo[]> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('listSessions', undefined)
return result.sessions.filter((s) => s.isAlive)
return result.sessions
.filter((s) => s.isAlive)
.map((session) => ({
...session,
...this.validatedAgentSessionOwners(session.agentSessionOwners)
}))
}
getActiveSessionIds(): string[] {
@@ -813,8 +973,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics.
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.exitListeners]) {
listener({ id, code })
listener({
id,
code,
...(this.sessionIncarnations.get(id)
? { incarnationId: this.sessionIncarnations.get(id) }
: {})
})
}
this.sessionIncarnations.delete(id)
}
}
@@ -868,7 +1035,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
return () => {}
}
onExit(callback: (payload: { id: string; code: number }) => void): () => void {
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
@@ -1350,6 +1519,29 @@ export class DaemonPtyAdapter implements IPtyProvider {
fact: event.payload
})
} else if (event.event === 'exit') {
const pendingOperations = new Set([
...(this.pendingSpawnOperationsBySessionId.get(event.sessionId) ?? []),
...this.pendingClaimSpawnOperations
])
for (const operation of pendingOperations) {
if (operation.ignoreNextExit) {
operation.ignoreNextExit = false
continue
}
const exits = operation.exitsBySessionId.get(event.sessionId) ?? []
exits.push(
event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}
)
operation.exitsBySessionId.set(event.sessionId, exits)
}
const currentIncarnationId = this.sessionIncarnations.get(event.sessionId)
if (
event.payload.incarnationId &&
currentIncarnationId &&
event.payload.incarnationId !== currentIncarnationId
) {
return
}
this.activeSessionIds.delete(event.sessionId)
this.dirtySessionVersions.delete(event.sessionId)
// Why: a reused sessionId must not inherit the dead session's owed resume (stray resumePty) or backgrounded/thinned state.
@@ -1371,9 +1563,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
this.initialCwds.delete(event.sessionId)
this.wslDistrosBySessionId.delete(event.sessionId)
this.sessionIncarnations.delete(event.sessionId)
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.exitListeners]) {
listener({ id: event.sessionId, code: event.payload.code })
listener({
id: event.sessionId,
code: event.payload.code,
...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {})
})
}
}
})
+1 -1
View File
@@ -164,7 +164,7 @@ describe('DaemonPtyProvider', () => {
lastSubprocess._simulateExit(42)
await waitFor(() => exits.length > 0)
expect(exits[0]).toEqual({ id: 's1', code: 42 })
expect(exits[0]).toEqual({ id: 's1', code: 42, incarnationId: expect.any(String) })
})
})
+16 -3
View File
@@ -1,5 +1,6 @@
import { DaemonClient } from './client'
import type { DaemonEvent, CreateOrAttachResult } from './types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DaemonPtyProviderOptions = {
socketPath: string
@@ -18,6 +19,7 @@ export type DaemonSpawnOptions = {
export type DaemonSpawnResult = {
id: string
incarnationId?: PtyIncarnationId
isNew: boolean
pid: number | null
}
@@ -25,7 +27,11 @@ export type DaemonSpawnResult = {
export class DaemonPtyProvider {
private client: DaemonClient
private dataListeners: ((payload: { id: string; data: string }) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
private exitListeners: ((payload: {
id: string
code: number
incarnationId?: PtyIncarnationId
}) => void)[] = []
private removeEventListener: (() => void) | null = null
constructor(opts: DaemonPtyProviderOptions) {
@@ -51,6 +57,7 @@ export class DaemonPtyProvider {
return {
id: opts.sessionId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
isNew: result.isNew,
pid: result.pid
}
@@ -87,7 +94,9 @@ export class DaemonPtyProvider {
}
}
onExit(callback: (payload: { id: string; code: number }) => void): () => void {
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
@@ -120,7 +129,11 @@ export class DaemonPtyProvider {
}
} else if (event.event === 'exit') {
for (const listener of this.exitListeners) {
listener({ id: event.sessionId, code: event.payload.code })
listener({
id: event.sessionId,
code: event.payload.code,
...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {})
})
}
}
})
+149 -12
View File
@@ -2,12 +2,16 @@ import { describe, expect, it, vi } from 'vitest'
import { DaemonPtyRouter } from './daemon-pty-router'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import { GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types'
import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
} from './types'
type AdapterMock = DaemonPtyAdapter & {
emitData: (id: string, data: string, sequenceChars?: number) => void
emitBackground: (event: PtyBackgroundStreamEvent) => void
emitExit: (id: string, code: number) => void
emitExit: (id: string, code: number, incarnationId?: string) => void
}
const LARGE_RECONCILE_SESSION_COUNT = 150_000
@@ -30,11 +34,18 @@ function createAdapter(
const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] =
[]
const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
const exitListeners: ((payload: { id: string; code: number }) => void)[] = []
const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] =
[]
return {
protocolVersion,
supportsGitCredentialGuardHost: () =>
protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
supportsAgentSessionClaims: () =>
protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
supportsAgentSessionCreateOperations: () =>
protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
providesAgentSessionOwnerListings: () =>
protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
canProvideAuthoritativeBufferSnapshot: () => protocolVersion >= 20,
spawn: vi.fn(async (opts: PtySpawnOptions): Promise<PtySpawnResult> => {
const id = opts.sessionId ?? `${label}-new`
@@ -94,15 +105,17 @@ function createAdapter(
}
}
}),
onExit: vi.fn((callback: (payload: { id: string; code: number }) => void) => {
exitListeners.push(callback)
return () => {
const idx = exitListeners.indexOf(callback)
if (idx !== -1) {
exitListeners.splice(idx, 1)
onExit: vi.fn(
(callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => {
exitListeners.push(callback)
return () => {
const idx = exitListeners.indexOf(callback)
if (idx !== -1) {
exitListeners.splice(idx, 1)
}
}
}
}),
),
ackColdRestore: vi.fn(),
clearTombstone: vi.fn(),
reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }),
@@ -118,9 +131,9 @@ function createAdapter(
listener(event)
}
},
emitExit: (id: string, code: number) => {
emitExit: (id: string, code: number, incarnationId?: string) => {
for (const listener of exitListeners) {
listener({ id, code })
listener({ id, code, ...(incarnationId ? { incarnationId } : {}) })
}
},
_writes: writes
@@ -128,6 +141,130 @@ function createAdapter(
}
describe('DaemonPtyRouter', () => {
it('reports separate conservative resume and fresh-create boundaries', () => {
const current = createAdapter(
'current',
[],
undefined,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION
)
const legacy = createAdapter(
'legacy',
[],
undefined,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION - 1
)
const mixed = new DaemonPtyRouter({ current, legacy: [legacy] })
const old = new DaemonPtyRouter({ current: legacy, legacy: [] })
expect(mixed.supportsAgentSessionClaims()).toBe(false)
expect(mixed.supportsAgentSessionCreateOperations()).toBe(true)
expect(old.supportsAgentSessionClaims()).toBe(false)
expect(old.supportsAgentSessionCreateOperations()).toBe(false)
})
it('only treats owner listings as authoritative for a mapped daemon route', async () => {
const current = createAdapter(
'current',
[],
undefined,
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
)
const legacy = createAdapter(
'legacy',
['legacy-session'],
undefined,
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
const created = await router.spawn({ cols: 80, rows: 24 })
expect(router.providesAgentSessionOwnerListings('legacy-session')).toBe(true)
expect(router.providesAgentSessionOwnerListings(created.id)).toBe(true)
expect(router.providesAgentSessionOwnerListings('unknown-session')).toBe(false)
})
it('does not publish a route when the adapter proves exit before reply', async () => {
const current = createAdapter('current')
let finishSpawn: ((result: PtySpawnResult) => void) | undefined
vi.mocked(current.spawn).mockImplementation(
() =>
new Promise<PtySpawnResult>((resolve) => {
finishSpawn = resolve
})
)
const router = new DaemonPtyRouter({ current, legacy: [] })
const spawning = router.spawn({ cols: 80, rows: 24, sessionId: 'raced-session' })
finishSpawn?.({
id: 'raced-session',
incarnationId: 'raced-incarnation',
exitedBeforeSpawnReply: true
})
await expect(spawning).resolves.toMatchObject({ exitedBeforeSpawnReply: true })
const internals = router as unknown as {
sessionAdapters: Map<string, DaemonPtyAdapter>
}
expect(internals.sessionAdapters.has('raced-session')).toBe(false)
})
it('routes a replacement when only an older incarnation exits during spawn', async () => {
const current = createAdapter('current')
let finishSpawn: ((result: PtySpawnResult) => void) | undefined
vi.mocked(current.spawn).mockImplementation(
() =>
new Promise<PtySpawnResult>((resolve) => {
finishSpawn = resolve
})
)
const router = new DaemonPtyRouter({ current, legacy: [] })
const spawning = router.spawn({ cols: 80, rows: 24, sessionId: 'reused-session' })
current.emitExit('reused-session', 0, 'incarnation-old')
finishSpawn?.({ id: 'reused-session', incarnationId: 'incarnation-current' })
await spawning
const internals = router as unknown as {
sessionAdapters: Map<string, DaemonPtyAdapter>
}
expect(internals.sessionAdapters.get('reused-session')).toBe(current)
})
it('preserves canonical claimed-owner exit proof from the adapter', async () => {
const current = createAdapter('current')
let finishSpawn: ((result: PtySpawnResult) => void) | undefined
vi.mocked(current.spawn).mockImplementation(
() =>
new Promise<PtySpawnResult>((resolve) => {
finishSpawn = resolve
})
)
const router = new DaemonPtyRouter({ current, legacy: [] })
const spawning = router.spawn({
cols: 80,
rows: 24,
sessionId: 'requested-session',
agentSessionEnsure: {} as never
})
finishSpawn?.({
id: 'canonical-session',
incarnationId: 'canonical-incarnation',
exitedBeforeSpawnReply: true
})
await expect(spawning).resolves.toMatchObject({
id: 'canonical-session',
exitedBeforeSpawnReply: true
})
const internals = router as unknown as {
sessionAdapters: Map<string, DaemonPtyAdapter>
}
expect(internals.sessionAdapters.has('canonical-session')).toBe(false)
})
it('reports snapshot capability for the adapter that owns each session', async () => {
const current = createAdapter('current', ['current-session'], undefined, 22)
const legacy = createAdapter('legacy', ['legacy-session'], undefined, 19)
+30 -3
View File
@@ -7,6 +7,7 @@ import type {
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
@@ -20,7 +21,11 @@ export class DaemonPtyRouter implements IPtyProvider {
transformed?: boolean
seq?: number
}) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
private exitListeners: ((payload: {
id: string
code: number
incarnationId?: PtyIncarnationId
}) => void)[] = []
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
this.current = opts.current
@@ -60,7 +65,10 @@ export class DaemonPtyRouter implements IPtyProvider {
const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined
const target = adapter ?? this.current
const result = await target.spawn(opts)
this.sessionAdapters.set(result.id, target)
// Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof.
if (!result.exitedBeforeSpawnReply) {
this.sessionAdapters.set(result.id, target)
}
return result
}
@@ -69,6 +77,23 @@ export class DaemonPtyRouter implements IPtyProvider {
return adapter.supportsGitCredentialGuardHost()
}
supportsAgentSessionClaims(): boolean {
// Why: a legacy daemon may still own a resumable PTY, so authority requires every route.
return this.allAdapters().every((adapter) => adapter.supportsAgentSessionClaims())
}
providesAgentSessionOwnerListings(ptyId: string): boolean {
const adapter = this.sessionAdapters.get(ptyId)
// Why: an unmapped id may belong to any preserved daemon generation;
// only an established route can make an omitted owner authoritative.
return adapter?.providesAgentSessionOwnerListings(ptyId) === true
}
supportsAgentSessionCreateOperations(): boolean {
// Fresh sessions always route to the current daemon; legacy adapters only retain old IDs.
return this.current.supportsAgentSessionCreateOperations()
}
async attach(id: string): Promise<void> {
await this.adapterFor(id).attach(id)
}
@@ -226,7 +251,9 @@ export class DaemonPtyRouter implements IPtyProvider {
return () => {}
}
onExit(callback: (payload: { id: string; code: number }) => void): () => void {
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
+37 -18
View File
@@ -32,6 +32,10 @@ import {
type HelloMessage,
type DaemonRequest
} from './types'
import {
isAgentSessionExecutionClaim,
isAgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
export type DaemonServerOptions = {
socketPath: string
@@ -679,8 +683,16 @@ export class DaemonServer {
}
this.createOrAttachInFlight++
const p = request.payload
let routedSessionId = p.sessionId
let result: Awaited<ReturnType<TerminalHost['createOrAttach']>>
try {
if (
p.agentSessionEnsure !== undefined &&
(!isAgentSessionExecutionClaim(p.agentSessionEnsure.claim) ||
!isAgentSessionSurfaceBinding(p.agentSessionEnsure.surface))
) {
throw new Error('agent_session_identity_required')
}
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
result = await this.host.createOrAttach({
sessionId: p.sessionId,
@@ -702,16 +714,20 @@ export class DaemonServer {
...(p.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
: {}),
...(p.agentSessionEnsure ? { agentSessionEnsure: p.agentSessionEnsure } : {}),
onSessionResolved: (sessionId) => {
routedSessionId = sessionId
},
streamClient: {
onData: (data, rawLength = data.length, transformed = false, seq) => {
// Scan BEFORE enqueue: the batcher may drop this chunk, but its facts must be captured regardless.
this.transientFactRelay.onSessionData(p.sessionId, data)
const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId)
this.transientFactRelay.onSessionData(routedSessionId, data)
const lastInputAt = this.lastInputAtBySessionId.get(routedSessionId)
const isInteractiveOutput =
data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS
this.streamDataBatcher.enqueue(clientId, p.sessionId, data, {
this.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
flushImmediately: isInteractiveOutput,
flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS,
rawLength,
@@ -719,22 +735,22 @@ export class DaemonServer {
seq
})
},
onExit: (code) => {
onExit: (code, incarnationId) => {
// Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output.
this.log.log('session-exited', { sessionId: p.sessionId, code })
this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
this.log.log('session-exited', { sessionId: routedSessionId, code })
this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
type: 'event',
event: 'exit',
sessionId: p.sessionId,
payload: { code }
sessionId: routedSessionId,
payload: { code, incarnationId }
})
this.streamDataBatcher.flush(clientId)
recordDaemonStreamBacklogEvent('sessionExit', {
sessionIdSuffix: p.sessionId.slice(-10)
sessionIdSuffix: routedSessionId.slice(-10)
})
this.transientFactRelay.onSessionExit(p.sessionId)
this.streamClientIdBySessionId.delete(p.sessionId)
this.lastInputAtBySessionId.delete(p.sessionId)
this.transientFactRelay.onSessionExit(routedSessionId)
this.streamClientIdBySessionId.delete(routedSessionId)
this.lastInputAtBySessionId.delete(routedSessionId)
this.reevaluateIdleShutdown()
}
}
@@ -743,18 +759,19 @@ export class DaemonServer {
this.createOrAttachInFlight--
this.reevaluateIdleShutdown()
}
this.streamClientIdBySessionId.set(p.sessionId, clientId)
routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId
this.streamClientIdBySessionId.set(routedSessionId, clientId)
// Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head.
if (this.transientFactRelay.isBackgrounded(p.sessionId)) {
this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, {
if (this.transientFactRelay.isBackgrounded(routedSessionId)) {
this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, {
type: 'event',
event: 'sessionBackgroundMarker',
sessionId: p.sessionId,
sessionId: routedSessionId,
payload: { background: true }
})
}
this.log.log(result.isNew ? 'session-created' : 'session-attached', {
sessionId: p.sessionId,
sessionId: routedSessionId,
pid: result.pid
})
return {
@@ -762,9 +779,11 @@ export class DaemonServer {
snapshot: result.snapshot,
pid: result.pid,
shellState: result.shellState,
incarnationId: result.incarnationId,
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
wslDistro: result.wslDistro,
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {})
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
// ─── Events (Daemon → Client, on stream socket) ────────────────────
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DataEvent = {
type: 'event'
@@ -19,7 +20,7 @@ export type ExitEvent = {
type: 'event'
event: 'exit'
sessionId: string
payload: { code: number }
payload: { code: number; incarnationId?: PtyIncarnationId }
}
export type TerminalErrorEvent = {
@@ -9,7 +9,11 @@ type ProviderMock = IPtyProvider & {
emitExit: (id: string, code: number) => void
}
function createProvider(label: string, sessions: string[] = []): ProviderMock {
function createProvider(
label: string,
sessions: string[] = [],
authoritativeOwnerListings = false
): ProviderMock {
const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] =
[]
const replayListeners: ((payload: { id: string; data: string }) => void)[] = []
@@ -22,6 +26,7 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock {
}),
attach: vi.fn(async () => {}),
hasPty: vi.fn((id: string) => sessions.includes(id)),
providesAgentSessionOwnerListings: vi.fn(() => authoritativeOwnerListings),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(async (id: string) => {
@@ -95,7 +100,7 @@ function createDaemonAdapter(
sessions: string[] = []
): DaemonPtyAdapter & ProviderMock {
return {
...createProvider(label, sessions),
...createProvider(label, sessions, true),
protocolVersion: 13,
listSessions: vi.fn(async () => []),
ackColdRestore: vi.fn(),
@@ -109,6 +114,16 @@ function createDaemonAdapter(
}
describe('DegradedDaemonPtyProvider', () => {
it('only delegates owner-listing authority to the provider that owns the id', async () => {
const current = createDaemonAdapter('daemon', ['daemon-session'])
const fallback = createProvider('fallback', [], true)
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })
await provider.discoverDaemonSessions()
expect(provider.providesAgentSessionOwnerListings('daemon-session')).toBe(true)
expect(provider.providesAgentSessionOwnerListings('unknown-session')).toBe(false)
})
it('routes fresh foreground confirmation to the session owner', async () => {
const current = createDaemonAdapter('daemon', ['daemon-session'])
const fallback = createProvider('fallback')
+17 -23
View File
@@ -10,22 +10,15 @@ import type {
PtySpawnResult
} from '../providers/types'
type ManagedPtyProvider = IPtyProvider & {
disconnectOnly?: () => Promise<void>
dispose?: () => void
}
export class DegradedDaemonPtyProvider implements IPtyProvider {
readonly routesFreshSpawnsToLocalProvider = true
// Why: the preserved daemon answers protocol but cannot spawn fresh PTYs.
// Surfaced (e.g. via pty:management:listSessions) so the UI can warn that
// new terminals are running without daemon persistence until a restart.
// Why: surface that fresh PTYs lack daemon persistence until restart.
readonly isDegraded = true
private current: DaemonPtyAdapter
private legacy: DaemonPtyAdapter[]
private fallback: ManagedPtyProvider
private sessionProviders = new Map<string, ManagedPtyProvider>()
private fallback: IPtyProvider
private sessionProviders = new Map<string, IPtyProvider>()
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: PtyDataEvent) => void)[] = []
private exitListeners: ((payload: { id: string; code: number }) => void)[] = []
@@ -33,7 +26,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
constructor(opts: {
current: DaemonPtyAdapter
legacy: DaemonPtyAdapter[]
fallback: ManagedPtyProvider
fallback: IPtyProvider
}) {
this.current = opts.current
this.legacy = opts.legacy
@@ -83,12 +76,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
hasPty(id: string): boolean {
const mapped = this.sessionProviders.get(id)
if (mapped) {
return mapped.hasPty?.(id) ?? true
}
return this.findProviderForExistingSession(id) !== null
return mapped ? (mapped.hasPty?.(id) ?? true) : this.findProviderForExistingSession(id) !== null
}
// Why: an unknown id cannot borrow listing authority from the fresh-spawn provider.
providesAgentSessionOwnerListings = (ptyId: string): boolean =>
(
this.sessionProviders.get(ptyId) ?? this.findProviderForExistingSession(ptyId)
)?.providesAgentSessionOwnerListings?.(ptyId) === true
write(id: string, data: string): void {
this.providerFor(id).write(id, data)
}
@@ -139,8 +135,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
id: string,
opts?: { scrollbackRows?: number }
): Promise<PtyProviderBufferSnapshot | null> {
// Why: a preserved legacy daemon can still thin its monitoring stream;
// recovery must reach the adapter that owns that session's full model.
// Why: recovery must reach the legacy adapter that owns the thinned session model.
return (await this.providerFor(id).getBufferSnapshot?.(id, opts)) ?? null
}
@@ -294,8 +289,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
fanoutCurrentDaemonSyntheticExits(code: number): void {
for (const id of this.getCurrentDaemonSessionIds()) {
this.sessionProviders.delete(id)
// Why: sessions discovered from listProcesses may not exist in the
// adapter's active-session set, but restart still kills that daemon.
// Why: restart kills listed sessions even when the adapter did not track them active.
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
for (const listener of [...this.exitListeners]) {
listener({ id, code })
@@ -320,7 +314,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
return this.allDaemonAdapters()
}
private providerFor(sessionId: string): ManagedPtyProvider {
private providerFor(sessionId: string): IPtyProvider {
return (
this.sessionProviders.get(sessionId) ??
this.findProviderForExistingSession(sessionId) ??
@@ -328,7 +322,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
)
}
private findProviderForExistingSession(sessionId: string): ManagedPtyProvider | null {
private findProviderForExistingSession(sessionId: string): IPtyProvider | null {
for (const provider of this.allProviders()) {
if (provider.hasPty?.(sessionId) === true) {
this.sessionProviders.set(sessionId, provider)
@@ -338,7 +332,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
return null
}
private sessionIdsForProvider(provider: ManagedPtyProvider): string[] {
private sessionIdsForProvider(provider: IPtyProvider): string[] {
return [...this.sessionProviders]
.filter(([, mappedProvider]) => mappedProvider === provider)
.map(([id]) => id)
@@ -351,7 +345,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
: null
}
private allProviders(): ManagedPtyProvider[] {
private allProviders(): IPtyProvider[] {
return [this.fallback, ...this.allDaemonAdapters()]
}
+32 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { isSafePtySessionId, mintPtySessionId, parsePtySessionId } from './pty-session-id'
import {
isSafePtySessionId,
mintPtySessionId,
parsePtySessionId,
ptySessionIdForAgentCreateOperation
} from './pty-session-id'
const USER_DATA = '/tmp/orca-userdata'
@@ -23,6 +28,32 @@ describe('mintPtySessionId', () => {
})
})
describe('ptySessionIdForAgentCreateOperation', () => {
it('derives the same daemon session for retries of one host operation', () => {
const operationId = 'a'.repeat(43)
expect(ptySessionIdForAgentCreateOperation('repo::/tmp/worktree', operationId)).toBe(
'repo::/tmp/worktree@@aaaaaaaa'
)
expect(ptySessionIdForAgentCreateOperation(undefined, operationId)).toBe('aaaaaaaa')
})
it('produces a safe session id for a path-shaped worktree', () => {
const id = ptySessionIdForAgentCreateOperation('repo::/Users/dev/worktree', 'b'.repeat(43))
expect(isSafePtySessionId(id, USER_DATA)).toBe(true)
expect(parsePtySessionId(id)).toEqual({ worktreeId: 'repo::/Users/dev/worktree' })
})
it('preserves the legacy worktree length boundary', () => {
const worktreeId = `repo::/${'w'.repeat(495)}`
const id = ptySessionIdForAgentCreateOperation(worktreeId, 'c'.repeat(43))
expect(id).toHaveLength(512)
expect(isSafePtySessionId(id, USER_DATA)).toBe(true)
})
})
describe('isSafePtySessionId', () => {
it('accepts minted UUIDs', () => {
expect(isSafePtySessionId(mintPtySessionId(), USER_DATA)).toBe(true)
+9
View File
@@ -24,6 +24,15 @@ export function mintPtySessionId(worktreeId?: string): string {
: randomUUID()
}
export function ptySessionIdForAgentCreateOperation(
worktreeId: string | undefined,
operationId: string
): string {
// Why: keep the legacy eight-character suffix budget so max-length worktree IDs still launch.
const suffix = operationId.slice(0, 8)
return worktreeId ? `${worktreeId}${PTY_SESSION_ID_SEPARATOR}${suffix}` : suffix
}
/**
* Why: `effectiveSessionId` is used as a filesystem key for provider hook
* state and legacy Pi overlay cleanup under app.getPath('userData'). The
+1 -1
View File
@@ -664,7 +664,7 @@ describe('Session', () => {
expect(onData).toHaveBeenCalledWith('late output')
expect(onExit).toHaveBeenCalledTimes(1)
expect(onExit).toHaveBeenCalledWith(23)
expect(onExit).toHaveBeenCalledWith(23, session.incarnationId)
expect(session.exitCode).toBe(23)
})
})
+5 -3
View File
@@ -11,6 +11,7 @@ import {
import { isPowerShellProcess } from '../../shared/shell-process-detection'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import type { TuiAgent } from '../../shared/types'
import { randomUUID } from 'node:crypto'
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import {
PtyStartupIngress,
@@ -92,11 +93,12 @@ export type SessionOptions = {
type AttachedClient = {
token: symbol
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
onExit: (code: number) => void
onExit: (code: number, incarnationId: string) => void
}
export class Session {
readonly sessionId: string
readonly incarnationId = randomUUID()
readonly terminalHandle: string | null
readonly launchAgent: TuiAgent | null
readonly wslDistro: string | null
@@ -525,7 +527,7 @@ export class Session {
this.emulator.dispose()
for (const client of clientsToNotify) {
client.onExit(-1)
client.onExit(-1, this.incarnationId)
}
}
@@ -670,7 +672,7 @@ export class Session {
this.disposeSubprocessHandle()
for (const client of this.attachedClients) {
client.onExit(code)
client.onExit(code, this.incarnationId)
}
// Why: hand off to the owner's reaper (disposes emulator, drops session from host map); else dead sessions accumulate.
@@ -0,0 +1,44 @@
import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
export type InternalCreateOrAttachOptions = CreateOrAttachOptions & {
agentSessionGeneration?: string
attachOnly?: boolean
}
export async function createOrAttachClaimedAgentSession(args: {
options: CreateOrAttachOptions
owners: ClaimedAgentPtyOwnerRegistry
isLive: (owner: AgentSessionOwnerBinding) => boolean
createOrAttach: (options: InternalCreateOrAttachOptions) => Promise<CreateOrAttachResult>
}): Promise<CreateOrAttachResult> {
const ensureRequest = args.options.agentSessionEnsure
if (!ensureRequest) {
return await args.createOrAttach(args.options)
}
let created: CreateOrAttachResult | null = null
const ensured = await args.owners.ensure({
claim: ensureRequest.claim,
surface: ensureRequest.surface,
spawn: async ({ generation }) => {
created = await args.createOrAttach({
...args.options,
agentSessionGeneration: generation
})
return { ptyId: args.options.sessionId }
},
isLive: args.isLive
})
if (ensured.disposition === 'created' && created) {
return { ...(created as CreateOrAttachResult), agentSessionEnsure: ensured }
}
const adopted = await args.createOrAttach({
...args.options,
sessionId: ensured.owner.ptyId,
command: undefined,
agentSessionEnsure: undefined,
attachOnly: true
})
return { ...adopted, agentSessionEnsure: ensured }
}
@@ -0,0 +1,21 @@
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
export class TerminalHostAgentSessionGenerations {
private readonly byPtyId = new Map<string, string>()
isCurrent(owner: AgentSessionOwnerBinding, isPtyLive: boolean): boolean {
return isPtyLive && this.byPtyId.get(owner.ptyId) === owner.generation
}
remember(ptyId: string, generation: string | undefined, isPtyLive: boolean): void {
if (generation && isPtyLive) {
this.byPtyId.set(ptyId, generation)
}
}
forget(ptyId: string, generation?: string): void {
if (generation === undefined || this.byPtyId.get(ptyId) === generation) {
this.byPtyId.delete(ptyId)
}
}
}
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubprocessHandle } from './session'
import { TerminalHost } from './terminal-host'
function createClaimedSubprocess(): SubprocessHandle & { exit: () => void } {
let onExit: ((code: number) => void) | null = null
return {
pid: 99_999,
getForegroundProcess: () => 'codex',
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
forceKill: vi.fn(),
signal: vi.fn(),
onData: vi.fn(),
onExit: (listener) => {
onExit = listener
},
dispose: vi.fn(),
exit: () => onExit?.(0)
}
}
describe('TerminalHost agent-session claims', () => {
let host: TerminalHost
let subprocess: ReturnType<typeof createClaimedSubprocess> | undefined
const spawnSubprocess = vi.fn(() => {
subprocess = createClaimedSubprocess()
return subprocess
})
const claim = {
digestVersion: 1 as const,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex' as const
}
const surface = {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
beforeEach(() => {
spawnSubprocess.mockClear()
host = new TerminalHost({ spawnSubprocess })
})
afterEach(async () => {
subprocess?.exit()
await host.dispose()
})
it('adopts one claimed provider session across different requested daemon ids', async () => {
const first = await host.createOrAttach({
sessionId: 'session-claimed-first',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() },
agentSessionEnsure: { claim, surface }
})
const second = await host.createOrAttach({
sessionId: 'session-claimed-retry',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() },
agentSessionEnsure: {
claim,
surface: { ...surface, terminalHandle: 'term_retry' }
}
})
expect(first.agentSessionEnsure).toMatchObject({
disposition: 'created',
owner: { ptyId: 'session-claimed-first', surface }
})
expect(second.agentSessionEnsure).toMatchObject({
disposition: 'adopted',
owner: { ptyId: 'session-claimed-first', surface }
})
expect(spawnSubprocess).toHaveBeenCalledOnce()
})
})
@@ -2,6 +2,12 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery
import type { TuiAgent } from '../../shared/types'
import type { ShellReadyState, TerminalSnapshot } from './types'
import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
import type {
AgentSessionClaimedSpawnResult,
AgentSessionExecutionClaim,
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type CreateOrAttachOptions = {
sessionId: string
@@ -21,10 +27,17 @@ export type CreateOrAttachOptions = {
shellReadyTimeoutMs?: number
historySeed?: string
startupIngress?: PtyStartupIngressIntent
agentSessionEnsure?: {
claim: AgentSessionExecutionClaim
surface: AgentSessionSurfaceBinding
}
streamClient: {
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
onExit: (code: number) => void
onExit: (code: number, incarnationId: PtyIncarnationId) => void
}
/** Lets the daemon route output under the adopted owner's canonical id before
* attaching its stream callbacks. */
onSessionResolved?: (sessionId: string) => void
}
export type CreateOrAttachResult = {
@@ -36,4 +49,6 @@ export type CreateOrAttachResult = {
launchAgent?: TuiAgent
wslDistro: string | null
attachToken: symbol
incarnationId: PtyIncarnationId
agentSessionEnsure?: AgentSessionClaimedSpawnResult
}
+2
View File
@@ -1,4 +1,5 @@
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TuiAgent } from '../../shared/types'
import type { SubprocessHandle } from './session'
import type { TakePendingOutputResult, TerminalSnapshot } from './types'
@@ -12,6 +13,7 @@ export type TerminalHostOptions = {
envToDelete?: string[]
command?: string
startupCommandDelivery?: StartupCommandDelivery
launchAgent?: TuiAgent
shellOverride?: string
terminalWindowsWslDistro?: string | null
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
@@ -0,0 +1,143 @@
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
import { normalizePtySize } from './daemon-pty-size'
import { Session } from './session'
import { shellPathSupportsPtyStartupBarrier } from './shell-ready'
import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim'
import type { CreateOrAttachResult } from './terminal-host-create-contract'
import type { TerminalHostOptions } from './terminal-host-options'
import type { TerminalHostTombstones } from './terminal-host-tombstones'
import type { TerminalSessionTeardown } from './terminal-session-teardown'
import { SessionNotFoundError } from './types'
import { resolveWslSessionContext } from './wsl-session-context'
type TerminalHostSessionCreateDependencies = {
sessions: Map<string, Session>
sessionTeardown: TerminalSessionTeardown
killedTombstones: TerminalHostTombstones
spawnSubprocess: TerminalHostOptions['spawnSubprocess']
creationFenced: boolean
onDeadSessionRemoved: (sessionId: string) => void
onSessionCreated: (sessionId: string, generation: string | undefined, isAlive: boolean) => void
onSessionExit: (sessionId: string, generation: string | undefined) => void
}
export async function createOrAttachTerminalSession(
opts: InternalCreateOrAttachOptions,
deps: TerminalHostSessionCreateDependencies
): Promise<CreateOrAttachResult> {
if (deps.creationFenced) {
throw new Error('Terminal host is shutting down')
}
opts.onSessionResolved?.(opts.sessionId)
const existing = deps.sessions.get(opts.sessionId)
// Why: descendant capture must finish before attach or recreation, or the
// caller could receive a doomed session while teardown owns its process.
if (deps.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) {
throw new SessionNotFoundError(opts.sessionId)
}
if (existing && existing.isAlive && !existing.isTerminating) {
const snapshot = existing.getSnapshot()
existing.detachAllClients()
const token = existing.attachClient(opts.streamClient)
return {
isNew: false,
snapshot,
pid: existing.pid,
shellState: existing.shellState,
incarnationId: existing.incarnationId,
...getDaemonSessionResultMetadata(existing),
attachToken: token
}
}
if (existing?.isAlive && existing.isTerminating) {
// Why: replacing a SIGKILLed-but-unreaped child could hide two live
// generations behind the same public session id.
throw new Error(`Session "${opts.sessionId}" is terminating`)
}
if (opts.attachOnly) {
// Why: an adopted claim proves only one owner generation; it must never
// turn an exit race into permission to spawn an unclaimed shell.
throw new SessionNotFoundError(opts.sessionId)
}
if (existing) {
existing.dispose()
deps.sessions.delete(opts.sessionId)
deps.onDeadSessionRemoved(opts.sessionId)
}
deps.killedTombstones.clearForCreate(opts.sessionId)
const size = normalizePtySize(opts.cols, opts.rows)
const wslDistro = resolveWslSessionContext(opts)?.distro
const subprocess = deps.spawnSubprocess({
sessionId: opts.sessionId,
cols: size.cols,
rows: size.rows,
cwd: opts.cwd,
env: opts.env,
envToDelete: opts.envToDelete,
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery,
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation
})
// Why: a fallback shell does not emit the preferred shell's ready marker;
// retaining the stale capability would indefinitely queue its first command.
const shellReadySupported =
(opts.shellReadySupported ?? false) &&
(subprocess.shellPath === undefined || shellPathSupportsPtyStartupBarrier(subprocess.shellPath))
const session = new Session({
sessionId: opts.sessionId,
cols: size.cols,
rows: size.rows,
terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE,
launchAgent: opts.launchAgent,
subprocess,
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath: subprocess.shellPath,
wslDistro
}),
shellReadySupported,
historySeed: opts.historySeed,
...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),
wslDistro,
onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration),
...(opts.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs }
: {})
})
deps.sessions.set(opts.sessionId, session)
deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive)
const token = session.attachClient(opts.streamClient)
if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) {
const submit = process.platform === 'win32' ? '\r' : '\n'
// Why: only Orca-wrapped shells advertise the paste-safe startup barrier.
session.write(
buildStartupCommandSubmission(opts.command, {
submit,
bracketedPasteSafe: shellReadySupported
})
)
}
return {
isNew: true,
snapshot: null,
pid: subprocess.pid,
shellState: session.shellState,
incarnationId: session.incarnationId,
...getDaemonSessionResultMetadata(session),
attachToken: token
}
}
@@ -0,0 +1,11 @@
import { resolveProcessCwd } from '../providers/process-cwd'
import type { Session } from './session'
export async function resolveTerminalHostSessionCwd(session: Session): Promise<string | null> {
const tracked = session.getCwd()
if (tracked) {
return tracked
}
const resolved = await resolveProcessCwd(session.pid)
return resolved || null
}
@@ -0,0 +1,31 @@
import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
import type { Session } from './session'
import type { SessionInfo } from './types'
export function listLiveTerminalHostSessions(
sessions: ReadonlyMap<string, Session>,
agentSessionOwners: ClaimedAgentPtyOwnerRegistry
): SessionInfo[] {
const result: SessionInfo[] = []
for (const session of sessions.values()) {
if (!session.isAlive) {
continue
}
const size = session.getAppliedSize()
result.push({
sessionId: session.sessionId,
incarnationId: session.incarnationId,
state: session.state,
shellState: session.shellState,
isAlive: true,
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
pid: session.pid,
cwd: session.getCwd(),
cols: size?.cols ?? 0,
rows: size?.rows ?? 0,
createdAt: 0,
agentSessionOwners: agentSessionOwners.listForPty(session.sessionId)
})
}
return result
}
@@ -0,0 +1,29 @@
export class TerminalHostTombstones {
private readonly killed = new Map<string, number>()
constructor(private readonly capacity: number) {}
clearForCreate(sessionId: string): void {
this.killed.delete(sessionId)
}
record(sessionId: string): void {
this.killed.delete(sessionId)
this.killed.set(sessionId, Date.now())
if (this.killed.size <= this.capacity) {
return
}
const oldest = this.killed.keys().next().value
if (oldest) {
this.killed.delete(oldest)
}
}
has(sessionId: string): boolean {
return this.killed.has(sessionId)
}
clear(): void {
this.killed.clear()
}
}
+43 -163
View File
@@ -1,9 +1,4 @@
import { Session } from './session'
import { normalizePtySize } from './daemon-pty-size'
import { shellPathSupportsPtyStartupBarrier } from './shell-ready'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
import { resolveProcessCwd } from '../providers/process-cwd'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import type { Session } from './session'
import {
SessionNotFoundError,
type SessionInfo,
@@ -14,8 +9,13 @@ import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-hos
import type { TerminalHostOptions } from './terminal-host-options'
import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown'
import { TerminalSessionTeardown } from './terminal-session-teardown'
import { resolveWslSessionContext } from './wsl-session-context'
import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner'
import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim'
import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations'
import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd'
import { TerminalHostTombstones } from './terminal-host-tombstones'
import { listLiveTerminalHostSessions } from './terminal-host-session-listing'
import { createOrAttachTerminalSession } from './terminal-host-session-create'
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
export type { TerminalHostOptions } from './terminal-host-options'
@@ -25,134 +25,52 @@ const DEFAULT_MAX_TOMBSTONES = 1000
export class TerminalHost {
private sessions = new Map<string, Session>()
private sessionTeardown = new TerminalSessionTeardown(this.sessions)
private killedTombstones = new Map<string, number>()
private killedTombstones: TerminalHostTombstones
private spawnSubprocess: TerminalHostOptions['spawnSubprocess']
private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint']
private maxTombstones: number
private creationFenced = false
private disposePromise: Promise<void> | null = null
private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry()
private readonly agentSessionGenerations = new TerminalHostAgentSessionGenerations()
constructor(opts: TerminalHostOptions) {
this.spawnSubprocess = opts.spawnSubprocess
this.onFinalCheckpoint = opts.onFinalCheckpoint
this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES
this.killedTombstones = new TerminalHostTombstones(this.maxTombstones)
}
/**
* Creates a terminal session or attaches to an existing live one.
*
* Startup commands are written through stdin only when the subprocess did not
* already deliver them through shell launch arguments.
*/
async createOrAttach(opts: CreateOrAttachOptions): Promise<CreateOrAttachResult> {
if (this.creationFenced) {
throw new Error('Terminal host is shutting down')
}
const existing = this.sessions.get(opts.sessionId)
// Why: async descendant capture must finish before attach/recreate, or we hand out a doomed session.
if (this.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) {
throw new SessionNotFoundError(opts.sessionId)
}
if (existing && existing.isAlive && !existing.isTerminating) {
const snapshot = existing.getSnapshot()
existing.detachAllClients()
const token = existing.attachClient(opts.streamClient)
return {
isNew: false,
snapshot,
pid: existing.pid,
shellState: existing.shellState,
...getDaemonSessionResultMetadata(existing),
attachToken: token
}
}
if (existing?.isAlive && existing.isTerminating) {
// Why: replacing a SIGKILLed-but-unreaped child would leak its native handles and hide two generations under one id.
throw new Error(`Session "${opts.sessionId}" is terminating`)
}
if (existing) {
existing.dispose()
this.sessions.delete(opts.sessionId)
}
// Clear tombstone if re-creating a killed session
this.killedTombstones.delete(opts.sessionId)
const size = normalizePtySize(opts.cols, opts.rows)
const wslDistro = resolveWslSessionContext(opts)?.distro
const subprocess = this.spawnSubprocess({
sessionId: opts.sessionId,
cols: size.cols,
rows: size.rows,
cwd: opts.cwd,
env: opts.env,
envToDelete: opts.envToDelete,
command: opts.command,
startupCommandDelivery: opts.startupCommandDelivery,
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro,
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation
})
// Why: the pre-spawn flag goes stale if spawn fell back to a shell (e.g. /bin/sh) that never emits the ready marker.
const shellReadySupported =
(opts.shellReadySupported ?? false) &&
(subprocess.shellPath === undefined ||
shellPathSupportsPtyStartupBarrier(subprocess.shellPath))
const session = new Session({
sessionId: opts.sessionId,
cols: size.cols,
rows: size.rows,
terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE,
launchAgent: opts.launchAgent,
subprocess,
ownerBackend: resolvePtyOwnerBackend({
platform: process.platform,
shellPath: subprocess.shellPath,
wslDistro
}),
shellReadySupported,
historySeed: opts.historySeed,
...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}),
wslDistro,
// Why: reap the dead session (dispose emulator + drop from map) on subprocess exit, not at daemon shutdown.
onExit: () => this.reapSession(opts.sessionId),
...(opts.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs }
: {})
})
this.sessions.set(opts.sessionId, session)
const token = session.attachClient(opts.streamClient)
if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) {
// Why: startup commands must run inside the long-lived interactive shell the daemon keeps for the pane.
// Why CR on Windows: PSReadLine/cmd.exe submit on CR; a bare LF leaves it unsubmitted (POSIX accepts CR via ICRNL).
const submit = process.platform === 'win32' ? '\r' : '\n'
// Why: bracketed-paste only for Orca-wrapped bash/zsh (== shell-ready supported); other shells use the raw submit path.
session.write(
buildStartupCommandSubmission(opts.command, {
submit,
bracketedPasteSafe: shellReadySupported
return await createOrAttachClaimedAgentSession({
options: opts,
owners: this.agentSessionOwners,
isLive: (owner) =>
this.agentSessionGenerations.isCurrent(
owner,
Boolean(this.sessions.get(owner.ptyId)?.isAlive)
),
createOrAttach: async (options) => {
if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) {
throw new Error('agent_session_claim_unavailable')
}
return await createOrAttachTerminalSession(options, {
sessions: this.sessions,
sessionTeardown: this.sessionTeardown,
killedTombstones: this.killedTombstones,
spawnSubprocess: this.spawnSubprocess,
creationFenced: this.creationFenced,
onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId),
onSessionCreated: (sessionId, generation, isAlive) =>
this.agentSessionGenerations.remember(sessionId, generation, isAlive),
onSessionExit: (sessionId, generation) => {
this.agentSessionOwners.release(sessionId, generation)
this.agentSessionGenerations.forget(sessionId, generation)
this.reapSession(sessionId)
}
})
)
}
return {
isNew: true,
snapshot: null,
pid: subprocess.pid,
shellState: session.shellState,
...getDaemonSessionResultMetadata(session),
attachToken: token
}
}
})
}
write(sessionId: string, data: string): void {
@@ -189,7 +107,7 @@ export class TerminalHost {
}
const session = this.getAliveSession(sessionId)
const killed = this.sessionTeardown.killSession(sessionId, session, opts.immediate === true)
this.recordTombstone(sessionId)
this.killedTombstones.record(sessionId)
return Promise.resolve(killed)
}
@@ -213,14 +131,7 @@ export class TerminalHost {
}
async getCwd(sessionId: string): Promise<string | null> {
const session = this.getAliveSession(sessionId)
const tracked = session.getCwd()
if (tracked) {
return tracked
}
// Why: emulator cwd stays null (Orca rcfiles emit OSC 133 not OSC 7), so fall back to the live process cwd.
const resolved = await resolveProcessCwd(session.pid)
return resolved || null
return await resolveTerminalHostSessionCwd(this.getAliveSession(sessionId))
}
// Why: null-not-throw — fetched for the tab-bar icon, so a vanished pane should quietly yield "no agent".
@@ -289,26 +200,7 @@ export class TerminalHost {
}
listSessions(): SessionInfo[] {
const result: SessionInfo[] = []
for (const [, session] of this.sessions) {
if (!session.isAlive) {
continue
}
const size = session.getAppliedSize()
result.push({
sessionId: session.sessionId,
state: session.state,
shellState: session.shellState,
isAlive: true,
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
pid: session.pid,
cwd: session.getCwd(),
cols: size?.cols ?? 0,
rows: size?.rows ?? 0,
createdAt: 0
})
}
return result
return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners)
}
dispose(): Promise<void> {
@@ -339,16 +231,4 @@ export class TerminalHost {
}
return session
}
private recordTombstone(sessionId: string): void {
this.killedTombstones.delete(sessionId)
this.killedTombstones.set(sessionId, Date.now())
if (this.killedTombstones.size > this.maxTombstones) {
const oldest = this.killedTombstones.keys().next().value
if (oldest) {
this.killedTombstones.delete(oldest)
}
}
}
}
+13
View File
@@ -12,10 +12,17 @@ export type {
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TuiAgent } from '../../shared/types'
import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
import type {
AgentSessionExecutionClaim,
AgentSessionOwnerBinding,
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
export type { TerminalModes } from './terminal-modes'
import type { TerminalSnapshot } from './terminal-snapshot'
export type { TerminalSnapshot } from './terminal-snapshot'
export {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
CLEAN_DISCONNECT_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
@@ -72,6 +79,10 @@ export type CreateOrAttachRequest = {
/** Recovered ANSI applied before the new subprocess can emit startup output. */
historySeed?: string
startupIngress?: PtyStartupIngressIntent
agentSessionEnsure?: {
claim: AgentSessionExecutionClaim
surface: AgentSessionSurfaceBinding
}
}
}
@@ -339,6 +350,7 @@ export type SystemResolverHealthResult = {
export type SessionInfo = {
sessionId: string
incarnationId?: string
state: SessionState
shellState: ShellReadyState
isAlive: boolean
@@ -348,6 +360,7 @@ export type SessionInfo = {
cols: number
rows: number
createdAt: number
agentSessionOwners?: AgentSessionOwnerBinding[]
}
// Why: SessionInfo + source protocol version, so the Manage Sessions UI can
+5
View File
@@ -41,6 +41,7 @@ import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-cl
import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint'
import { ServeReadinessPublisher } from './server/serve-readiness'
@@ -1985,6 +1986,10 @@ app.whenReady().then(async () => {
.map((account) => ({ id: account.id, managedHomePath: account.managedHomePath }))
})
const runtimeService = new OrcaRuntimeService(store, stats, {
agentSessionClaimSigner: loadAgentSessionClaimSigner(
getProfileUserDataPath(),
getProfileUserDataPath()
),
// Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3).
getLocalProvider: () => getLocalPtyProvider(),
// Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation.
+1089 -34
View File
File diff suppressed because it is too large Load Diff
+460 -48
View File
@@ -62,10 +62,14 @@ import {
SSH_SESSION_EXPIRED_ERROR,
isSshPtyIdentityMismatchError,
isSshPtyNotFoundError
} from '../providers/ssh-pty-provider'
} from '../providers/ssh-pty-errors'
import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
import { createPtySpawnTiming } from './pty-spawn-timing'
import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id'
import {
isSafePtySessionId,
mintPtySessionId,
ptySessionIdForAgentCreateOperation
} from '../daemon/pty-session-id'
import { resolveWslSessionContext } from '../daemon/wsl-session-context'
import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints'
import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe'
@@ -113,6 +117,11 @@ import {
} from '../../shared/terminal-startup-cwd'
import { isWslUncPath } from '../../shared/wsl-paths'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import {
agentSessionOwnerBindingsEqual,
ClaimedAgentPtyOwnerRegistry
} from '../../shared/claimed-agent-pty-owner'
import {
clearMigrationUnsupportedPty,
clearMigrationUnsupportedPtysForPaneKey
@@ -157,6 +166,7 @@ import {
} from '../project-groups/folder-workspace-path-status'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
// ─── Provider Registry ──────────────────────────────────────────────
// Routes PTY operations by connectionId (null = local provider).
@@ -171,6 +181,12 @@ const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000
const PRODUCER_FLOW_CONTROL_ENABLED = true
// Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider.
const ptyOwnership = new Map<string, string | null>()
const ptyIncarnationById = new Map<string, string>()
export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean {
const current = ptyIncarnationById.get(payload.id)
return !current || payload.incarnationId === current
}
// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation).
const ptySizes = new Map<string, { cols: number; rows: number }>()
// Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener.
@@ -226,7 +242,84 @@ type PaneSpawnReservationResult = {
} & Partial<PtySpawnResult>
// Why: mobile materialization and a newly-focused pane can race to spawn the same leaf; key by paneKey so the loser adopts the winner's PTY.
const paneSpawnReservationsByPaneKey = new Map<string, PaneSpawnReservation>()
// Why: PTY ids are reusable and teardown callbacks carry no incarnation token; bind the generation to its spawn result so teardown never guesses.
// Why: one main process can route the same remote provider namespace through
// multiple SSH relays; coordinate claims above every provider boundary too.
const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry()
let agentSessionOwnerReconciliation: Promise<void> | null = null
function assertSpawnReplyWasLive(result: PtySpawnResult): void {
if (!result.exitedBeforeSpawnReply) {
return
}
// Why: lower owners can resolve a different canonical id, so controller-local pending ids cannot prove this exit.
throw Object.assign(new Error('agent_session_exited_during_start'), {
agentSessionOperationOutcome: 'unknown' as const
})
}
async function reconcileAgentSessionOwnerListings(): Promise<void> {
if (agentSessionOwnerReconciliation) {
return await agentSessionOwnerReconciliation
}
const reconciliation = (async () => {
const providers: { provider: IPtyProvider; connectionId: string | null }[] = [
{ provider: localProvider, connectionId: null },
...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId }))
]
const listings = await Promise.all(
providers.map(async ({ provider, connectionId }) => ({
connectionId,
sessions: await provider.listProcesses()
}))
)
const advertisedOwners: AgentSessionOwnerBinding[] = []
const advertisedOwnerSessions: {
id: string
connectionId: string | null
incarnationId: string
}[] = []
for (const { connectionId, sessions } of listings) {
for (const session of sessions) {
const incarnationId = session.incarnationId
let hasAdvertisedOwner = false
for (const owner of session.agentSessionOwners ?? []) {
if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) {
// Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit.
throw new Error('agent_session_ownership_unknown')
}
advertisedOwners.push(owner)
hasAdvertisedOwner = true
}
if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) {
advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId })
}
}
}
agentSessionOwners.reconcileAuthoritative(advertisedOwners, {
// Why: an unregistered relay can still own a live PTY during reconnect;
// only providers that serialize claims may make listing absence authoritative.
isInAuthoritativeScope: (owner) => {
const provider = tryGetProviderForAgentSessionOwner(owner.ptyId)
return provider?.providesAgentSessionOwnerListings?.(owner.ptyId) === true
}
})
for (const session of advertisedOwnerSessions) {
ptyOwnership.set(session.id, session.connectionId)
ptyIncarnationById.set(session.id, session.incarnationId)
}
})()
agentSessionOwnerReconciliation = reconciliation
try {
await reconciliation
} finally {
if (agentSessionOwnerReconciliation === reconciliation) {
agentSessionOwnerReconciliation = null
}
}
}
// Why: bind the declaration generation directly to its spawn result. PTY ids
// are reusable and teardown callbacks carry no incarnation token, so teardown
// must never guess which pending renderer generation it owns.
const pendingPtyIdBySerializerGeneration = new Map<number, string>()
// Why: hasRendererSerializer probe needs a ptyId-keyed signal; a later spawn starts a fresh incarnation, subscription abort owns waiter cleanup.
const rendererSerializerReadiness = new RendererTerminalSerializerReadiness()
@@ -430,6 +523,16 @@ function closeStartupQueryAuthorityForPty(ptyId: string): void {
}
}
function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined {
const ownedConnectionId = ptyOwnership.get(ptyId)
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null
try {
return getProvider(parsedSshId?.connectionId ?? ownedConnectionId)
} catch {
return undefined
}
}
function normalizeNodePtySpawnError(err: unknown): Error {
const rawMessage = err instanceof Error ? err.message : String(err)
const hintedMessage = addNodePtyRecoveryHint(rawMessage)
@@ -470,6 +573,27 @@ async function isProviderPtyLive(
)
}
async function isProviderAgentSessionOwnerLive(
provider: IPtyProvider,
owner: AgentSessionOwnerBinding
): Promise<boolean> {
const session = (await provider.listProcesses()).find((candidate) => candidate.id === owner.ptyId)
if (!session) {
return false
}
if (provider.providesAgentSessionOwnerListings?.(owner.ptyId) !== true) {
// Why: in-process local owners cannot serialize the controller claim; exact incarnation
// liveness keeps that claim authoritative until the normal PTY exit releases it.
const expectedIncarnation = ptyIncarnationById.get(owner.ptyId)
return expectedIncarnation !== undefined && session.incarnationId === expectedIncarnation
}
return Boolean(
session.agentSessionOwners?.some((candidate) =>
agentSessionOwnerBindingsEqual(candidate, owner)
)
)
}
async function verifyPtyStopped(
provider: IPtyProvider,
ptyId: string,
@@ -495,13 +619,15 @@ function finishPtyShutdown(
id: string,
connectionId: string | null | undefined,
store: Store | undefined
): void {
): string | undefined {
const incarnationId = ptyIncarnationById.get(id)
clearProviderPtyState(id)
if (connectionId) {
store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated')
}
ptyOwnership.delete(id)
markClaudePtyExited(id)
return incarnationId
}
// ─── Host PTY env assembly ──────────────────────────────────────────
@@ -1098,14 +1224,16 @@ export function getPtyIdsForConnection(connectionId: string): string[] {
}
/**
* Remove all PTY ownership entries for a given connectionId.
* Why: SSH close leaves stale ownership entries that route later spawns to a dead provider and grow unbounded.
* Remove transient PTY routing entries for a disconnected connection.
* Claimed agent owners remain fenced because the relay process may survive and
* prove the exact same generation after reconnect.
*/
export function clearPtyOwnershipForConnection(connectionId: string): void {
for (const [ptyId, connId] of ptyOwnership) {
if (connId === connectionId) {
// Why: SSH close bypasses the local onExit, so sweep paneKey-scoped caches manually or they leak.
clearProviderPtyState(ptyId)
// Why: pane-scoped caches cannot route while disconnected, but claimed
// ownership must survive until reconnect makes absence authoritative.
clearProviderPtyState(ptyId, { preserveAgentSessionOwners: true })
ptyOwnership.delete(ptyId)
}
}
@@ -1113,13 +1241,22 @@ export function clearPtyOwnershipForConnection(connectionId: string): void {
// ─── Provider-scoped PTY state cleanup ──────────────────────────────
export function clearProviderPtyState(id: string): void {
// Why: OpenCode and Pi allocate PTY-scoped state outside the node-pty process table; centralizing cleanup avoids a teardown path forgetting one provider's overlay/hook state.
export function clearProviderPtyState(
id: string,
opts: { preserveAgentSessionOwners?: boolean } = {}
): void {
if (!opts.preserveAgentSessionOwners) {
agentSessionOwners.release(id)
}
// Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the
// node-pty process table. Centralizing provider cleanup avoids drift where a
// new teardown path forgets to remove one provider's overlay/hook state.
openCodeHookService.clearPty(id)
piTitlebarExtensionService.clearPty(id)
// Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards.
markClaudePtyExited(id)
ptySizes.delete(id)
ptyIncarnationById.delete(id)
lastInputAtByPty.delete(id)
interactiveOutputCharsByPty.delete(id)
activeRendererPtys.delete(id)
@@ -1178,7 +1315,17 @@ export function setPtyOwnership(id: string, connectionId: string | null): void {
ptyOwnership.set(id, connectionId)
}
// Why: store onData/onExit unsubscribers so macOS re-activation re-calling registerPtyHandlers doesn't leak duplicate listeners forwarding every event twice.
export function restorePtyIncarnation(id: string, incarnationId: string): void {
if (!isPtyIncarnationId(incarnationId)) {
throw new Error('Invalid PTY incarnation')
}
ptyIncarnationById.set(id, incarnationId)
}
// Why: localProvider.onData/onExit return unsubscribe functions. Without
// storing and calling these on re-registration, macOS app re-activation
// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking
// duplicate listeners that forward every event twice.
let localDataUnsub: (() => void) | null = null
let localExitUnsub: (() => void) | null = null
let localBackgroundStreamUnsub: (() => void) | null = null
@@ -1515,12 +1662,15 @@ export function registerPtyHandlers(
}
return env
},
onSpawned: (id) => runtime?.onPtySpawned(id),
onExit: (id, code) => {
onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId),
onExit: (id, code, incarnationId) => {
if (!isCurrentPtyExit({ id, incarnationId })) {
return
}
clearProviderPtyState(id)
ptyOwnership.delete(id)
markClaudePtyExited(id)
runtime?.onPtyExit(id, code)
runtime?.onPtyExit(id, code, incarnationId)
},
onData: (id, data, timestamp, sequenceChars, transformed) =>
runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed)
@@ -2382,8 +2532,12 @@ export function registerPtyHandlers(
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<boolean> {
let providerExitObserved = false
const expectedIncarnationId = ptyIncarnationById.get(id)
const unsubscribe = provider.onExit((payload) => {
if (payload.id === id) {
if (
payload.id === id &&
(!expectedIncarnationId || payload.incarnationId === expectedIncarnationId)
) {
providerExitObserved = true
}
})
@@ -2532,6 +2686,9 @@ export function registerPtyHandlers(
}
})
localExitUnsub = localProvider.onExit((payload) => {
if (!isCurrentPtyExit(payload)) {
return
}
if (consumeSyntheticKillExit(payload.id)) {
return
}
@@ -2539,7 +2696,7 @@ export function registerPtyHandlers(
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
runtime?.onPtyExit(payload.id, payload.code)
runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId)
}
sendPtyExitToRenderer(payload)
})
@@ -2795,14 +2952,19 @@ export function registerPtyHandlers(
!args.connectionId &&
!(provider instanceof LocalPtyProvider) &&
!routesFreshSpawnsToLocalProvider(provider)
const requestedSessionId = args.sessionId?.trim()
const callerRequestedSessionId = args.sessionId?.trim()
const requestedSessionId =
callerRequestedSessionId ??
(isDaemonHostSpawn && args.agentSessionCreateOperationId
? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId)
: undefined)
const sessionId =
requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
const effectiveSessionRelayId =
sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined
const effectiveSessionAppId =
sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined
const isMintedSessionId = requestedSessionId === undefined && isDaemonHostSpawn
const isMintedSessionId = callerRequestedSessionId === undefined && isDaemonHostSpawn
const expectedWslDistro = !args.connectionId
? (resolveWslSessionContext({
cwd,
@@ -2912,6 +3074,14 @@ export function registerPtyHandlers(
deadlineMs: 5_000
}
}
let ptySpawnCommitReported = false
const reportPtySpawnCommitted = (): void => {
if (ptySpawnCommitReported) {
return
}
ptySpawnCommitReported = true
args.onPtySpawnCommitted?.()
}
spawnOptions.envToDelete = mergePtyEnvDeletions(
mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : []
@@ -2983,6 +3153,35 @@ export function registerPtyHandlers(
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
: undefined
}
if (
args.agentSessionEnsure &&
(await (provider as IPtyProvider).supportsAgentSessionClaims?.()) === false
) {
// Why: runtime routing must select legacy before dispatch; never downgrade here after it began.
throw new Error('agent_session_claim_unavailable')
}
if (
args.agentSessionCreateOperationId &&
(await (provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false
) {
throw new Error('execution_owner_unavailable')
}
if (args.agentSessionEnsure) {
spawnOptions.agentSessionEnsure = args.agentSessionEnsure
}
if (args.agentSessionCreateOperationId) {
spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId
}
if (args.signal) {
spawnOptions.signal = args.signal
}
if (
args.onPtySpawnCommitted &&
(provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(provider))
) {
// Why: local fallback has no lower operation ledger, so commit must be reported at native spawn.
spawnOptions.onPtySpawnCommitted = reportPtySpawnCommitted
}
const existingPaneSpawn = materializedPaneKey
? paneSpawnReservationsByPaneKey.get(materializedPaneKey)
@@ -2999,6 +3198,8 @@ export function registerPtyHandlers(
? reservePaneSpawn(materializedPaneKey)
: null
let result: PtySpawnResult
let rejectedRegistrationCandidate: PtySpawnResult | null = null
let pendingRegistrationPtyId: string | null = null
let preparedProvisionalExecutionContext = false
try {
try {
@@ -3006,6 +3207,10 @@ export function registerPtyHandlers(
trustedTerminalHandleEnv.add(args.preAllocatedHandle)
}
const expectedPtyId = effectiveSessionAppId ?? sessionId
if (expectedPtyId) {
runtime?.beginPtyRegistration?.(expectedPtyId)
pendingRegistrationPtyId = expectedPtyId
}
if (isDaemonHostSpawn && expectedPtyId) {
preparedProvisionalExecutionContext =
runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, {
@@ -3016,7 +3221,93 @@ export function registerPtyHandlers(
const sequenceBeforeProviderSpawn = expectedPtyId
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
: 0
result = await provider.spawn(spawnOptions)
const assertClientStillConnected = (): void => {
if (args.signal?.aborted) {
throw new Error('client_disconnected')
}
}
if (args.agentSessionEnsure) {
// Why: daemon-backed claims can outlive this controller; import all
// proven owners before deciding that an identity is absent.
await reconcileAgentSessionOwnerListings()
const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim)
if (recoveredOwner && pendingRegistrationPtyId !== recoveredOwner.ptyId) {
if (pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId)
}
runtime?.beginPtyRegistration?.(
recoveredOwner.ptyId,
ptyIncarnationById.get(recoveredOwner.ptyId)
)
pendingRegistrationPtyId = recoveredOwner.ptyId
}
let providerResult: PtySpawnResult | null = null
const ensured = await agentSessionOwners.ensure({
claim: args.agentSessionEnsure.claim,
surface: args.agentSessionEnsure.surface,
spawn: async () => {
assertClientStillConnected()
providerResult = await provider.spawn(spawnOptions)
rejectedRegistrationCandidate = providerResult
// Why: a successful lower-owner return proves physical work committed even if admission sees an early exit.
reportPtySpawnCommitted()
assertSpawnReplyWasLive(providerResult)
runtime?.assertPtyRegistrationAllowed?.(
providerResult.id,
providerResult.incarnationId
)
if (providerResult.incarnationId) {
// Why: local providers cannot serialize controller claims, so liveness proof
// needs the exact incarnation before the registry promotes the new owner.
ptyIncarnationById.set(providerResult.id, providerResult.incarnationId)
}
const providerEnsure = providerResult.agentSessionEnsure
return {
ptyId: providerResult.id,
...(providerEnsure
? {
owner: providerEnsure.owner,
disposition: providerEnsure.disposition
}
: {})
}
},
isLive: async (owner) => {
const ownerProvider = tryGetProviderForAgentSessionOwner(owner.ptyId)
if (!ownerProvider) {
// Why: a disconnected relay may keep its PTY alive during the
// grace window; missing transport is unknown, never absence.
throw new Error('execution_owner_unavailable')
}
return await isProviderAgentSessionOwnerLive(ownerProvider, owner)
}
})
result = providerResult ?? {
id: ensured.owner.ptyId,
isReattach: true,
// Why: adoption from an authoritative listing must preserve the
// incarnation proof used to reject a delayed exit from an older process.
incarnationId: ptyIncarnationById.get(ensured.owner.ptyId)
}
result.agentSessionEnsure = ensured
} else {
assertClientStillConnected()
result = await provider.spawn(spawnOptions)
rejectedRegistrationCandidate = result
// Why: daemon/relay returns cross the physical commit boundary before controller admission.
reportPtySpawnCommitted()
assertSpawnReplyWasLive(result)
}
rejectedRegistrationCandidate ??= result
if (pendingRegistrationPtyId !== result.id) {
if (pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId)
}
runtime?.beginPtyRegistration?.(result.id, result.incarnationId)
pendingRegistrationPtyId = result.id
}
// Why: admission precedes sequence/context state and every durable publication below.
runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId)
if (result.providerSequence) {
runtime?.synchronizePtyOutputSequenceFromProvider?.(
result.id,
@@ -3039,6 +3330,19 @@ export function registerPtyHandlers(
})
}
const rawMessage = err instanceof Error ? err.message : String(err)
if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) {
runtime?.releaseRejectedPtyRegistrationFence?.(
rejectedRegistrationCandidate.id,
rejectedRegistrationCandidate.incarnationId
)
}
if (pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(
pendingRegistrationPtyId,
rejectedRegistrationCandidate?.incarnationId
)
pendingRegistrationPtyId = null
}
const spawnError = normalizeNodePtySpawnError(err)
const isIdentityMismatch =
isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage)
@@ -3072,7 +3376,28 @@ export function registerPtyHandlers(
trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
}
}
if (result.agentSessionEnsure?.disposition === 'adopted') {
const owner = result.agentSessionEnsure.owner
ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null)
runtime?.registerPreAllocatedHandleForPty(result.id, owner.surface.terminalHandle)
if (result.incarnationId) {
ptyIncarnationById.set(result.id, result.incarnationId)
}
runtime?.registerPty(result.id, owner.surface.worktreeId, args.connectionId ?? null, {
tabId: owner.surface.tabId,
leafId: owner.surface.leafId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
return {
id: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
agentSessionEnsure: result.agentSessionEnsure
}
}
ptyOwnership.set(result.id, args.connectionId ?? null)
if (result.incarnationId) {
ptyIncarnationById.set(result.id, result.incarnationId)
}
// Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero.
if (
isNativeWindowsLocalPtySpawn({
@@ -3115,6 +3440,7 @@ export function registerPtyHandlers(
tabId: hostSessionBinding.tabId,
leafId: hostSessionBinding.leafId,
ptyId: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(cwd ? { startupCwd: cwd } : {})
})
} catch (err) {
@@ -3128,7 +3454,9 @@ export function registerPtyHandlers(
}
clearProviderPtyState(result.id)
}
throw new Error(createTerminalSessionStateSaveFailureMessage())
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
agentSessionOperationOutcome: 'unknown' as const
})
}
persistSshLease()
}
@@ -3145,12 +3473,19 @@ export function registerPtyHandlers(
isValidTerminalTabId(args.tabId) &&
args.tabId.length <= 512 &&
metadataLeafId !== null
? { tabId: args.tabId, leafId: metadataLeafId }
? {
tabId: args.tabId,
leafId: metadataLeafId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
}
: undefined,
!args.connectionId
? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd)
: undefined
)
} else {
// Why: non-worktree PTYs have no later surface-registration phase to clear admission intent.
runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId)
}
// Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity).
runtime?.noteTerminalSpawnCommand?.(result.id, args.command ?? null)
@@ -3193,10 +3528,26 @@ export function registerPtyHandlers(
: null
})
}
const response = { id: result.id }
const response = {
id: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
}
return resolvePaneSpawnReservation(materializedPaneKey, paneSpawnReservation, response)
} catch (err) {
// Why: any later throw must settle the reservation, or it lingers and every future spawn for this pane awaits a promise that never resolves (reject no-ops if already resolved).
if (pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(
pendingRegistrationPtyId,
rejectedRegistrationCandidate?.incarnationId
)
pendingRegistrationPtyId = null
}
// Why: once the reservation is created, any later throw — spawn
// failure, persist failure, or a post-spawn helper such as
// registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise
// it lingers in paneSpawnReservationsByPaneKey and every future spawn
// for this pane awaits a promise that never resolves. reject is a
// no-op once the reservation has already resolved.
rejectPaneSpawnReservation(materializedPaneKey, paneSpawnReservation, err)
throw err
} finally {
@@ -3222,9 +3573,11 @@ export function registerPtyHandlers(
provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId)
} catch {
if (connectionId) {
// Why: runtime/CLI close can target a detached SSH PTY after its provider was unregistered; tombstone the lease so reconnect can't revive it.
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
// Why: runtime/CLI close can target a detached SSH PTY after its
// provider was unregistered. Tombstone the lease so reconnect does
// not revive a terminal the user explicitly closed.
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return true
@@ -3234,17 +3587,17 @@ export function registerPtyHandlers(
// Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit.
void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
.then((providerExitObserved) => {
finishPtyShutdown(ptyId, connectionId, store)
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
if (!providerExitObserved) {
runtime?.onPtyExit(ptyId, -1)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
})
.catch((err) => {
if (isPtyAlreadyGoneError(err)) {
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return
@@ -3252,8 +3605,9 @@ export function registerPtyHandlers(
console.warn(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
// Why: close runtime tails but keep provider ownership so a retry can still target a PTY that survived the failed shutdown.
runtime?.onPtyExit(ptyId, -1)
// Why: close runtime tails without clearing provider ownership, so
// a retry can still target a PTY that survived the failed shutdown.
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
})
return true
}
@@ -3264,7 +3618,7 @@ export function registerPtyHandlers(
console.warn(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
runtime?.onPtyExit(ptyId, -1)
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
})
return true
}
@@ -3306,9 +3660,10 @@ export function registerPtyHandlers(
provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId)
} catch {
if (connectionId) {
// Why: an absent SSH provider means no live target to await, but the relay lease must still be tombstoned.
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
// Why: an absent SSH provider means there is no live target left to
// await, but the relay lease must still be tombstoned.
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return true
@@ -3342,9 +3697,9 @@ export function registerPtyHandlers(
)
return false
}
finishPtyShutdown(ptyId, connectionId, store)
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
if (!providerExitObserved) {
runtime?.onPtyExit(ptyId, -1)
runtime?.onPtyExit(ptyId, -1, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
}
@@ -3947,6 +4302,8 @@ export function registerPtyHandlers(
markHiddenRendererPty(preSpawnHiddenMarkId)
}
let result: PtySpawnResult
let rejectedRegistrationCandidate: PtySpawnResult | null = null
let pendingRegistrationPtyId: string | null = null
let preparedProvisionalExecutionContext = false
try {
try {
@@ -3955,6 +4312,10 @@ export function registerPtyHandlers(
}
spawnTiming.mark('options')
const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId
if (expectedPtyId) {
runtime?.beginPtyRegistration?.(expectedPtyId)
pendingRegistrationPtyId = expectedPtyId
}
if (isDaemonHostSpawn && expectedPtyId) {
preparedProvisionalExecutionContext =
runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, {
@@ -3966,6 +4327,16 @@ export function registerPtyHandlers(
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
: 0
result = await provider.spawn(spawnOptions)
rejectedRegistrationCandidate = result
if (pendingRegistrationPtyId !== result.id) {
if (pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId)
}
runtime?.beginPtyRegistration?.(result.id, result.incarnationId)
pendingRegistrationPtyId = result.id
}
assertSpawnReplyWasLive(result)
runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId)
if (result.providerSequence) {
runtime?.synchronizePtyOutputSequenceFromProvider?.(
result.id,
@@ -3993,6 +4364,19 @@ export function registerPtyHandlers(
unmarkHiddenRendererPty(preSpawnHiddenMarkId)
}
const rawMessage = err instanceof Error ? err.message : String(err)
if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) {
runtime?.releaseRejectedPtyRegistrationFence?.(
rejectedRegistrationCandidate.id,
rejectedRegistrationCandidate.incarnationId
)
}
if (pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(
pendingRegistrationPtyId,
rejectedRegistrationCandidate?.incarnationId
)
pendingRegistrationPtyId = null
}
const spawnError = normalizeNodePtySpawnError(err)
const isIdentityMismatch =
isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage)
@@ -4051,6 +4435,9 @@ export function registerPtyHandlers(
reattach: result.isReattach ?? false
})
ptyOwnership.set(result.id, args.connectionId ?? null)
if (result.incarnationId) {
ptyIncarnationById.set(result.id, result.incarnationId)
}
if (initiallyHidden) {
// Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn).
markHiddenRendererPty(result.id)
@@ -4085,7 +4472,6 @@ export function registerPtyHandlers(
ptySizes.set(result.id, { cols: args.cols, rows: args.rows })
// Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217).
if (
(isDaemonHostSpawn || args.connectionId) &&
store &&
typeof args.worktreeId === 'string' &&
typeof args.tabId === 'string' &&
@@ -4097,6 +4483,7 @@ export function registerPtyHandlers(
tabId: args.tabId,
leafId: validatedLeafId,
ptyId: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
...(cwd ? { startupCwd: cwd } : {})
})
} catch (err) {
@@ -4113,7 +4500,9 @@ export function registerPtyHandlers(
if (!result.isReattach && args.connectionId && store) {
store.removeSshRemotePtyLease(args.connectionId, relayResultId)
}
throw new Error(createTerminalSessionStateSaveFailureMessage())
throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), {
agentSessionOperationOutcome: 'unknown' as const
})
}
}
// Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md.
@@ -4186,12 +4575,20 @@ export function registerPtyHandlers(
isValidTerminalTabId(args.tabId) &&
args.tabId.length <= 512 &&
metadataLeafId !== null
? { tabId: args.tabId, leafId: metadataLeafId }
? {
tabId: args.tabId,
leafId: metadataLeafId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
}
: undefined,
!args.connectionId
? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd)
: undefined
)
pendingRegistrationPtyId = null
} else if (pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId, result.incarnationId)
pendingRegistrationPtyId = null
}
// Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one.
runtime?.noteTerminalSpawnCommand?.(
@@ -4270,7 +4667,19 @@ export function registerPtyHandlers(
}
return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response)
} catch (err) {
// Why: any later throw must settle the reservation, else it lingers and every future spawn for this pane awaits a promise that never resolves (reject no-ops if already resolved).
if (pendingRegistrationPtyId) {
runtime?.cancelPendingPtyRegistration?.(
pendingRegistrationPtyId,
rejectedRegistrationCandidate?.incarnationId
)
pendingRegistrationPtyId = null
}
// Why: once the reservation is created, any later throw —
// spawn failure, persist failure, or a post-spawn helper such as
// seedHeadlessTerminal/registerPty/track — must settle it. Otherwise
// it lingers in paneSpawnReservationsByPaneKey and every future spawn
// for this pane awaits a promise that never resolves. reject is a
// no-op once the reservation has already resolved.
rejectPaneSpawnReservation(reservationPaneKey, paneSpawnReservation, err)
throw err
} finally {
@@ -4753,9 +5162,11 @@ export function registerPtyHandlers(
}
const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id)
if (!provider && connectionId) {
// Why: detached SSH PTYs keep ownership after provider unregister, and hydrated app-scoped ids may arrive pre-ownership; tombstone instead of falling back local.
finishPtyShutdown(args.id, connectionId, store)
runtime?.onPtyExit(args.id, -1)
// Why: detached SSH PTYs intentionally keep ownership after their
// provider is unregistered; hydrated app-scoped ids can also arrive
// before ownership is rebuilt. Tombstone instead of falling back local.
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
runtime?.onPtyExit(args.id, -1, incarnationId)
rememberSyntheticKillExit(args.id)
sendPtyExitToRenderer({ id: args.id, code: -1 })
return
@@ -4774,10 +5185,11 @@ export function registerPtyHandlers(
}
/* session already dead — cleanup below handles the rest */
}
// Why: some shutdown paths don't emit onExit via the provider listener; this cleanup is idempotent and covers already-dead PTYs.
finishPtyShutdown(args.id, connectionId, store)
// Why: some shutdown paths do not emit onExit through the provider listener.
// Explicit cleanup is idempotent and covers already-dead PTYs.
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
if (!providerExitObserved) {
runtime?.onPtyExit(args.id, -1)
runtime?.onPtyExit(args.id, -1, incarnationId)
rememberSyntheticKillExit(args.id)
sendPtyExitToRenderer({ id: args.id, code: -1 })
}
+3 -2
View File
@@ -163,6 +163,7 @@ vi.mock('./pty', () => ({
setPtyOwnership: vi.fn(),
getSshPtyProvider: vi.fn(),
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
isCurrentPtyExit: vi.fn().mockReturnValue(true),
isRendererPtyOutputPaused: vi.fn().mockReturnValue(false)
}))
@@ -815,7 +816,7 @@ describe('SSH IPC handlers', () => {
'hello'.length,
undefined
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7, undefined)
})
it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => {
@@ -1192,7 +1193,7 @@ describe('SSH IPC handlers', () => {
'hello'.length,
undefined
)
expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9)
expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9, undefined)
expect(firstRuntime.onPtyData).not.toHaveBeenCalled()
expect(firstRuntime.onPtyExit).not.toHaveBeenCalled()
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(
+1 -1
View File
@@ -19,7 +19,7 @@ import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host'
import { isAuthError } from '../ssh/ssh-connection-utils'
import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset'
import { isSshPtyNotFoundError } from '../providers/ssh-pty-provider'
import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
import { registerSshBrowseHandler } from './ssh-browse'
import {
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import {
mergeWorkspaceSessions,
removeRepoFromWorkspaceSession
} from './profile-project-session-state'
import { extractSessionForTransfer } from './profile-project-session-transfer'
const REMOVED_WORKTREE_ID = 'repo-a::/removed'
const RETAINED_WORKTREE_ID = 'repo-b::/retained'
const REMOVED_REPO_ID = 'repo-a'
const RETAINED_REPO_ID = 'repo-b'
describe('profile project session state', () => {
it('keeps topology revisions monotonic while merging session authority records', () => {
const base = {
...getDefaultWorkspaceSession(),
terminalTopologyRevisionByRepoId: { [REMOVED_REPO_ID]: 5 },
terminalPtyIncarnationsByPaneKey: { 'base-tab:leaf': 'base-incarnation' }
}
const incoming = {
...getDefaultWorkspaceSession(),
terminalTopologyRevisionByRepoId: {
[REMOVED_REPO_ID]: 3,
[RETAINED_REPO_ID]: 7
},
terminalPtyIncarnationsByPaneKey: { 'incoming-tab:leaf': 'incoming-incarnation' }
}
const result = mergeWorkspaceSessions(base, incoming)
expect(result.terminalTopologyRevisionByRepoId).toEqual({
[REMOVED_REPO_ID]: 5,
[RETAINED_REPO_ID]: 7
})
expect(result.terminalPtyIncarnationsByPaneKey).toEqual({
'base-tab:leaf': 'base-incarnation',
'incoming-tab:leaf': 'incoming-incarnation'
})
})
it('rekeys terminal membership authority during project transfer', () => {
const session = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[REMOVED_WORKTREE_ID]: [
{
id: 'transferred-tab',
worktreeId: REMOVED_WORKTREE_ID,
title: 'Transferred',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'transferred-pty'
}
]
},
terminalTopologyRevisionByRepoId: { [REMOVED_REPO_ID]: 6 },
terminalPtyIncarnationsByPaneKey: {
'transferred-tab:leaf': 'transferred-incarnation'
},
terminalSurfaceTombstonesByPaneKey: {
'retired-tab:leaf': {
worktreeId: REMOVED_WORKTREE_ID,
parentTabId: 'retired-tab',
leafId: 'leaf',
ptyId: 'retired-pty',
incarnationId: 'retired-incarnation',
retiredAt: 1
}
}
}
const result = extractSessionForTransfer(session, 'repo-a', 'repo-c')
const transferredWorktreeId = 'repo-c::/removed'
expect(result.terminalTopologyRevisionByRepoId).toEqual({ 'repo-c': 6 })
expect(result.terminalPtyIncarnationsByPaneKey).toEqual({
'transferred-tab:leaf': 'transferred-incarnation'
})
expect(result.terminalSurfaceTombstonesByPaneKey?.['retired-tab:leaf']?.worktreeId).toBe(
transferredWorktreeId
)
})
it('prunes terminal membership authority records with a removed repo', () => {
const session = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[REMOVED_WORKTREE_ID]: [
{
id: 'removed-tab',
worktreeId: REMOVED_WORKTREE_ID,
title: 'Removed',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'removed-pty'
}
],
[RETAINED_WORKTREE_ID]: [
{
id: 'retained-tab',
worktreeId: RETAINED_WORKTREE_ID,
title: 'Retained',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'retained-pty'
}
]
},
terminalTopologyRevisionByRepoId: {
[REMOVED_REPO_ID]: 3,
[RETAINED_REPO_ID]: 4
},
terminalSurfaceTombstonesByPaneKey: {
'removed-tab:removed-leaf': {
worktreeId: REMOVED_WORKTREE_ID,
parentTabId: 'removed-tab',
leafId: 'removed-leaf',
ptyId: 'removed-pty',
incarnationId: 'removed-incarnation',
retiredAt: 1
},
'retained-tab:retained-leaf': {
worktreeId: RETAINED_WORKTREE_ID,
parentTabId: 'retained-tab',
leafId: 'retained-leaf',
ptyId: 'retained-pty',
incarnationId: 'retained-incarnation',
retiredAt: 2
}
},
terminalPtyIncarnationsByPaneKey: {
'removed-tab:removed-leaf': 'removed-incarnation',
'retained-tab:retained-leaf': 'retained-incarnation'
}
}
const result = removeRepoFromWorkspaceSession(session, 'repo-a')
expect(result.terminalTopologyRevisionByRepoId).toEqual({
[RETAINED_REPO_ID]: 4
})
expect(result.terminalSurfaceTombstonesByPaneKey).toEqual({
'retained-tab:retained-leaf':
session.terminalSurfaceTombstonesByPaneKey['retained-tab:retained-leaf']
})
expect(result.terminalPtyIncarnationsByPaneKey).toEqual({
'retained-tab:retained-leaf': 'retained-incarnation'
})
})
})
@@ -8,6 +8,17 @@ import {
removeRepoWorktreeRecord
} from './profile-project-worktree-identity'
function mergeTerminalTopologyRevisions(
base: Record<string, number> | undefined,
incoming: Record<string, number> | undefined
): Record<string, number> {
const merged = { ...base }
for (const [worktreeId, revision] of Object.entries(incoming ?? {})) {
merged[worktreeId] = Math.max(merged[worktreeId] ?? 0, revision)
}
return merged
}
export function mergeHostWorkspaceSessions(
existing: Partial<Record<ExecutionHostId, WorkspaceSessionState>> | undefined,
incoming: Partial<Record<ExecutionHostId, WorkspaceSessionState>>
@@ -74,6 +85,18 @@ export function mergeWorkspaceSessions(
...base.defaultTerminalTabsAppliedByWorktreeId,
...incoming.defaultTerminalTabsAppliedByWorktreeId
},
terminalPtyIncarnationsByPaneKey: {
...base.terminalPtyIncarnationsByPaneKey,
...incoming.terminalPtyIncarnationsByPaneKey
},
terminalTopologyRevisionByRepoId: mergeTerminalTopologyRevisions(
base.terminalTopologyRevisionByRepoId,
incoming.terminalTopologyRevisionByRepoId
),
terminalSurfaceTombstonesByPaneKey: {
...base.terminalSurfaceTombstonesByPaneKey,
...incoming.terminalSurfaceTombstonesByPaneKey
},
activeWorktreeIdsOnShutdown: [
...(base.activeWorktreeIdsOnShutdown ?? []),
...(incoming.activeWorktreeIdsOnShutdown ?? [])
@@ -141,6 +164,25 @@ export function removeRepoFromWorkspaceSession(
next.defaultTerminalTabsAppliedByWorktreeId,
repoId
)
next.terminalTopologyRevisionByRepoId = removeRepoWorktreeRecord(
next.terminalTopologyRevisionByRepoId,
repoId
)
if (next.terminalSurfaceTombstonesByPaneKey) {
next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries(
Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter(
([, tombstone]) => !ownerKeyBelongsToRepo(tombstone.worktreeId, repoId)
)
)
}
if (next.terminalPtyIncarnationsByPaneKey) {
next.terminalPtyIncarnationsByPaneKey = Object.fromEntries(
Object.entries(next.terminalPtyIncarnationsByPaneKey).filter(([paneKey]) => {
const separator = paneKey.lastIndexOf(':')
return separator < 1 || !removedTerminalTabIds.has(paneKey.slice(0, separator))
})
)
}
if (next.activeWorktreeId && isRepoWorktreeId(repoId, next.activeWorktreeId)) {
next.activeWorktreeId = null
}
@@ -40,7 +40,8 @@ function hasTransferredSessionState(session: WorkspaceSessionState): boolean {
Object.keys(session.openFilesByWorktree ?? {}).length > 0 ||
Object.keys(session.browserTabsByWorktree ?? {}).length > 0 ||
Object.keys(session.unifiedTabs ?? {}).length > 0 ||
Object.keys(session.tabGroups ?? {}).length > 0
Object.keys(session.tabGroups ?? {}).length > 0 ||
Object.keys(session.terminalTopologyRevisionByRepoId ?? {}).length > 0
)
}
@@ -120,6 +121,10 @@ export function extractSessionForTransfer(
source.defaultTerminalTabsAppliedByWorktreeId,
(value) => structuredClone(value)
)
transferred.terminalTopologyRevisionByRepoId = mapOwnerRecord(
source.terminalTopologyRevisionByRepoId,
(value) => value
)
transferred.terminalLayoutsByTabId = {}
for (const tabId of copiedTerminalTabIds) {
const layout = source.terminalLayoutsByTabId[tabId]
@@ -127,6 +132,28 @@ export function extractSessionForTransfer(
transferred.terminalLayoutsByTabId[tabId] = structuredClone(layout)
}
}
transferred.terminalPtyIncarnationsByPaneKey = Object.fromEntries(
Object.entries(source.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => {
const separator = paneKey.lastIndexOf(':')
return separator > 0 && copiedTerminalTabIds.has(paneKey.slice(0, separator))
})
)
transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries(
Object.entries(source.terminalSurfaceTombstonesByPaneKey ?? {}).flatMap(
([paneKey, tombstone]) =>
isRepoWorktreeId(oldRepoId, tombstone.worktreeId)
? [
[
paneKey,
{
...structuredClone(tombstone),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tombstone.worktreeId)
}
] as const
]
: []
)
)
transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown
?.filter((worktreeId) => isRepoWorktreeId(oldRepoId, worktreeId))
.map((worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId))
@@ -123,7 +123,11 @@ function collectSessionWorktreeIds(
addOwnerKeys(session.activeGroupIdByWorktree)
addOwnerKeys(session.lastVisitedAtByWorktreeId)
addOwnerKeys(session.defaultTerminalTabsAppliedByWorktreeId)
addOwnerKeys(session.terminalTopologyRevisionByRepoId)
addOwnerKeys(session.activeFileIdByWorktree)
for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) {
add(tombstone.worktreeId)
}
add(session.activeWorktreeId)
const activeScope = session.activeWorkspaceKey
? parseWorkspaceKey(session.activeWorkspaceKey)
+384 -1
View File
@@ -3420,6 +3420,22 @@ describe('Store', () => {
expect(store.getWorktreeMeta('r2::/other')!.displayName).toBe('other')
})
it('does not retain topology authority for historically removed repos', async () => {
const store = await createStore()
for (let index = 0; index < 25; index += 1) {
const repoId = `removed-${index}`
store.addRepo(makeRepo({ id: repoId, path: `/repo-${index}` }))
store.setWorkspaceSession({
...store.getWorkspaceSession(),
terminalTopologyRevisionByRepoId: { [repoId]: 1 }
})
store.removeProject(repoId)
}
expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId).toEqual({})
})
it('removeProject removes the derived project host setup compatibility record', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1' }))
@@ -8021,6 +8037,95 @@ describe('Store', () => {
})
})
it('admits a fresh host spawn after retirement while rejecting an older renderer topology', async () => {
const store = await createStore()
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
terminalTopologyRevisionByRepoId: { wt1: 1 }
})
store.persistPtyBinding({
worktreeId: 'wt1',
tabId: 'fresh-tab',
leafId: TEST_LEAF_1,
ptyId: 'fresh-pty',
incarnationId: 'fresh-incarnation'
})
const admitted = structuredClone(store.getWorkspaceSession())
expect(admitted.terminalTopologyRevisionByRepoId?.wt1).toBe(2)
expect(admitted.tabsByWorktree.wt1).toEqual([
expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' })
])
store.setWorkspaceSession({
...admitted,
tabsByWorktree: {
...admitted.tabsByWorktree,
wt1: admitted.tabsByWorktree.wt1.map((tab) => ({
...tab,
title: 'Fresh title',
sortOrder: 7
}))
},
terminalLayoutsByTabId: {
...admitted.terminalLayoutsByTabId,
'fresh-tab': {
...admitted.terminalLayoutsByTabId['fresh-tab'],
titlesByLeafId: { [TEST_LEAF_1]: 'Fresh pane title' }
}
}
})
expect(store.getWorkspaceSession().tabsByWorktree.wt1[0]).toMatchObject({
id: 'fresh-tab',
ptyId: 'fresh-pty',
title: 'Fresh title',
sortOrder: 7
})
expect(store.getWorkspaceSession().terminalLayoutsByTabId['fresh-tab'].titlesByLeafId).toEqual({
[TEST_LEAF_1]: 'Fresh pane title'
})
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [
{
id: 'retired-tab',
worktreeId: 'wt1',
title: 'Retired',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'retired-pty'
}
]
},
terminalLayoutsByTabId: {
'retired-tab': {
root: { type: 'leaf', leafId: TEST_LEAF_2 },
activeLeafId: TEST_LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_2]: 'retired-pty' }
}
},
terminalPtyIncarnationsByPaneKey: {
[`retired-tab:${TEST_LEAF_2}`]: 'retired-incarnation'
},
terminalTopologyRevisionByRepoId: { wt1: 1 }
})
const afterStaleWrite = store.getWorkspaceSession()
expect(afterStaleWrite.tabsByWorktree.wt1).toEqual([
expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' })
])
expect(afterStaleWrite.terminalLayoutsByTabId['retired-tab']).toBeUndefined()
expect(
afterStaleWrite.terminalPtyIncarnationsByPaneKey?.[`retired-tab:${TEST_LEAF_2}`]
).toBeUndefined()
})
it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => {
const store = await createStore()
store.setWorkspaceSession({
@@ -8078,6 +8183,227 @@ describe('Store', () => {
})
})
it('advances host topology when a live spawn adds a split leaf after retirement', async () => {
const store = await createStore()
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
}
},
terminalTopologyRevisionByRepoId: { wt1: 1 }
})
store.persistPtyBinding({
worktreeId: 'wt1',
tabId: 'tab1',
leafId: TEST_LEAF_2,
ptyId: 'pty-2'
})
const session = store.getWorkspaceSession()
expect(session.terminalTopologyRevisionByRepoId?.wt1).toBe(2)
expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({
[TEST_LEAF_1]: 'pty-1',
[TEST_LEAF_2]: 'pty-2'
})
})
it('keeps worktree deletion authoritative against stale writes and later same-path reuse', async () => {
const store = await createStore()
store.setWorktreeMeta('wt1', { displayName: 'Worktree' })
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
wt1: [makeTerminalTab({ id: 'old-tab', worktreeId: 'wt1', ptyId: 'old-pty' })]
},
terminalLayoutsByTabId: {
'old-tab': {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'old-pty' }
}
},
terminalPtyIncarnationsByPaneKey: {
[`old-tab:${TEST_LEAF_1}`]: 'old-incarnation'
},
terminalTopologyRevisionByRepoId: { wt1: 1 }
})
const stale = structuredClone(store.getWorkspaceSession())
store.removeWorktreeMeta('wt1')
expect(store.getWorkspaceSession().tabsByWorktree.wt1).toBeUndefined()
expect(store.getWorkspaceSession().terminalLayoutsByTabId['old-tab']).toBeUndefined()
expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.wt1).toBe(2)
store.setWorkspaceSession(stale)
expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([])
store.persistPtyBinding({
worktreeId: 'wt1',
tabId: 'fresh-tab',
leafId: TEST_LEAF_2,
ptyId: 'fresh-pty',
incarnationId: 'fresh-incarnation'
})
expect(store.getWorkspaceSession().tabsByWorktree.wt1).toEqual([
expect.objectContaining({ id: 'fresh-tab', ptyId: 'fresh-pty' })
])
expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.wt1).toBe(3)
})
it('fences a delayed terminal snapshot after an empty worktree is deleted', async () => {
const store = await createStore()
const worktreeId = 'repo::/empty-worktree'
store.setWorktreeMeta(worktreeId, { displayName: 'Empty worktree' })
const stale = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: 'late-tab', worktreeId, ptyId: 'late-pty' })]
}
}
store.removeWorktreeMeta(worktreeId)
expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(1)
store.setWorkspaceSession(stale)
expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toEqual([])
})
it('advances deletion authority when only a legacy retirement fence existed', async () => {
const store = await createStore()
const worktreeId = 'repo::/legacy-tombstone'
store.setWorktreeMeta(worktreeId, { displayName: 'Legacy worktree' })
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
terminalSurfaceTombstonesByPaneKey: {
'old-tab:old-leaf': {
worktreeId,
parentTabId: 'old-tab',
leafId: 'old-leaf',
ptyId: 'old-pty',
incarnationId: 'old-incarnation',
retiredAt: 1
}
}
})
const revisionBeforeDelete =
store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo ?? 0
store.removeWorktreeMeta(worktreeId)
expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(
revisionBeforeDelete + 1
)
})
it('enforces one repo epoch across siblings and admits a fresh sibling spawn', async () => {
const store = await createStore()
const worktreeA = 'repo::/worktree-a'
const worktreeB = 'repo::/worktree-b'
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeA]: [makeTerminalTab({ id: 'tab-a', worktreeId: worktreeA, ptyId: 'pty-a' })],
[worktreeB]: [makeTerminalTab({ id: 'tab-b', worktreeId: worktreeB, ptyId: 'pty-b' })]
},
terminalTopologyRevisionByRepoId: { repo: 1 }
})
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeB]: [makeTerminalTab({ id: 'stale-tab', worktreeId: worktreeB })]
}
})
expect(store.getWorkspaceSession().tabsByWorktree[worktreeA]?.[0]?.id).toBe('tab-a')
expect(store.getWorkspaceSession().tabsByWorktree[worktreeB]?.[0]?.id).toBe('tab-b')
store.persistPtyBinding({
worktreeId: worktreeB,
tabId: 'fresh-tab',
leafId: TEST_LEAF_1,
ptyId: 'fresh-pty'
})
expect(store.getWorkspaceSession().tabsByWorktree[worktreeB].map((tab) => tab.id)).toEqual([
'tab-b',
'fresh-tab'
])
expect(store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.repo).toBe(2)
})
it('keeps one deletion watermark for many historical worktrees in the same repo', async () => {
const store = await createStore()
for (let index = 0; index < 25; index += 1) {
const worktreeId = `repo::/worktree-${index}`
store.setWorktreeMeta(worktreeId, { displayName: `Worktree ${index}` })
store.persistPtyBinding({
worktreeId,
tabId: `tab-${index}`,
leafId: TEST_LEAF_1,
ptyId: `pty-${index}`
})
store.removeWorktreeMeta(worktreeId)
}
const session = store.getWorkspaceSession()
expect(Object.keys(session.terminalTopologyRevisionByRepoId ?? {})).toEqual(['repo'])
expect(session.tabsByWorktree).toEqual({})
})
it('does not remove colliding worktree ids from other execution-host partitions', async () => {
const store = await createStore()
const worktreeId = 'repo::/same-path'
store.setWorktreeMeta(worktreeId, { displayName: 'Local worktree' })
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: 'local-tab', worktreeId })]
}
})
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: 'remote-a-tab', worktreeId })]
}
},
'runtime:env-a'
)
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: 'remote-b-tab', worktreeId })]
}
},
'runtime:env-b'
)
store.removeWorktreeMeta(worktreeId)
expect(store.getWorkspaceSession().tabsByWorktree[worktreeId]).toBeUndefined()
expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]?.[0]?.id).toBe(
'remote-a-tab'
)
expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]?.[0]?.id).toBe(
'remote-b-tab'
)
})
it('preserves a sync-persisted UUID root when a stale empty layout write arrives', async () => {
const store = await createStore()
store.setWorkspaceSession({
@@ -9931,6 +10257,7 @@ describe('Store.migrateWorktreeIdentity', () => {
activeGroupIdByWorktree: { [OLD]: 'group1' },
lastVisitedAtByWorktreeId: { [OLD]: 123 },
defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true },
terminalTopologyRevisionByRepoId: { repo1: 4 },
sleepingAgentSessionsByPaneKey: {
'tab1:leaf': {
paneKey: 'tab1:leaf',
@@ -9953,7 +10280,18 @@ describe('Store.migrateWorktreeIdentity', () => {
activeWorkspaceKey: OLD_WORKSPACE_KEY,
activeWorktreeId: OLD,
tabsByWorktree: { [OLD]: [makeTerminalTab({ id: 'host-tab', worktreeId: OLD })] },
terminalLayoutsByTabId: {}
terminalLayoutsByTabId: {},
terminalTopologyRevisionByRepoId: { repo1: 8 },
terminalSurfaceTombstonesByPaneKey: {
'host-tab:leaf': {
worktreeId: OLD,
parentTabId: 'host-tab',
leafId: 'leaf',
ptyId: 'host-pty',
incarnationId: 'host-incarnation',
retiredAt: 1
}
}
},
'runtime:env-a'
)
@@ -9997,12 +10335,15 @@ describe('Store.migrateWorktreeIdentity', () => {
expect(session.activeGroupIdByWorktree?.[NEW]).toBe('group1')
expect(session.lastVisitedAtByWorktreeId?.[NEW]).toBe(123)
expect(session.defaultTerminalTabsAppliedByWorktreeId?.[NEW]).toBe(true)
expect(session.terminalTopologyRevisionByRepoId?.repo1).toBe(4)
expect(session.sleepingAgentSessionsByPaneKey?.['tab1:leaf']?.worktreeId).toBe(NEW)
const hostSession = store.getWorkspaceSession('runtime:env-a')
expect(hostSession.tabsByWorktree[OLD]).toBeUndefined()
expect(hostSession.tabsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW)
expect(hostSession.activeWorkspaceKey).toBe(NEW_WORKSPACE_KEY)
expect(hostSession.terminalTopologyRevisionByRepoId?.repo1).toBe(9)
expect(hostSession.terminalSurfaceTombstonesByPaneKey).toEqual({})
})
it('rewrites parentWorktreeId back-references in other lineage entries', async () => {
@@ -10149,6 +10490,48 @@ describe('Store host-partitioned workspace sessions', () => {
expect(store.getWorkspaceSession('local').activeRepoId).toBe('repo-local')
})
it('preserves and enforces equal repo-id topology authority independently per host', async () => {
const store = await createStore()
const worktreeId = 'duplicate::/worktree'
const staleTabs = {
[worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId, ptyId: 'stale-pty' })]
}
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
terminalTopologyRevisionByRepoId: { duplicate: 2 }
},
'runtime:env-a'
)
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: 'live-tab', worktreeId, ptyId: 'live-pty' })]
},
terminalTopologyRevisionByRepoId: { duplicate: 7 }
},
'runtime:env-b'
)
store.setWorkspaceSession(
{ ...getDefaultWorkspaceSession(), tabsByWorktree: staleTabs },
'runtime:env-a'
)
store.patchWorkspaceSession({ tabsByWorktree: staleTabs }, 'runtime:env-a')
expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toEqual([])
expect(
store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.duplicate
).toBe(2)
expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]?.[0]?.id).toBe(
'live-tab'
)
expect(
store.getWorkspaceSession('runtime:env-b').terminalTopologyRevisionByRepoId?.duplicate
).toBe(7)
})
it('defaults an omitted hostId to the local partition', async () => {
const store = await createStore()
store.setWorkspaceSession(makeHostSession('repo-a'), 'runtime:env-a')
+111 -2
View File
@@ -77,6 +77,11 @@ import {
} from '../shared/task-source-context'
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
import {
removeRepoFromHostWorkspaceSessions,
removeRepoFromWorkspaceSession
} from './orca-profiles/profile-project-session-state'
import { hardenExistingSecureFile } from '../shared/secure-file'
import {
LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
@@ -2343,7 +2348,8 @@ function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSe
function removeWorkspaceSessionOwner(
session: WorkspaceSessionState | undefined,
ownerKey: string
ownerKey: string,
options: { advanceTerminalTopologyRevision?: boolean } = {}
): WorkspaceSessionState | undefined {
if (!session) {
return session
@@ -2359,6 +2365,30 @@ function removeWorkspaceSessionOwner(
next.activeTabId = null
}
}
if (next.terminalPtyIncarnationsByPaneKey) {
const removedTabIds = new Set(removedTerminalTabs.map((tab) => tab.id))
next.terminalPtyIncarnationsByPaneKey = Object.fromEntries(
Object.entries(next.terminalPtyIncarnationsByPaneKey).filter(([paneKey]) => {
const separator = paneKey.lastIndexOf(':')
return separator < 1 || !removedTabIds.has(paneKey.slice(0, separator))
})
)
}
if (next.terminalSurfaceTombstonesByPaneKey) {
next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries(
Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter(
([, tombstone]) => tombstone.worktreeId !== ownerKey
)
)
}
const repoId = getRepoIdFromWorktreeId(ownerKey)
const previousTopologyRevision = next.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
if (options.advanceTerminalTopologyRevision) {
next.terminalTopologyRevisionByRepoId = {
...next.terminalTopologyRevisionByRepoId,
[repoId]: previousTopologyRevision + 1
}
}
if (next.openFilesByWorktree) {
delete next.openFilesByWorktree[ownerKey]
@@ -4137,6 +4167,11 @@ export class Store {
// Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it.
delete this.state.sparsePresetsByRepo[id]
this.pruneWorktreeStateForRepo(id, null)
this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id)
this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions(
this.state.workspaceSessionsByHostId,
id
)
this.scheduleSave()
}
@@ -4153,6 +4188,21 @@ export class Store {
this.syncProjectHostSetupCompatibilityState()
// Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject).
this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null)
if (!idStillPresent) {
this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id)
this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions(
this.state.workspaceSessionsByHostId,
id
)
} else if (parseExecutionHostId(hostId)?.kind === 'runtime') {
const session = this.state.workspaceSessionsByHostId?.[hostId]
if (session) {
this.state.workspaceSessionsByHostId = {
...this.state.workspaceSessionsByHostId,
[hostId]: removeRepoFromWorkspaceSession(session, id)
}
}
}
this.scheduleSave()
}
@@ -4772,6 +4822,11 @@ export class Store {
delete this.state.worktreeMeta[worktreeId]
delete this.state.worktreeLineageById[worktreeId]
delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)]
this.state.workspaceSession = removeWorkspaceSessionOwner(
this.state.workspaceSession,
worktreeId,
{ advanceTerminalTopologyRevision: true }
)!
this.scheduleSave()
}
@@ -4915,6 +4970,21 @@ export class Store {
sessionChanged = true
}
}
if (session.terminalSurfaceTombstonesByPaneKey) {
let tombstonesChanged = false
const nextTombstones = { ...session.terminalSurfaceTombstonesByPaneKey }
for (const [paneKey, tombstone] of Object.entries(nextTombstones)) {
if (tombstone.worktreeId !== oldWorktreeId) {
continue
}
nextTombstones[paneKey] = { ...tombstone, worktreeId: newWorktreeId }
tombstonesChanged = true
}
if (tombstonesChanged) {
session.terminalSurfaceTombstonesByPaneKey = nextTombstones
sessionChanged = true
}
}
return sessionChanged
}
@@ -5502,6 +5572,11 @@ export class Store {
/** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */
private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void {
// Why: each partition owns its topology fence; renderer writes omit it and must rebase locally.
session = sanitizeWorkspaceSessionTerminalRetirements(
session,
this.state.workspaceSessionsByHostId?.[hostId]
)
const pruned = pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
)
@@ -5513,12 +5588,13 @@ export class Store {
}
private setLocalWorkspaceSession(session: PersistedState['workspaceSession']): void {
const prior = this.state.workspaceSession
session = sanitizeWorkspaceSessionTerminalRetirements(session, prior)
session = pruneWorkspaceSessionBrowserHistory(
pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
)
// Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding.
const prior = this.state.workspaceSession
const normalized = normalizeWorkspaceSessionPaneIdentities(
session,
prior?.terminalLayoutsByTabId
@@ -5798,6 +5874,7 @@ export class Store {
tabId: string
leafId: string
ptyId: string
incarnationId?: string
startupCwd?: string
}): void {
const session = this.state.workspaceSession
@@ -5805,11 +5882,38 @@ export class Store {
return
}
const sessionBeforeBinding = cloneWorkspaceSessionState(session)
const paneKey = `${args.tabId}:${args.leafId}`
let terminalMembershipChanged = false
const advanceTopologyAfterMembershipChange = (): void => {
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
if (!terminalMembershipChanged || currentRevision <= 0) {
return
}
// Why: a real host-admitted spawn after a retirement must be distinguishable from a stale renderer replay.
session.terminalTopologyRevisionByRepoId = {
...session.terminalTopologyRevisionByRepoId,
[repoId]: currentRevision + 1
}
}
if (args.incarnationId) {
session.terminalPtyIncarnationsByPaneKey = {
...session.terminalPtyIncarnationsByPaneKey,
[paneKey]: args.incarnationId
}
if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) {
session.terminalSurfaceTombstonesByPaneKey = {
...session.terminalSurfaceTombstonesByPaneKey
}
delete session.terminalSurfaceTombstonesByPaneKey[paneKey]
}
}
const tabs = session.tabsByWorktree?.[args.worktreeId]
const tab = tabs?.find((t) => t.id === args.tabId)
if (tab) {
tab.ptyId = args.ptyId
} else {
terminalMembershipChanged = true
// Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned.
const nextTabs = [
...(tabs ?? []),
@@ -5831,6 +5935,7 @@ export class Store {
}
if (!isTerminalLeafId(args.leafId)) {
// Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration.
advanceTopologyAfterMembershipChange()
try {
this.flushOrThrow()
} catch (err) {
@@ -5842,11 +5947,13 @@ export class Store {
const layout = session.terminalLayoutsByTabId?.[args.tabId]
if (layout) {
if (!layout.root) {
terminalMembershipChanged = true
// Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root.
layout.root = { type: 'leaf', leafId: args.leafId }
layout.activeLeafId = args.leafId
layout.expandedLeafId = null
} else if (!layoutContainsLeafId(layout.root, args.leafId)) {
terminalMembershipChanged = true
// Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding.
layout.root = {
type: 'split',
@@ -5864,6 +5971,7 @@ export class Store {
[args.leafId]: args.ptyId
}
} else {
terminalMembershipChanged = true
// Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId.
session.terminalLayoutsByTabId = {
...session.terminalLayoutsByTabId,
@@ -5875,6 +5983,7 @@ export class Store {
}
}
}
advanceTopologyAfterMembershipChange()
try {
this.flushOrThrow()
} catch (err) {
+22 -6
View File
@@ -425,8 +425,24 @@ describe('LocalPtyProvider', () => {
it('invokes onSpawned callback', async () => {
const onSpawned = vi.fn()
provider.configure({ onSpawned })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
expect(onSpawned).toHaveBeenCalledWith(id)
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
expect(onSpawned).toHaveBeenCalledWith(id, incarnationId)
})
it('reports physical commit before post-spawn publication can fail', async () => {
spawnMock.mockClear()
const committed = vi.fn()
provider.configure({
onSpawned: () => {
throw new Error('post-spawn publication failed')
}
})
await expect(
provider.spawn({ cols: 80, rows: 24, onPtySpawnCommitted: committed })
).rejects.toThrow('post-spawn publication failed')
expect(spawnMock).toHaveBeenCalledOnce()
expect(committed).toHaveBeenCalledOnce()
})
it('invokes buildSpawnEnv callback to customize environment', async () => {
@@ -1186,9 +1202,9 @@ describe('LocalPtyProvider', () => {
it('invokes onExit callback via the node-pty exit handler', async () => {
const onExit = vi.fn()
provider.configure({ onExit })
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
await provider.shutdown(id, { immediate: true })
expect(onExit).toHaveBeenCalledWith(id, -1)
expect(onExit).toHaveBeenCalledWith(id, -1, incarnationId)
})
it('does not destroy after an intentional Windows shutdown kill', async () => {
@@ -1734,12 +1750,12 @@ describe('LocalPtyProvider', () => {
it('notifies exit listeners when PTY exits', async () => {
const exitHandler = vi.fn()
provider.onExit(exitHandler)
const { id } = await provider.spawn({ cols: 80, rows: 24 })
const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 })
// Simulate node-pty exit event
exitCb?.({ exitCode: 0 })
expect(exitHandler).toHaveBeenCalledWith({ id, code: 0 })
expect(exitHandler).toHaveBeenCalledWith({ id, code: 0, incarnationId })
})
it('allows unsubscribing from events', async () => {
+17 -6
View File
@@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */
import { basename, delimiter } from 'node:path'
import { randomUUID } from 'node:crypto'
import { win32 as pathWin32 } from 'node:path'
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
import {
@@ -78,6 +79,7 @@ const PANE_IDENTITY_ENV_KEYS = [
let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
const ptyIncarnations = new Map<string, string>()
// Why: only agent sessions get descendant tree-kill (tool children run in detached groups SIGHUP can't reach); plain terminals skip it so nohup-detached children survive.
const ptyAgentSessionIds = new Set<string>()
// Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY.
@@ -123,7 +125,7 @@ type DataCallback = (payload: {
transformed?: boolean
seq?: number
}) => void
type ExitCallback = (payload: { id: string; code: number }) => void
type ExitCallback = (payload: { id: string; code: number; incarnationId?: string }) => void
const dataListeners = new Set<DataCallback>()
const exitListeners = new Set<ExitCallback>()
@@ -236,6 +238,7 @@ function clearPtyState(id: string): void {
disposePtyListeners(id)
disposePtyExitListener(id)
ptyProcesses.delete(id)
ptyIncarnations.delete(id)
ptyAgentSessionIds.delete(id)
ptyShellName.delete(id)
ptyAgentForegroundContextPaths.delete(id)
@@ -488,8 +491,8 @@ export type LocalPtyProviderOptions = {
getWindowsShell?: () => string | undefined
getWindowsPowerShellImplementation?: () => 'auto' | 'powershell.exe' | 'pwsh.exe' | undefined
pwshAvailable?: () => boolean
onSpawned?: (id: string) => void
onExit?: (id: string, code: number) => void
onSpawned?: (id: string, incarnationId: string) => void
onExit?: (id: string, code: number, incarnationId: string) => void
onData?: (
id: string,
data: string,
@@ -529,6 +532,7 @@ export class LocalPtyProvider implements IPtyProvider {
}
}
const id = allocatePtyId(reattachId ?? undefined)
const incarnationId = randomUUID()
const startupAgentRecognition = args.command
? recognizeAgentProcessFromCommandLine(args.command)
@@ -802,6 +806,9 @@ export class LocalPtyProvider implements IPtyProvider {
}
await prepareLocalPtySpawn(id)
if (args.signal?.aborted) {
throw new Error('client_disconnected')
}
// Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell.
const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null
if (concurrentWinner) {
@@ -823,6 +830,7 @@ export class LocalPtyProvider implements IPtyProvider {
: undefined,
windowsFallbackAttempts
})
args.onPtySpawnCommitted?.()
shellPath = spawnResult.shellPath
// Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write.
if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) {
@@ -862,7 +870,8 @@ export class LocalPtyProvider implements IPtyProvider {
getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId })
)
ptyLoadGeneration.set(id, loadGeneration)
this.opts.onSpawned?.(id)
ptyIncarnations.set(id, incarnationId)
this.opts.onSpawned?.(id, incarnationId)
const emitIngressData = (emission: PtyIngressEmission): void => {
const sequenceChars = emission.rawEndSeq - emission.rawStartSeq
@@ -987,9 +996,9 @@ export class LocalPtyProvider implements IPtyProvider {
startupIngressByPty.delete(id)
// Why: release the master ptmx fd on natural exit, else a clean exit leaks the fd until GC. See docs/fix-pty-fd-leak.md.
destroyPtyProcess(proc, { alreadyKilled: wasTerminationRequested })
this.opts.onExit?.(id, exitCode)
this.opts.onExit?.(id, exitCode, incarnationId)
for (const cb of exitListeners) {
cb({ id, code: exitCode })
cb({ id, code: exitCode, incarnationId })
}
})
if (onExitDisposable) {
@@ -1018,6 +1027,7 @@ export class LocalPtyProvider implements IPtyProvider {
const pid = typeof rawPid === 'number' && Number.isFinite(rawPid) && rawPid > 0 ? rawPid : null
return {
id,
incarnationId,
pid,
...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {})
}
@@ -1309,6 +1319,7 @@ export class LocalPtyProvider implements IPtyProvider {
async listProcesses(): Promise<PtyProcessInfo[]> {
return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({
id,
...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}),
cwd: ptyInitialCwd.get(id) ?? '',
title: proc.process || ptyShellName.get(id) || 'shell',
...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}),
+7
View File
@@ -1,10 +1,17 @@
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import type { TuiAgent } from '../../shared/types'
import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type PtySpawnResult = {
agentSessionEnsure?: AgentSessionClaimedSpawnResult
/** App-facing PTY id. Remote providers must return globally routable ids,
* not relay-local handles, because renderer/runtime IPC routes by this key. */
id: string
/** Opaque provider-owned identity for this process behind a reusable PTY id. */
incarnationId?: PtyIncarnationId
/** The provider observed this exact spawn exit before its control reply settled. */
exitedBeforeSpawnReply?: true
/** OS-level pid of the shell process, when available at spawn time.
* Why: the memory collector needs this to walk each PTY's process
* subtree. Daemon-backed providers return it from the RPC result;
@@ -0,0 +1,50 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { proveSshAgentSessionClaimCapability } from './ssh-agent-session-claim-validation'
import { sshSupportsAgentSessionCreateOperations } from './ssh-agent-session-create-operation'
import { waitForSshCapabilityProbe } from './ssh-capability-probe-waiter'
export class SshAgentSessionCapabilities {
private claimProbe: Promise<void> | null = null
private claimSupported = false
private createOperationProbe: Promise<boolean> | null = null
constructor(private readonly mux: SshChannelMultiplexer) {}
async supportsClaims(options: { signal?: AbortSignal } = {}): Promise<boolean> {
const probe = this.claimProbe ?? proveSshAgentSessionClaimCapability(this.mux)
this.claimProbe = probe
try {
await waitForSshCapabilityProbe(probe, options.signal)
this.claimSupported = true
return true
} catch {
if (!options.signal?.aborted && this.claimProbe === probe) {
// Why: negative physical probes must follow a relay upgraded on this connection.
this.claimProbe = null
this.claimSupported = false
}
return false
}
}
providesOwnerListings(): boolean {
return this.claimSupported
}
async supportsCreateOperations(options: { signal?: AbortSignal } = {}): Promise<boolean> {
const probe = this.createOperationProbe ?? sshSupportsAgentSessionCreateOperations(this.mux)
this.createOperationProbe = probe
let supported: boolean
try {
supported = await waitForSshCapabilityProbe(probe, options.signal)
} catch {
// Why: one canceled waiter must not cancel or evict the shared physical probe used by peers.
return false
}
if (!supported && this.createOperationProbe === probe) {
// Why: negative capability results must follow a relay upgraded on the same connection.
this.createOperationProbe = null
}
return supported
}
}
@@ -0,0 +1,90 @@
import {
AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION,
isAgentSessionClaimedSpawnResult,
type AgentSessionExecutionClaim,
type AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import type { PtySpawnResult } from './pty-spawn-result'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from './ssh-agent-session-create-operation'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
export type ClaimedSshSpawnValidation =
| { valid: true }
| { valid: false; cleanup: 'created' | 'none'; error: string }
export async function proveSshAgentSessionClaimCapability(
mux: SshChannelMultiplexer,
options: { signal?: AbortSignal } = {}
): Promise<void> {
try {
const result = (await mux.request('pty.getCapabilities', undefined, {
signal: options.signal,
timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS
})) as {
agentSessionClaimVersion?: unknown
}
if (result.agentSessionClaimVersion !== AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION) {
throw new Error('unsupported')
}
} catch {
throw new Error('agent_session_claim_unavailable')
}
}
function claimsEqual(
actual: AgentSessionExecutionClaim,
expected: AgentSessionExecutionClaim
): boolean {
return (
actual.digestVersion === expected.digestVersion &&
actual.keyId === expected.keyId &&
actual.identityDigest === expected.identityDigest &&
actual.worktreeScopeDigest === expected.worktreeScopeDigest &&
actual.agent === expected.agent
)
}
function surfacesEqual(
actual: AgentSessionSurfaceBinding,
expected: AgentSessionSurfaceBinding
): boolean {
return (
actual.worktreeId === expected.worktreeId &&
actual.tabId === expected.tabId &&
actual.leafId === expected.leafId &&
actual.terminalHandle === expected.terminalHandle
)
}
export function validateClaimedSshSpawn(
result: PtySpawnResult,
expected: {
claim: AgentSessionExecutionClaim
surface: AgentSessionSurfaceBinding
}
): ClaimedSshSpawnValidation {
const claimed = result.agentSessionEnsure
if (!isAgentSessionClaimedSpawnResult(claimed)) {
// Why: without a disposition we cannot prove the returned PTY was newly
// created, so killing it could terminate a canonical adopted owner.
return { valid: false, cleanup: 'none', error: 'execution_owner_unavailable' }
}
const cleanup = claimed.disposition === 'created' ? 'created' : 'none'
if (claimed.owner.ptyId !== result.id) {
return { valid: false, cleanup, error: 'agent_session_ownership_unknown' }
}
if (!claimsEqual(claimed.owner.claim, expected.claim)) {
return { valid: false, cleanup, error: 'agent_session_ownership_unknown' }
}
if (
claimed.disposition === 'created' &&
!surfacesEqual(claimed.owner.surface, expected.surface)
) {
return { valid: false, cleanup, error: 'agent_session_ownership_unknown' }
}
if (!isPtyIncarnationId(result.incarnationId)) {
return { valid: false, cleanup, error: 'agent_session_ownership_unknown' }
}
return { valid: true }
}
@@ -0,0 +1,65 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtySpawnResult } from './pty-spawn-result'
export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000
export function assertSshAgentSessionCreateResult(
result: unknown
): asserts result is PtySpawnResult {
const candidate = result as Partial<PtySpawnResult> | null
if (
typeof candidate?.id === 'string' &&
candidate.id.length > 0 &&
candidate.id.length <= 512 &&
isPtyIncarnationId(candidate.incarnationId)
) {
return
}
// Why: a malformed success arrived after dispatch, so retain the replay fence instead of
// falling back or issuing a fresh operation that could duplicate a live PTY.
throw Object.assign(new Error('execution_owner_unavailable'), {
agentSessionOperationOutcome: 'unknown' as const
})
}
export async function sshSupportsAgentSessionCreateOperations(
mux: SshChannelMultiplexer,
options: { signal?: AbortSignal } = {}
): Promise<boolean> {
try {
const result = (await mux.request('pty.getCapabilities', undefined, {
signal: options.signal,
timeoutMs: SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS
})) as {
agentSessionCreateOperationVersion?: unknown
}
return (
result.agentSessionCreateOperationVersion === AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
)
} catch {
// Why: capability probing does not spawn, so an old relay can safely keep legacy behavior.
return false
}
}
export async function requestSshAgentSessionCreate(args: {
mux: SshChannelMultiplexer
params: Record<string, unknown>
operationId?: string
signal?: AbortSignal
}): Promise<unknown> {
try {
return await (args.signal
? args.mux.request('pty.spawn', args.params, { signal: args.signal })
: args.mux.request('pty.spawn', args.params))
} catch (error) {
if (!args.operationId) {
throw error
}
const spawnError = error instanceof Error ? error : new Error(String(error))
// Why: after request dispatch, either an old relay or a capable replay ledger may own a PTY.
throw Object.assign(spawnError, { agentSessionOperationOutcome: 'unknown' as const })
}
}
@@ -0,0 +1,28 @@
import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyProcessInfo } from './types'
export function mapSshPtyProcessList(
sessions: PtyProcessInfo[],
toAppPtyId: (id: string) => string
): PtyProcessInfo[] {
return sessions.map((session) => {
if (session.agentSessionOwners?.length && !isPtyIncarnationId(session.incarnationId)) {
throw new Error('agent_session_ownership_unknown')
}
return {
...session,
id: toAppPtyId(session.id),
...(session.agentSessionOwners
? {
agentSessionOwners: session.agentSessionOwners.map((owner) => {
if (!isAgentSessionOwnerBinding(owner) || owner.ptyId !== session.id) {
throw new Error('agent_session_ownership_unknown')
}
return { ...owner, ptyId: toAppPtyId(owner.ptyId) }
})
}
: {})
}
})
}
@@ -0,0 +1,24 @@
export function waitForSshCapabilityProbe<T>(probe: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return probe
}
if (signal.aborted) {
return Promise.reject(new Error('client_disconnected'))
}
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
reject(new Error('client_disconnected'))
}
signal.addEventListener('abort', onAbort, { once: true })
void probe.then(
(result) => {
signal.removeEventListener('abort', onAbort)
resolve(result)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(error)
}
)
})
}
+12
View File
@@ -0,0 +1,12 @@
export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
export const SSH_PTY_IDENTITY_MISMATCH_ERROR = 'SSH_PTY_IDENTITY_MISMATCH'
export function isSshPtyNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /PTY ".+" not found/i.test(message)
}
export function isSshPtyIdentityMismatchError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR) || /identity mismatch/i.test(message)
}
@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
import { SshPtyProvider } from './ssh-pty-provider'
describe('SSH fresh agent-session create operations', () => {
const request = vi.fn()
let provider: SshPtyProvider
beforeEach(() => {
request.mockReset()
provider = new SshPtyProvider('conn-1', {
request,
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn(() => false)
} as never)
})
it('sends operation identity only to a capable relay', async () => {
request.mockImplementation(async (method: string) =>
method === 'pty.getCapabilities'
? { agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION }
: { id: 'pty-operation', incarnationId: 'incarnation-operation' }
)
await provider.spawn({
cols: 80,
rows: 24,
command: 'codex',
agentSessionCreateOperationId: 'a'.repeat(43)
})
expect(request).toHaveBeenNthCalledWith(1, 'pty.getCapabilities', undefined, {
signal: undefined,
timeoutMs: 5_000
})
expect(request).toHaveBeenNthCalledWith(2, 'pty.spawn', {
cols: 80,
rows: 24,
cwd: undefined,
env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true' },
command: 'codex',
agentSessionCreateOperationId: 'a'.repeat(43)
})
})
it('does not downgrade after structured dispatch reaches an old relay', async () => {
request.mockResolvedValueOnce({})
await expect(
provider.spawn({
cols: 80,
rows: 24,
command: 'codex',
agentSessionCreateOperationId: 'b'.repeat(43)
})
).rejects.toThrow('execution_owner_unavailable')
expect(request).toHaveBeenCalledOnce()
})
it('keeps a client-selected old-relay spawn byte-for-byte legacy', async () => {
request.mockResolvedValueOnce({ id: 'pty-legacy' })
await expect(
provider.spawn({
cols: 80,
rows: 24,
command: 'codex'
})
).resolves.toMatchObject({ id: 'ssh:conn-1@@pty-legacy' })
expect(request).toHaveBeenNthCalledWith(1, 'pty.spawn', {
cols: 80,
rows: 24,
cwd: undefined,
env: { POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true' },
command: 'codex'
})
})
it('re-probes a negative capability after an in-place relay upgrade', async () => {
request.mockResolvedValueOnce({}).mockResolvedValueOnce({
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
})
await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(false)
await expect(provider.supportsAgentSessionCreateOperations()).resolves.toBe(true)
expect(request).toHaveBeenCalledTimes(2)
})
it('keeps a shared capability probe alive when one waiter disconnects', async () => {
let finishProbe!: (result: { agentSessionCreateOperationVersion: number }) => void
request.mockReturnValueOnce(
new Promise((resolve) => {
finishProbe = resolve
})
)
const abort = new AbortController()
const canceled = provider.supportsAgentSessionCreateOperations({ signal: abort.signal })
const live = provider.supportsAgentSessionCreateOperations()
abort.abort()
await expect(canceled).resolves.toBe(false)
finishProbe({
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
})
await expect(live).resolves.toBe(true)
expect(request).toHaveBeenCalledOnce()
})
it('does not dispatch create after cancellation during its capability gate', async () => {
let finishProbe!: (result: { agentSessionCreateOperationVersion: number }) => void
request.mockReturnValueOnce(
new Promise((resolve) => {
finishProbe = resolve
})
)
const abort = new AbortController()
const spawn = provider.spawn({
cols: 80,
rows: 24,
command: 'codex',
agentSessionCreateOperationId: 'd'.repeat(43),
signal: abort.signal
})
abort.abort()
finishProbe({
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
})
await expect(spawn).rejects.toThrow('client_disconnected')
expect(request.mock.calls.map((call) => call[0])).toEqual(['pty.getCapabilities'])
})
it('fences a malformed successful structured-create response', async () => {
request
.mockResolvedValueOnce({
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
})
.mockResolvedValueOnce({ id: 'pty-without-incarnation' })
const failure = await provider
.spawn({
cols: 80,
rows: 24,
command: 'codex',
agentSessionCreateOperationId: 'c'.repeat(43)
})
.catch((error: unknown) => error)
expect(failure).toMatchObject({
message: 'execution_owner_unavailable',
agentSessionOperationOutcome: 'unknown'
})
expect(request).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
import { SshPtyProvider } from './ssh-pty-provider'
describe('SSH claimed PTY incarnation validation', () => {
it('retires a created owner with an invalid incarnation identity', async () => {
const claim = {
digestVersion: 1 as const,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex' as const
}
const surface = {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
const request = vi.fn(async (method: string) => {
if (method === 'pty.getCapabilities') {
return { agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION }
}
if (method === 'pty.spawn') {
return {
id: 'pty-invalid-incarnation',
incarnationId: 'i'.repeat(129),
agentSessionEnsure: {
disposition: 'created',
owner: {
claim,
generation: 'generation-invalid-incarnation',
phase: 'live',
ptyId: 'pty-invalid-incarnation',
surface
}
}
}
}
return undefined
})
const provider = new SshPtyProvider('conn-1', {
request,
notify: vi.fn(),
onNotification: vi.fn()
} as never)
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('agent_session_ownership_unknown')
expect(request).toHaveBeenCalledWith('pty.shutdown', {
id: 'pty-invalid-incarnation',
immediate: true
})
})
})
@@ -0,0 +1,23 @@
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type RemoteCliBridgeEnv = {
binDir: string
relayDir: string
nodePath: string
sockPath: string
pathDelimiter?: ':' | ';'
}
export type SshPtyDataCallback = (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
export type SshPtyReplayCallback = (payload: { id: string; data: string }) => void
export type SshPtyExitCallback = (payload: {
id: string
code: number
incarnationId?: PtyIncarnationId
}) => void
@@ -0,0 +1,78 @@
import { expect, it, vi } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
it('rejects a fresh SSH PTY whose exit shares the spawn response batch', async () => {
const mux = {
request: vi.fn(),
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
const provider = new SshPtyProvider('conn-1', mux as never)
const exitListener = vi.fn()
provider.onExit(exitListener)
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.spawn') {
const notify = mux.onNotification.mock.calls[0]?.[0]
notify?.('pty.exit', {
id: 'pty-raced',
code: 0,
incarnationId: 'incarnation-raced'
})
return { id: 'pty-raced', incarnationId: 'incarnation-raced' }
}
return undefined
})
await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow(
'agent_session_exited_during_start'
)
expect(exitListener).toHaveBeenCalledWith({
id: 'ssh:conn-1@@pty-raced',
code: 0,
incarnationId: 'incarnation-raced'
})
mux.request.mockResolvedValue({ id: 'pty-next', incarnationId: 'incarnation-next' })
await expect(provider.spawn({ cols: 80, rows: 24 })).resolves.toMatchObject({
id: 'ssh:conn-1@@pty-next',
incarnationId: 'incarnation-next'
})
})
it('rejects an SSH reattach whose matching exit shares the attach reply batch', async () => {
const mux = {
request: vi.fn(),
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
const provider = new SshPtyProvider('conn-1', mux as never)
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.attach') {
const notify = mux.onNotification.mock.calls[0]?.[0]
notify?.('pty.exit', {
id: 'pty-existing',
code: 0,
incarnationId: 'incarnation-existing'
})
return { incarnationId: 'incarnation-existing' }
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:conn-1@@pty-existing' })
).rejects.toThrow('agent_session_exited_during_start')
mux.request.mockResolvedValue({ incarnationId: 'incarnation-next' })
await expect(
provider.spawn({ cols: 80, rows: 24, sessionId: 'ssh:conn-1@@pty-existing' })
).resolves.toMatchObject({
id: 'ssh:conn-1@@pty-existing',
incarnationId: 'incarnation-next',
isReattach: true
})
})
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
type MockMultiplexer = {
request: ReturnType<typeof vi.fn>
notify: ReturnType<typeof vi.fn>
onNotification: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
isDisposed: ReturnType<typeof vi.fn>
}
function createMockMux(): MockMultiplexer {
return {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn(),
onNotification: vi.fn(),
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false)
}
}
describe('SshPtyProvider process listings and events', () => {
let mux: MockMultiplexer
let provider: SshPtyProvider
const scopedPty1 = 'ssh:conn-1@@pty-1'
beforeEach(() => {
mux = createMockMux()
provider = new SshPtyProvider('conn-1', mux as never)
})
it('scopes process listings and bounds the relay RPC by the teardown deadline', async () => {
const processes = [{ id: 'pty-1', cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' }]
mux.request.mockResolvedValue(processes)
await expect(provider.listProcesses()).resolves.toEqual([
{ id: scopedPty1, cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' }
])
expect(mux.request).toHaveBeenLastCalledWith('pty.listProcesses', undefined, undefined)
vi.useFakeTimers()
try {
mux.request.mockResolvedValue([])
await provider.listProcesses({ deadlineMs: Date.now() + 4321 })
expect(mux.request).toHaveBeenLastCalledWith('pty.listProcesses', undefined, {
timeoutMs: 4321
})
} finally {
vi.useRealTimers()
}
})
it('scopes recovered claim owner ids with their SSH connection', async () => {
mux.request.mockResolvedValue([
{
id: 'pty-1',
incarnationId: 'incarnation-1',
cwd: '/home',
title: 'codex',
agentSessionOwners: [
{
claim: {
digestVersion: 1,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex'
},
generation: 'generation-1',
phase: 'live',
ptyId: 'pty-1',
surface: {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
}
]
}
])
await expect(provider.listProcesses()).resolves.toMatchObject([
{
id: scopedPty1,
incarnationId: 'incarnation-1',
agentSessionOwners: [{ ptyId: scopedPty1 }]
}
])
})
it('rejects recovered claimed owners without PTY incarnation proof', async () => {
mux.request.mockResolvedValue([
{
id: 'pty-1',
cwd: '/home',
title: 'codex',
agentSessionOwners: [
{
claim: {
digestVersion: 1,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex'
},
generation: 'generation-1',
phase: 'live',
ptyId: 'pty-1',
surface: {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
}
]
}
])
await expect(provider.listProcesses()).rejects.toThrow('agent_session_ownership_unknown')
})
it('forwards data, replay, and incarnation-aware exit notifications', () => {
const dataHandler = vi.fn()
const replayHandler = vi.fn()
const exitHandler = vi.fn()
provider.onData(dataHandler)
provider.onReplay(replayHandler)
provider.onExit(exitHandler)
const notify = mux.onNotification.mock.calls[0][0]
notify('pty.data', { id: 'pty-1', data: 'output' })
notify('pty.data', { id: 'pty-1', data: '', rawLength: 9, seq: 9, transformed: true })
notify('pty.replay', { id: 'pty-1', data: 'buffered output' })
notify('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' })
expect(dataHandler).toHaveBeenNthCalledWith(1, { id: scopedPty1, data: 'output' })
expect(dataHandler).toHaveBeenNthCalledWith(2, {
id: scopedPty1,
data: '',
sequenceChars: 9,
seq: 9,
transformed: true
})
expect(replayHandler).toHaveBeenCalledWith({ id: scopedPty1, data: 'buffered output' })
expect(exitHandler).toHaveBeenCalledWith({
id: scopedPty1,
code: 0,
incarnationId: 'incarnation-1'
})
})
it('supports listener removal, fanout, and connection namespaces', () => {
const removed = vi.fn()
const first = vi.fn()
const second = vi.fn()
const unsubscribe = provider.onData(removed)
provider.onData(first)
provider.onData(second)
unsubscribe()
mux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'first' })
expect(removed).not.toHaveBeenCalled()
expect(first).toHaveBeenCalled()
expect(second).toHaveBeenCalled()
const otherMux = createMockMux()
const otherProvider = new SshPtyProvider('conn-2', otherMux as never)
const other = vi.fn()
otherProvider.onData(other)
otherMux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'second' })
expect(other).toHaveBeenCalledWith({ id: 'ssh:conn-2@@pty-1', data: 'second' })
})
})
+250 -119
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
type MockMultiplexer = {
request: ReturnType<typeof vi.fn>
@@ -35,7 +36,226 @@ describe('SshPtyProvider', () => {
expect(provider.getConnectionId()).toBe('conn-1')
})
it('keeps a shared claim probe alive when one waiter disconnects', async () => {
let finishProbe!: (result: { agentSessionClaimVersion: number }) => void
mux.request.mockReturnValueOnce(
new Promise((resolve) => {
finishProbe = resolve
})
)
const abort = new AbortController()
const canceled = provider.supportsAgentSessionClaims({ signal: abort.signal })
const live = provider.supportsAgentSessionClaims()
abort.abort()
await expect(canceled).resolves.toBe(false)
finishProbe({ agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION })
await expect(live).resolves.toBe(true)
expect(mux.request).toHaveBeenCalledOnce()
})
describe('spawn', () => {
const claim = {
digestVersion: 1 as const,
keyId: 'key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex' as const
}
const surface = {
worktreeId: 'worktree',
tabId: 'tab',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_claimed'
}
it('proves relay claim support before a claimed spawn', async () => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return {
id: 'pty-1',
incarnationId: 'incarnation-1',
agentSessionEnsure: {
disposition: 'created',
owner: {
claim,
generation: 'generation-1',
phase: 'live',
ptyId: 'pty-1',
surface
}
}
}
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).resolves.toMatchObject({
id: scopedPty1,
agentSessionEnsure: { owner: { ptyId: scopedPty1 } }
})
expect(mux.request.mock.calls.map((call) => call[0])).toEqual([
'pty.getCapabilities',
'pty.spawn'
])
})
it('fails before spawn when the relay cannot prove claim support', async () => {
mux.request.mockRejectedValue(new Error('method not found'))
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('agent_session_claim_unavailable')
expect(mux.request).toHaveBeenCalledTimes(1)
expect(mux.request).not.toHaveBeenCalledWith('pty.spawn', expect.anything())
})
it('fails closed without killing when a claimed response omits its disposition', async () => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return { id: 'pty-unclaimed' }
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('execution_owner_unavailable')
expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything())
})
it.each([
{
name: 'PTY identity',
mutate: (owner: Record<string, unknown>) => ({ ...owner, ptyId: 'other-pty' })
},
{
name: 'claim',
mutate: (owner: Record<string, unknown>) => ({
...owner,
claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' }
})
},
{
name: 'fresh surface',
mutate: (owner: Record<string, unknown>) => ({
...owner,
surface: { ...surface, tabId: 'other-tab' }
})
}
])('physically retires a created owner with mismatched $name', async ({ mutate }) => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return {
id: 'pty-malformed',
incarnationId: 'incarnation-malformed',
agentSessionEnsure: {
disposition: 'created',
owner: mutate({
claim,
generation: 'generation-malformed',
phase: 'live',
ptyId: 'pty-malformed',
surface
})
}
}
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('agent_session_ownership_unknown')
expect(mux.request).toHaveBeenCalledWith('pty.shutdown', {
id: 'pty-malformed',
immediate: true
})
})
it('does not kill a canonical adopted owner when its response is semantically invalid', async () => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return {
id: 'pty-canonical',
incarnationId: 'incarnation-canonical',
agentSessionEnsure: {
disposition: 'adopted',
owner: {
claim: { ...claim, identityDigest: 'ccccccccccccccccccccccccccccccccccccccccccc' },
generation: 'generation-canonical',
phase: 'live',
ptyId: 'pty-canonical',
surface
}
}
}
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('agent_session_ownership_unknown')
expect(mux.request).not.toHaveBeenCalledWith('pty.shutdown', expect.anything())
})
it('retains the unavailable fence when physical cleanup cannot be proven', async () => {
mux.request.mockImplementation(async (method: string) => {
if (method === 'pty.getCapabilities') {
return {
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION
}
}
if (method === 'pty.spawn') {
return {
id: 'pty-malformed',
incarnationId: 'incarnation-malformed',
agentSessionEnsure: {
disposition: 'created',
owner: {
claim,
generation: 'generation-malformed',
phase: 'live',
ptyId: 'other-pty',
surface
}
}
}
}
if (method === 'pty.shutdown') {
throw new Error('Timed out waiting for PTY process exit')
}
return undefined
})
await expect(
provider.spawn({ cols: 80, rows: 24, agentSessionEnsure: { claim, surface } })
).rejects.toThrow('execution_owner_unavailable')
})
it('sends pty.spawn request through multiplexer', async () => {
mux.request.mockResolvedValue({ id: 'pty-1' })
@@ -326,7 +546,10 @@ describe('SshPtyProvider', () => {
})
it('reattaches an existing session and returns attach replay separately from snapshot', async () => {
mux.request.mockResolvedValue({ replay: 'buffered-output' })
mux.request.mockResolvedValue({
replay: 'buffered-output',
incarnationId: 'incarnation-reattached'
})
const result = await provider.spawn({ cols: 80, rows: 24, sessionId: 'pty-old' })
@@ -339,7 +562,8 @@ describe('SshPtyProvider', () => {
expect(result).toEqual({
id: 'ssh:conn-1@@pty-old',
isReattach: true,
replay: 'buffered-output'
replay: 'buffered-output',
incarnationId: 'incarnation-reattached'
})
})
@@ -439,17 +663,39 @@ describe('SshPtyProvider', () => {
})
it('attachForReconnect returns replay without relay notification', async () => {
mux.request.mockResolvedValue({ replay: 'restored output' })
mux.request.mockResolvedValue({
replay: 'restored output',
incarnationId: 'incarnation-reconnect'
})
const result = await provider.attachForReconnect(scopedPty1)
expect(result).toEqual({ replay: 'restored output' })
expect(result).toEqual({
replay: 'restored output',
incarnationId: 'incarnation-reconnect'
})
expect(mux.request).toHaveBeenCalledWith('pty.attach', {
id: 'pty-1',
suppressReplayNotification: true
})
})
it('keeps missing incarnation compatible with an old relay', async () => {
mux.request.mockResolvedValue({ replay: 'legacy replay' })
await expect(provider.attachForReconnect(scopedPty1)).resolves.toEqual({
replay: 'legacy replay'
})
})
it('rejects a present malformed attach incarnation', async () => {
mux.request.mockResolvedValue({ incarnationId: '' })
await expect(provider.attachForReconnect(scopedPty1)).rejects.toThrow(
'Invalid SSH PTY attach incarnation'
)
})
it('attachForReconnect forwards expected identity when provided', async () => {
await provider.attachForReconnect(scopedPty1, {
paneKey: 'tab-a:leaf-a',
@@ -594,124 +840,9 @@ describe('SshPtyProvider', () => {
)
})
it('listProcesses returns process list', async () => {
const processes = [{ id: 'pty-1', cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' }]
mux.request.mockResolvedValue(processes)
const result = await provider.listProcesses()
expect(result).toEqual([
{ id: scopedPty1, cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' }
])
expect(mux.request).toHaveBeenCalledWith('pty.listProcesses', undefined, undefined)
})
it('listProcesses bounds the relay RPC by the teardown deadline', async () => {
vi.useFakeTimers()
try {
mux.request.mockResolvedValue([])
await provider.listProcesses({ deadlineMs: Date.now() + 4321 })
expect(mux.request).toHaveBeenCalledWith('pty.listProcesses', undefined, { timeoutMs: 4321 })
} finally {
vi.useRealTimers()
}
})
it('getDefaultShell returns shell path', async () => {
mux.request.mockResolvedValue('/bin/bash')
const result = await provider.getDefaultShell()
expect(result).toBe('/bin/bash')
})
describe('event listeners', () => {
it('forwards pty.data notifications to data listeners', () => {
const handler = vi.fn()
provider.onData(handler)
// Get the notification handler that was registered
const notifHandler = mux.onNotification.mock.calls[0][0]
notifHandler('pty.data', { id: 'pty-1', data: 'output' })
expect(handler).toHaveBeenCalledWith({ id: scopedPty1, data: 'output' })
})
it('forwards empty transformed relay spans without reinterpreting them', () => {
const handler = vi.fn()
provider.onData(handler)
const notifHandler = mux.onNotification.mock.calls[0][0]
notifHandler('pty.data', {
id: 'pty-1',
data: '',
rawLength: 9,
seq: 9,
transformed: true
})
expect(handler).toHaveBeenCalledWith({
id: scopedPty1,
data: '',
sequenceChars: 9,
seq: 9,
transformed: true
})
})
it('forwards pty.replay notifications to replay listeners', () => {
const handler = vi.fn()
provider.onReplay(handler)
const notifHandler = mux.onNotification.mock.calls[0][0]
notifHandler('pty.replay', { id: 'pty-1', data: 'buffered output' })
expect(handler).toHaveBeenCalledWith({ id: scopedPty1, data: 'buffered output' })
})
it('forwards pty.exit notifications to exit listeners', () => {
const handler = vi.fn()
provider.onExit(handler)
const notifHandler = mux.onNotification.mock.calls[0][0]
notifHandler('pty.exit', { id: 'pty-1', code: 0 })
expect(handler).toHaveBeenCalledWith({ id: scopedPty1, code: 0 })
})
it('allows unsubscribing from events', () => {
const handler = vi.fn()
const unsub = provider.onData(handler)
unsub()
const notifHandler = mux.onNotification.mock.calls[0][0]
notifHandler('pty.data', { id: 'pty-1', data: 'output' })
expect(handler).not.toHaveBeenCalled()
})
it('supports multiple listeners', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()
provider.onData(handler1)
provider.onData(handler2)
const notifHandler = mux.onNotification.mock.calls[0][0]
notifHandler('pty.data', { id: 'pty-1', data: 'output' })
expect(handler1).toHaveBeenCalled()
expect(handler2).toHaveBeenCalled()
})
it('namespaces identical relay ids from different SSH connections', () => {
const otherMux = createMockMux()
const otherProvider = new SshPtyProvider('conn-2', otherMux as never)
const firstHandler = vi.fn()
const secondHandler = vi.fn()
provider.onData(firstHandler)
otherProvider.onData(secondHandler)
mux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'first' })
otherMux.onNotification.mock.calls[0][0]('pty.data', { id: 'pty-1', data: 'second' })
expect(firstHandler).toHaveBeenCalledWith({ id: scopedPty1, data: 'first' })
expect(secondHandler).toHaveBeenCalledWith({ id: 'ssh:conn-2@@pty-1', data: 'second' })
})
})
})
+148 -171
View File
@@ -1,63 +1,46 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types'
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env'
import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size'
import type {
RemoteCliBridgeEnv,
SshPtyDataCallback,
SshPtyExitCallback,
SshPtyReplayCallback
} from './ssh-pty-provider-contract'
import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation'
import {
assertSshAgentSessionCreateResult,
requestSshAgentSessionCreate
} from './ssh-agent-session-create-operation'
import { isPtyIncarnationId } from '../../shared/pty-incarnation'
import { mapSshPtyProcessList } from './ssh-agent-session-process-list'
import {
parseSshPtyAttachResult,
reattachSshPtySessionWithExitFence,
type SshPtyAttachResult
} from './ssh-pty-session-reattach'
import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request'
import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities'
type DataCallback = (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
type ReplayCallback = (payload: { id: string; data: string }) => void
type ExitCallback = (payload: { id: string; code: number }) => void
type RemoteCliBridgeEnv = {
binDir: string
relayDir: string
nodePath: string
sockPath: string
pathDelimiter?: ':' | ';'
}
export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
export const SSH_PTY_IDENTITY_MISMATCH_ERROR = 'SSH_PTY_IDENTITY_MISMATCH'
export function isSshPtyNotFoundError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return /PTY ".+" not found/i.test(message)
}
export function isSshPtyIdentityMismatchError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR) || /identity mismatch/i.test(message)
}
// Why: providers take an absolute teardown deadline, but the mux takes a relative
// timeout — convert only here, at the RPC itself, so sequential relay calls share
// the remaining budget (undefined keeps the multiplexer default timeout).
// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch.
function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined {
return deadlineMs === undefined ? undefined : { timeoutMs: Math.max(1, deadlineMs - Date.now()) }
}
/**
* Remote PTY provider that proxies all operations through the relay
* via the JSON-RPC multiplexer. Implements the same IPtyProvider interface
* as LocalPtyProvider so the dispatch layer can route transparently.
*/
/** Remote PTY provider that proxies IPtyProvider operations through the relay. */
export class SshPtyProvider implements IPtyProvider {
private mux: SshChannelMultiplexer
private connectionId: string
private dataListeners = new Set<DataCallback>()
private replayListeners = new Set<ReplayCallback>()
private exitListeners = new Set<ExitCallback>()
// Why: store the unsubscribe handle so dispose() can detach from the
// multiplexer. Without this, notification callbacks keep firing after
// the provider is torn down on disconnect, routing events to stale state.
private dataListeners = new Set<SshPtyDataCallback>()
private replayListeners = new Set<SshPtyReplayCallback>()
private exitListeners = new Set<SshPtyExitCallback>()
// Why: stale notification callbacks must not outlive a disconnected provider.
private unsubscribeNotifications: (() => void) | null = null
readonly getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
private readonly agentSessionCapabilities: SshAgentSessionCapabilities
private spawnExitRaces = new SshPtySpawnExitRaceTracker()
constructor(
connectionId: string,
@@ -66,9 +49,9 @@ export class SshPtyProvider implements IPtyProvider {
) {
this.connectionId = connectionId
this.mux = mux
this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux)
this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId)
// Subscribe to relay notifications for PTY events
this.unsubscribeNotifications = mux.onNotification((method, params) => {
switch (method) {
case 'pty.data':
@@ -92,8 +75,15 @@ export class SshPtyProvider implements IPtyProvider {
break
case 'pty.exit':
this.spawnExitRaces.recordExit(params.id as string, params.incarnationId)
for (const cb of this.exitListeners) {
cb({ id: this.toAppPtyId(params.id as string), code: params.code as number })
cb({
id: this.toAppPtyId(params.id as string),
code: params.code as number,
...(isPtyIncarnationId(params.incarnationId)
? { incarnationId: params.incarnationId }
: {})
})
}
break
}
@@ -110,9 +100,7 @@ export class SshPtyProvider implements IPtyProvider {
this.exitListeners.clear()
}
getConnectionId(): string {
return this.connectionId
}
getConnectionId = (): string => this.connectionId
private toRelayPtyId(id: string): string {
return toRelaySshPtyId(this.connectionId, id)
@@ -123,118 +111,109 @@ export class SshPtyProvider implements IPtyProvider {
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
// Why: when sessionId is present, the caller is requesting reattach to an
// existing relay PTY (persisted across app restart). pty.attach replays
// the buffered output the relay kept alive during the grace window.
if (opts.sessionId) {
const relaySessionId = this.toRelayPtyId(opts.sessionId)
console.warn(
`[ssh-pty] spawn() called with sessionId=${opts.sessionId}, attempting pty.attach`
)
try {
// Why: pass the pane's expected identity so the relay can reject a
// cross-generation id collision (see pty-handler attach) instead of
// replaying the wrong shell into this pane. ORCA_PANE_KEY is the
// renderer's per-pane identity; ORCA_TAB_ID is the coarser fallback.
const expectedPaneKey = opts.paneKey ?? opts.env?.ORCA_PANE_KEY
const expectedTabId = opts.tabId ?? opts.env?.ORCA_TAB_ID
const attachResult = (await this.mux.request('pty.attach', {
id: relaySessionId,
cols: opts.cols,
rows: opts.rows,
suppressReplayNotification: true,
...(expectedPaneKey ? { expectedPaneKey } : {}),
...(expectedTabId ? { expectedTabId } : {})
})) as { replay?: string }
console.warn(
`[ssh-pty] pty.attach succeeded for ${opts.sessionId}, replay=${!!attachResult.replay}`
)
return {
id: this.toAppPtyId(relaySessionId),
isReattach: true,
...(attachResult.replay ? { replay: attachResult.replay } : {})
}
} catch (err) {
// Why: pty.attach fails when the relay grace window has elapsed.
// Surface the exact condition so the renderer can clear the stale
// binding before replacing the dead relay PTY in the same pane.
console.warn(`[ssh-pty] pty.attach FAILED for ${opts.sessionId}:`, err)
if (isSshPtyNotFoundError(err)) {
const mismatchMarker = isSshPtyIdentityMismatchError(err)
? ` ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
: ''
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}${mismatchMarker}`)
}
throw err
if (opts.agentSessionEnsure && opts.sessionId) {
throw new Error('agent_session_claim_unavailable')
}
if (opts.agentSessionEnsure) {
const supportsClaims = await this.supportsAgentSessionClaims({ signal: opts.signal })
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
if (!supportsClaims) {
throw new Error('agent_session_claim_unavailable')
}
}
if (opts.sessionId) {
return await reattachSshPtySessionWithExitFence({
mux: this.mux,
connectionId: this.connectionId,
sessionId: opts.sessionId,
options: opts,
exitRaceTracker: this.spawnExitRaces
})
}
const result = await this.mux.request('pty.spawn', {
cols: opts.cols,
rows: opts.rows,
cwd: opts.cwd,
env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete),
...(opts.envToDelete?.length ? { envToDelete: opts.envToDelete } : {}),
// Why: the relay's plugin-overlay env augmenter needs to know which
// Pi-compatible agent is being launched, while commandDelivery tells it
// whether to submit the command itself for runtime-owned background PTYs.
...(opts.command ? { command: opts.command } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.shellOverride !== undefined ? { shellOverride: opts.shellOverride } : {}),
...(opts.terminalWindowsWslDistro !== undefined
? { terminalWindowsWslDistro: opts.terminalWindowsWslDistro }
: {}),
...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}),
...(opts.startupCommandDelivery
? { startupCommandDelivery: opts.startupCommandDelivery }
: {}),
// Why: main may strip ORCA_PANE_KEY/ORCA_TAB_ID from the shell env when
// remote hooks are disabled, but the relay still needs attach identity
// metadata to reject cross-generation PTY id collisions.
...(opts.paneKey ? { paneKey: opts.paneKey } : {}),
...(opts.tabId ? { tabId: opts.tabId } : {}),
...(opts.startupIngress
? {
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
startupIngress: opts.startupIngress
const supportsCreateOperation = opts.agentSessionCreateOperationId
? await this.supportsAgentSessionCreateOperations({ signal: opts.signal })
: false
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
if (opts.agentSessionCreateOperationId && !supportsCreateOperation) {
// Why: host routing owns legacy selection; a changed relay must not downgrade after dispatch.
throw new Error('execution_owner_unavailable')
}
const operation = this.spawnExitRaces.begin()
try {
const result = await requestSshAgentSessionCreate({
mux: this.mux,
operationId: opts.agentSessionCreateOperationId,
signal: opts.signal,
params: buildSshPtySpawnRequest({
options: opts,
remoteCliBridgeEnv: this.remoteCliBridgeEnv,
supportsCreateOperation
})
})
if (opts.agentSessionCreateOperationId) {
assertSshAgentSessionCreateResult(result)
}
const spawnResult = result as PtySpawnResult
if (this.spawnExitRaces.didMatchingExitArrive(operation, spawnResult)) {
// Why: relay notification can share the response batch; no controller registration may follow.
throw Object.assign(new Error('agent_session_exited_during_start'), {
agentSessionOperationOutcome: 'unknown' as const
})
}
const claimed = spawnResult.agentSessionEnsure
if (opts.agentSessionEnsure) {
const validation = validateClaimedSshSpawn(spawnResult, opts.agentSessionEnsure)
if (!validation.valid) {
if (validation.cleanup === 'created' && typeof spawnResult.id === 'string') {
try {
// Why: immediate relay shutdown resolves only after physical exit;
// a best-effort graceful request cannot prove the duplicate is gone.
await this.mux.request('pty.shutdown', { id: spawnResult.id, immediate: true })
} catch {
throw new Error('execution_owner_unavailable')
}
}
: {})
})
return {
...(result as PtySpawnResult),
id: this.toAppPtyId((result as PtySpawnResult).id),
...(opts.sessionId ? { sessionExpired: true } : {})
throw new Error(validation.error)
}
}
return {
...spawnResult,
id: this.toAppPtyId(spawnResult.id),
...(claimed
? {
agentSessionEnsure: {
...claimed,
owner: {
...claimed.owner,
ptyId: this.toAppPtyId(claimed.owner.ptyId)
}
}
}
: {}),
...(opts.sessionId ? { sessionExpired: true } : {})
}
} finally {
this.spawnExitRaces.finish(operation)
}
}
private withRemoteCliBridgeEnv(
env: Record<string, string> | undefined,
envToDelete?: readonly string[]
): Record<string, string> {
const merged = { ...env }
if (this.remoteCliBridgeEnv) {
const pathDelimiter = this.remoteCliBridgeEnv.pathDelimiter ?? ':'
const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null
if (pathKey) {
const pathValue = merged[pathKey] ?? ''
merged[pathKey] = pathValue.split(pathDelimiter).includes(this.remoteCliBridgeEnv.binDir)
? pathValue
: pathValue
? `${this.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}`
: this.remoteCliBridgeEnv.binDir
}
merged.ORCA_REMOTE_CLI_BIN_DIR = this.remoteCliBridgeEnv.binDir
merged.ORCA_RELAY_DIR = this.remoteCliBridgeEnv.relayDir
merged.ORCA_RELAY_NODE_PATH = this.remoteCliBridgeEnv.nodePath
merged.ORCA_RELAY_SOCKET_PATH = this.remoteCliBridgeEnv.sockPath
}
// Why: match local/daemon precedence—managed defaults and augmentations
// cannot resurrect values the caller explicitly removed.
for (const key of envToDelete ?? []) {
delete merged[key]
}
seedPowerlevel10kWizardEnv(merged, { envToDelete })
return merged
async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise<boolean> {
return await this.agentSessionCapabilities.supportsClaims(options)
}
providesAgentSessionOwnerListings(_ptyId: string): boolean {
return this.agentSessionCapabilities.providesOwnerListings()
}
async supportsAgentSessionCreateOperations(
options: { signal?: AbortSignal } = {}
): Promise<boolean> {
return await this.agentSessionCapabilities.supportsCreateOperations(options)
}
async attach(id: string): Promise<void> {
@@ -244,18 +223,19 @@ export class SshPtyProvider implements IPtyProvider {
async attachForReconnect(
id: string,
expected?: { paneKey?: string; tabId?: string }
): Promise<{ replay?: string }> {
): Promise<SshPtyAttachResult> {
// Why: reconnect owns replay delivery so stale/duplicate attach results can
// be filtered before they reach the renderer. The expected identity lets the
// relay reject a cross-generation id collision instead of reattaching this
// lease to a different pane's freshly spawned PTY.
const result = (await this.mux.request('pty.attach', {
id: this.toRelayPtyId(id),
suppressReplayNotification: true,
...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}),
...(expected?.tabId ? { expectedTabId: expected.tabId } : {})
})) as { replay?: string } | undefined
return result ?? {}
return parseSshPtyAttachResult(
await this.mux.request('pty.attach', {
id: this.toRelayPtyId(id),
suppressReplayNotification: true,
...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}),
...(expected?.tabId ? { expectedTabId: expected.tabId } : {})
})
)
}
write(id: string, data: string): void {
@@ -337,10 +317,7 @@ export class SshPtyProvider implements IPtyProvider {
undefined,
relayTimeoutOptions(opts?.deadlineMs)
)
return (result as PtyProcessInfo[]).map((session) => ({
...session,
id: this.toAppPtyId(session.id)
}))
return mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id))
}
async getDefaultShell(): Promise<string> {
@@ -353,17 +330,17 @@ export class SshPtyProvider implements IPtyProvider {
return result as { name: string; path: string }[]
}
onData(callback: DataCallback): () => void {
onData(callback: SshPtyDataCallback): () => void {
this.dataListeners.add(callback)
return () => this.dataListeners.delete(callback)
}
onReplay(callback: ReplayCallback): () => void {
onReplay(callback: SshPtyReplayCallback): () => void {
this.replayListeners.add(callback)
return () => this.replayListeners.delete(callback)
}
onExit(callback: ExitCallback): () => void {
onExit(callback: SshPtyExitCallback): () => void {
this.exitListeners.add(callback)
return () => this.exitListeners.delete(callback)
}
@@ -0,0 +1,104 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation'
import {
SSH_PTY_IDENTITY_MISMATCH_ERROR,
SSH_SESSION_EXPIRED_ERROR,
isSshPtyIdentityMismatchError,
isSshPtyNotFoundError
} from './ssh-pty-errors'
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
import type { PtySpawnOptions, PtySpawnResult } from './types'
import type { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
export type SshPtyAttachResult = {
replay?: string
incarnationId?: PtyIncarnationId
}
export function parseSshPtyAttachResult(value: unknown): SshPtyAttachResult {
if (value === undefined || value === null) {
return {}
}
if (typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Invalid SSH PTY attach response')
}
const result = value as { replay?: unknown; incarnationId?: unknown }
if (result.replay !== undefined && typeof result.replay !== 'string') {
throw new Error('Invalid SSH PTY attach replay')
}
if (result.incarnationId !== undefined && !isPtyIncarnationId(result.incarnationId)) {
// Why: a present-but-invalid identity cannot safely fence delayed exits from a reused relay id.
throw new Error('Invalid SSH PTY attach incarnation')
}
return {
...(typeof result.replay === 'string' ? { replay: result.replay } : {}),
...(isPtyIncarnationId(result.incarnationId) ? { incarnationId: result.incarnationId } : {})
}
}
export async function reattachSshPtySession(args: {
mux: SshChannelMultiplexer
connectionId: string
sessionId: string
options: PtySpawnOptions
}): Promise<PtySpawnResult> {
const relaySessionId = toRelaySshPtyId(args.connectionId, args.sessionId)
console.warn(`[ssh-pty] spawn() called with sessionId=${args.sessionId}, attempting pty.attach`)
try {
// Why: expected pane identity prevents a reused relay id from attaching the wrong shell.
const expectedPaneKey = args.options.paneKey ?? args.options.env?.ORCA_PANE_KEY
const expectedTabId = args.options.tabId ?? args.options.env?.ORCA_TAB_ID
const attachResult = parseSshPtyAttachResult(
await args.mux.request('pty.attach', {
id: relaySessionId,
cols: args.options.cols,
rows: args.options.rows,
suppressReplayNotification: true,
...(expectedPaneKey ? { expectedPaneKey } : {}),
...(expectedTabId ? { expectedTabId } : {})
})
)
console.warn(
`[ssh-pty] pty.attach succeeded for ${args.sessionId}, replay=${!!attachResult.replay}`
)
return {
id: toAppSshPtyId(args.connectionId, relaySessionId),
isReattach: true,
...(attachResult.replay ? { replay: attachResult.replay } : {}),
...(attachResult.incarnationId ? { incarnationId: attachResult.incarnationId } : {})
}
} catch (error) {
// Why: an expired relay lease must be surfaced distinctly so the renderer clears its binding.
console.warn(`[ssh-pty] pty.attach FAILED for ${args.sessionId}:`, error)
if (isSshPtyNotFoundError(error)) {
const mismatchMarker = isSshPtyIdentityMismatchError(error)
? ` ${SSH_PTY_IDENTITY_MISMATCH_ERROR}`
: ''
throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}${mismatchMarker}`)
}
throw error
}
}
export async function reattachSshPtySessionWithExitFence(
args: Parameters<typeof reattachSshPtySession>[0] & {
exitRaceTracker: SshPtySpawnExitRaceTracker
}
): Promise<PtySpawnResult> {
const operation = args.exitRaceTracker.begin()
try {
const result = await reattachSshPtySession(args)
const relayPtyId = toRelaySshPtyId(args.connectionId, result.id)
if (
args.exitRaceTracker.didMatchingExitArrive(operation, {
id: relayPtyId,
incarnationId: result.incarnationId
})
) {
throw new Error('agent_session_exited_during_start')
}
return result
} finally {
args.exitRaceTracker.finish(operation)
}
}
+32
View File
@@ -0,0 +1,32 @@
import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env'
import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract'
export function buildSshPtySpawnEnv(args: {
env: Record<string, string> | undefined
envToDelete?: readonly string[]
remoteCliBridgeEnv?: RemoteCliBridgeEnv
}): Record<string, string> {
const merged = { ...args.env }
if (args.remoteCliBridgeEnv) {
const pathDelimiter = args.remoteCliBridgeEnv.pathDelimiter ?? ':'
const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null
if (pathKey) {
const pathValue = merged[pathKey] ?? ''
merged[pathKey] = pathValue.split(pathDelimiter).includes(args.remoteCliBridgeEnv.binDir)
? pathValue
: pathValue
? `${args.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}`
: args.remoteCliBridgeEnv.binDir
}
merged.ORCA_REMOTE_CLI_BIN_DIR = args.remoteCliBridgeEnv.binDir
merged.ORCA_RELAY_DIR = args.remoteCliBridgeEnv.relayDir
merged.ORCA_RELAY_NODE_PATH = args.remoteCliBridgeEnv.nodePath
merged.ORCA_RELAY_SOCKET_PATH = args.remoteCliBridgeEnv.sockPath
}
// Why: match local/daemon precedence—managed defaults cannot restore explicitly removed values.
for (const key of args.envToDelete ?? []) {
delete merged[key]
}
seedPowerlevel10kWizardEnv(merged, { envToDelete: args.envToDelete })
return merged
}
@@ -0,0 +1,41 @@
import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation'
type PendingSshPtySpawn = {
exits: { relayPtyId: string; incarnationId?: PtyIncarnationId }[]
}
export class SshPtySpawnExitRaceTracker {
private pending = new Set<PendingSshPtySpawn>()
begin(): PendingSshPtySpawn {
const operation = { exits: [] }
this.pending.add(operation)
return operation
}
recordExit(relayPtyId: string, incarnationId: unknown): void {
for (const operation of this.pending) {
operation.exits.push({
relayPtyId,
...(isPtyIncarnationId(incarnationId) ? { incarnationId } : {})
})
}
}
didMatchingExitArrive(
operation: PendingSshPtySpawn,
result: { id: string; incarnationId?: PtyIncarnationId }
): boolean {
return operation.exits.some(
(exit) =>
exit.relayPtyId === result.id &&
(!exit.incarnationId ||
!result.incarnationId ||
exit.incarnationId === result.incarnationId)
)
}
finish(operation: PendingSshPtySpawn): void {
this.pending.delete(operation)
}
}
@@ -0,0 +1,47 @@
import type { PtySpawnOptions } from './types'
import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract'
import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env'
import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
export function buildSshPtySpawnRequest(args: {
options: PtySpawnOptions
remoteCliBridgeEnv?: RemoteCliBridgeEnv
supportsCreateOperation: boolean
}): Record<string, unknown> {
const { options } = args
return {
cols: options.cols,
rows: options.rows,
cwd: options.cwd,
env: buildSshPtySpawnEnv({
env: options.env,
envToDelete: options.envToDelete,
remoteCliBridgeEnv: args.remoteCliBridgeEnv
}),
...(options.envToDelete?.length ? { envToDelete: options.envToDelete } : {}),
// Why: the relay needs launch identity for plugin env overlays and provider-side delivery.
...(options.command ? { command: options.command } : {}),
...(options.launchAgent ? { launchAgent: options.launchAgent } : {}),
...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}),
...(options.terminalWindowsWslDistro !== undefined
? { terminalWindowsWslDistro: options.terminalWindowsWslDistro }
: {}),
...(options.commandDelivery ? { commandDelivery: options.commandDelivery } : {}),
...(options.startupCommandDelivery
? { startupCommandDelivery: options.startupCommandDelivery }
: {}),
// Why: attach identity must survive even when hook variables are stripped from the shell env.
...(options.paneKey ? { paneKey: options.paneKey } : {}),
...(options.tabId ? { tabId: options.tabId } : {}),
...(options.startupIngress
? {
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
startupIngress: options.startupIngress
}
: {}),
...(options.agentSessionEnsure ? { agentSessionEnsure: options.agentSessionEnsure } : {}),
...(args.supportsCreateOperation
? { agentSessionCreateOperationId: options.agentSessionCreateOperationId }
: {})
}
}
+29 -1
View File
@@ -26,6 +26,12 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges
import type { GitProviderStatusOptions } from './git-provider-status-options'
import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events'
import type { PtySpawnResult } from './pty-spawn-result'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type {
AgentSessionExecutionClaim,
AgentSessionOwnerBinding,
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
export type {
PtyBackgroundStreamEvent,
@@ -96,24 +102,44 @@ export type PtySpawnOptions = {
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
/** Fresh-spawn-only source authority installed before any PTY output is released. */
startupIngress?: PtyStartupIngressIntent
agentSessionEnsure?: {
claim: AgentSessionExecutionClaim
surface: AgentSessionSurfaceBinding
}
/** Host-scoped structured-create identity used only for lower-owner replay. */
agentSessionCreateOperationId?: string
/** Signals that the native process exists even if later publication fails. */
onPtySpawnCommitted?: () => void
/** Cancels only before physical dispatch; operation identity fences later ambiguity. */
signal?: AbortSignal
}
export type { PtySpawnResult }
export type PtyProcessInfo = {
id: string
incarnationId?: PtyIncarnationId
cwd: string
title: string
/** Owning worktree when the provider can report it authoritatively. */
worktreeId?: string
/** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */
terminalHandle?: string
agentSessionOwners?: AgentSessionOwnerBinding[]
}
type PtyProbeOptions = { signal?: AbortSignal }
export type IPtyProvider = {
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
/** Whether this spawn target can append the Git guard after its final env merge. */
supportsGitCredentialGuardHost?: (sessionId?: string) => boolean
/** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */
supportsAgentSessionClaims?: (options?: PtyProbeOptions) => boolean | Promise<boolean>
/** Whether missing claim metadata in this PTY's process listing proves absence. */
providesAgentSessionOwnerListings?: (ptyId: string) => boolean
/** Whether fresh structured creates can replay one spawn across a lost relay response. */
supportsAgentSessionCreateOperations?: (options?: PtyProbeOptions) => boolean | Promise<boolean>
attach(id: string): Promise<void>
hasPty?: (id: string) => boolean
write(id: string, data: string): void
@@ -189,7 +215,9 @@ export type IPtyProvider = {
getProfiles(): Promise<{ name: string; path: string }[]>
onData(callback: (payload: PtyDataEvent) => void): () => void
onReplay(callback: (payload: { id: string; data: string }) => void): () => void
onExit(callback: (payload: { id: string; code: number }) => void): () => void
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void
}
// ─── Filesystem Provider ────────────────────────────────────────────
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
canonicalizeAgentSessionIdentity,
createEphemeralAgentSessionClaimSigner
} from './agent-session-claim-identity'
describe('agent session claim identity', () => {
it('creates stable opaque identity and worktree digests', () => {
const signer = createEphemeralAgentSessionClaimSigner('profile-1')
const identity = canonicalizeAgentSessionIdentity('codex', {
key: 'session_id',
id: 'session-1'
})
const namespace = {
machine: 'machine',
principal: 'user',
container: 'native',
providerRoot: 'default'
}
const first = signer.createClaim({ namespace, identity, canonicalWorktreeId: 'worktree-1' })
const second = signer.createClaim({ namespace, identity, canonicalWorktreeId: 'worktree-1' })
const otherWorktree = signer.createClaim({
namespace,
identity,
canonicalWorktreeId: 'worktree-2'
})
expect(first).toEqual(second)
expect(first.identityDigest).not.toContain('session-1')
expect(otherWorktree.identityDigest).toBe(first.identityDigest)
expect(otherWorktree.worktreeScopeDigest).not.toBe(first.worktreeScopeDigest)
})
it('rejects malformed and unsupported provider identity', () => {
expect(() =>
canonicalizeAgentSessionIdentity('codex', { key: 'session_id', id: '-unsafe' })
).toThrow('agent_session_identity_required')
expect(() =>
canonicalizeAgentSessionIdentity('blank', { key: 'session_id', id: 'session-1' })
).toThrow('agent_session_identity_required')
})
})
@@ -0,0 +1,177 @@
import { createHash, createHmac, randomBytes } from 'node:crypto'
import {
closeSync,
mkdirSync,
openSync,
readFileSync,
realpathSync,
statSync,
writeFileSync
} from 'node:fs'
import { dirname, isAbsolute, join, normalize } from 'node:path'
import {
AGENT_SESSION_CLAIM_DIGEST_VERSION,
type AgentSessionExecutionClaim
} from '../../shared/agent-session-host-authority'
import {
getAgentResumeArgv,
isResumableTuiAgent,
normalizeAgentProviderSession,
type AgentProviderSessionMetadata,
type ResumableTuiAgent
} from '../../shared/agent-session-resume'
const COORDINATION_KEY_BYTES = 32
const COORDINATION_KEY_FILE = 'agent-session-authority.key'
const TRANSCRIPT_PATH_MAX_BYTES = 16 * 1024
export type ProviderExecutionNamespace = {
machine: string
principal: string
container: string
providerRoot: string
}
export type CanonicalAgentSessionIdentity = {
agent: ResumableTuiAgent
providerSession: AgentProviderSessionMetadata
}
function encodeFields(fields: readonly string[]): Buffer {
const chunks: Buffer[] = []
for (const field of fields) {
const value = Buffer.from(field, 'utf8')
const length = Buffer.allocUnsafe(4)
length.writeUInt32BE(value.length)
chunks.push(length, value)
}
return Buffer.concat(chunks)
}
function canonicalPathForPlatform(value: string): string {
const canonical = normalize(realpathSync(value))
return process.platform === 'win32' ? canonical.toLocaleLowerCase('en-US') : canonical
}
export function canonicalizeAgentSessionIdentity(
agent: unknown,
rawProviderSession: unknown
): CanonicalAgentSessionIdentity {
if (!isResumableTuiAgent(agent)) {
throw new Error('agent_session_identity_required')
}
const providerSession = normalizeAgentProviderSession(rawProviderSession)
if (!providerSession || !getAgentResumeArgv(agent, providerSession)) {
throw new Error('agent_session_identity_required')
}
if (agent !== 'pi') {
return { agent, providerSession }
}
const transcriptPath = providerSession.transcriptPath
if (
!transcriptPath ||
!isAbsolute(transcriptPath) ||
Buffer.byteLength(transcriptPath, 'utf8') > TRANSCRIPT_PATH_MAX_BYTES
) {
throw new Error('agent_session_identity_required')
}
const canonicalTranscriptPath = canonicalPathForPlatform(transcriptPath)
if (!statSync(canonicalTranscriptPath).isFile()) {
throw new Error('agent_session_identity_required')
}
return {
agent,
providerSession: { ...providerSession, transcriptPath: canonicalTranscriptPath }
}
}
export class AgentSessionClaimSigner {
readonly keyId: string
constructor(
private readonly authorityDomainId: string,
private readonly key: Buffer
) {
if (key.length !== COORDINATION_KEY_BYTES) {
throw new Error('agent_session_ownership_unknown')
}
this.keyId = createHash('sha256').update(key).digest('base64url').slice(0, 22)
}
createClaim(args: {
namespace: ProviderExecutionNamespace
identity: CanonicalAgentSessionIdentity
canonicalWorktreeId: string
}): AgentSessionExecutionClaim {
const namespaceFields = [
args.namespace.machine,
args.namespace.principal,
args.namespace.container,
args.namespace.providerRoot
]
const identityFields = [
'orca-agent-session-claim-v1',
this.authorityDomainId,
...namespaceFields,
args.identity.agent,
args.identity.providerSession.key,
args.identity.providerSession.id,
args.identity.agent === 'pi' ? (args.identity.providerSession.transcriptPath ?? '') : ''
]
const worktreeFields = [
'orca-agent-session-worktree-v1',
this.authorityDomainId,
...namespaceFields,
args.canonicalWorktreeId
]
return {
digestVersion: AGENT_SESSION_CLAIM_DIGEST_VERSION,
keyId: this.keyId,
identityDigest: createHmac('sha256', this.key)
.update(encodeFields(identityFields))
.digest('base64url'),
worktreeScopeDigest: createHmac('sha256', this.key)
.update(encodeFields(worktreeFields))
.digest('base64url'),
agent: args.identity.agent
}
}
}
export function loadAgentSessionClaimSigner(
profileDirectory: string,
authorityDomainId: string
): AgentSessionClaimSigner {
const keyPath = join(profileDirectory, COORDINATION_KEY_FILE)
mkdirSync(dirname(keyPath), { recursive: true })
let key: Buffer
try {
key = readFileSync(keyPath)
} catch {
const candidate = randomBytes(COORDINATION_KEY_BYTES)
let fd: number | null = null
try {
fd = openSync(keyPath, 'wx', 0o600)
writeFileSync(fd, candidate)
key = candidate
} catch {
key = readFileSync(keyPath)
} finally {
if (fd !== null) {
closeSync(fd)
}
}
}
// Why: a replaced/corrupt key could make a surviving owner look absent;
// refuse authority instead of silently minting an incomparable namespace.
if (key.length !== COORDINATION_KEY_BYTES) {
throw new Error('agent_session_ownership_unknown')
}
return new AgentSessionClaimSigner(authorityDomainId, key)
}
export function createEphemeralAgentSessionClaimSigner(
authorityDomainId: string
): AgentSessionClaimSigner {
return new AgentSessionClaimSigner(authorityDomainId, randomBytes(COORDINATION_KEY_BYTES))
}
@@ -8,6 +8,8 @@ const MOBILE_DYNAMIC_RPC_METHODS = [
// mobile source scan below, but still must stay mobile-authorized.
'accounts.selectClaude',
'accounts.selectCodex',
'terminal.createAgentSession',
'terminal.ensureAgentSession',
'github.updateIssue',
'github.updatePRState',
'gitlab.updateIssue',
@@ -0,0 +1,335 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import {
retireTerminalSurfaceFromPersistence,
sanitizeWorkspaceSessionTerminalRetirements
} from './mobile-session-terminal-persistence-retirement'
const WORKTREE_ID = 'repo::/worktree'
const REPO_ID = 'repo'
describe('mobile session terminal persistence retirement', () => {
it('de-persists a final leaf and repairs active group state', () => {
const session = {
...getDefaultWorkspaceSession(),
activeTabId: 'terminal',
activeTabIdByWorktree: { [WORKTREE_ID]: 'terminal' },
activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' as const },
activeGroupIdByWorktree: { [WORKTREE_ID]: 'terminal-group' },
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'terminal',
ptyId: 'pty-left',
worktreeId: WORKTREE_ID,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
terminal: {
root: { type: 'leaf' as const, leafId: 'left' },
activeLeafId: 'left',
expandedLeafId: null,
ptyIdsByLeafId: { left: 'pty-left' }
}
},
unifiedTabs: {
[WORKTREE_ID]: [
{
id: 'terminal',
entityId: 'terminal',
groupId: 'terminal-group',
worktreeId: WORKTREE_ID,
contentType: 'terminal' as const,
label: 'Terminal',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
},
{
id: 'notes',
entityId: 'notes.md',
groupId: 'notes-group',
worktreeId: WORKTREE_ID,
contentType: 'editor' as const,
label: 'Notes',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 2
}
]
},
tabGroups: {
[WORKTREE_ID]: [
{
id: 'terminal-group',
worktreeId: WORKTREE_ID,
activeTabId: 'terminal',
tabOrder: ['terminal']
},
{
id: 'notes-group',
worktreeId: WORKTREE_ID,
activeTabId: 'notes',
tabOrder: ['notes']
}
]
},
tabGroupLayouts: {
[WORKTREE_ID]: {
type: 'split' as const,
direction: 'horizontal' as const,
first: { type: 'leaf' as const, groupId: 'terminal-group' },
second: { type: 'leaf' as const, groupId: 'notes-group' }
}
},
remoteSessionIdsByTabId: { terminal: 'pty-left' }
}
const result = retireTerminalSurfaceFromPersistence(session, {
worktreeId: WORKTREE_ID,
parentTabId: 'terminal',
leafId: 'left',
ptyId: 'pty-left'
})
expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([])
expect(result.terminalLayoutsByTabId.terminal).toBeUndefined()
expect(result.unifiedTabs?.[WORKTREE_ID].map((tab) => tab.id)).toEqual(['notes'])
expect(result.tabGroups?.[WORKTREE_ID].map((group) => group.id)).toEqual(['notes-group'])
expect(result.tabGroupLayouts?.[WORKTREE_ID]).toEqual({
type: 'leaf',
groupId: 'notes-group'
})
expect(result.activeTabIdByWorktree?.[WORKTREE_ID]).toBe('notes')
expect(result.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor')
expect(result.activeGroupIdByWorktree?.[WORKTREE_ID]).toBe('notes-group')
expect(result.remoteSessionIdsByTabId?.terminal).toBeUndefined()
})
it('retires an exact split leaf when only its sibling has a persisted PTY mapping', () => {
const session = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'terminal',
ptyId: 'pty-right',
worktreeId: WORKTREE_ID,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
terminal: {
root: {
type: 'split' as const,
direction: 'vertical' as const,
first: { type: 'leaf' as const, leafId: 'left' },
second: { type: 'leaf' as const, leafId: 'right' }
},
activeLeafId: 'left',
expandedLeafId: null,
ptyIdsByLeafId: { right: 'pty-right' }
}
}
}
const result = retireTerminalSurfaceFromPersistence(session, {
worktreeId: WORKTREE_ID,
parentTabId: 'terminal',
leafId: 'left',
ptyId: 'pty-left'
})
expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([
expect.objectContaining({ id: 'terminal', ptyId: 'pty-right' })
])
expect(result.terminalLayoutsByTabId.terminal).toMatchObject({
root: { type: 'leaf', leafId: 'right' },
ptyIdsByLeafId: { right: 'pty-right' }
})
})
it('fences an absent exact leaf without deleting its live sibling parent', () => {
const session = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'terminal',
ptyId: 'pty-right',
worktreeId: WORKTREE_ID,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
terminal: {
root: { type: 'leaf' as const, leafId: 'right' },
activeLeafId: 'right',
expandedLeafId: null,
ptyIdsByLeafId: { right: 'pty-right' }
}
},
terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-left' },
remoteSessionIdsByTabId: { terminal: 'pty-right' }
}
const result = retireTerminalSurfaceFromPersistence(session, {
worktreeId: WORKTREE_ID,
parentTabId: 'terminal',
leafId: 'left',
ptyId: 'pty-left',
incarnationId: 'incarnation-left',
retiredAt: 42
})
expect(result.tabsByWorktree[WORKTREE_ID]).toEqual(session.tabsByWorktree[WORKTREE_ID])
expect(result.terminalLayoutsByTabId.terminal).toEqual(session.terminalLayoutsByTabId.terminal)
expect(result.remoteSessionIdsByTabId).toEqual({ terminal: 'pty-right' })
expect(result.terminalPtyIncarnationsByPaneKey?.['terminal:left']).toBeUndefined()
expect(result.terminalSurfaceTombstonesByPaneKey).toEqual({})
expect(result.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1)
})
it('does not treat a sibling parent PTY as the exact leaf when layout is unavailable', () => {
const session = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'terminal',
ptyId: 'pty-right',
worktreeId: WORKTREE_ID,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
}
const result = retireTerminalSurfaceFromPersistence(session, {
worktreeId: WORKTREE_ID,
parentTabId: 'terminal',
leafId: 'left',
ptyId: 'pty-left',
incarnationId: 'incarnation-left'
})
expect(result.tabsByWorktree[WORKTREE_ID]).toEqual(session.tabsByWorktree[WORKTREE_ID])
expect(result.terminalSurfaceTombstonesByPaneKey).toEqual({})
expect(result.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1)
})
it('rebases stale writes onto durable host membership without retaining pane history', () => {
const staleSession = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'terminal',
ptyId: 'pty-left',
worktreeId: WORKTREE_ID,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
terminal: {
root: { type: 'leaf' as const, leafId: 'left' },
activeLeafId: 'left',
expandedLeafId: null,
ptyIdsByLeafId: { left: 'pty-left' }
}
},
terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-a' }
}
const retired = retireTerminalSurfaceFromPersistence(staleSession, {
worktreeId: WORKTREE_ID,
parentTabId: 'terminal',
leafId: 'left',
ptyId: 'pty-left',
incarnationId: 'incarnation-a',
retiredAt: 42
})
const afterStaleWrite = sanitizeWorkspaceSessionTerminalRetirements(staleSession, retired)
expect(afterStaleWrite.tabsByWorktree[WORKTREE_ID]).toEqual([])
expect(afterStaleWrite.terminalSurfaceTombstonesByPaneKey).toEqual({})
expect(afterStaleWrite.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1)
const afterRestart = sanitizeWorkspaceSessionTerminalRetirements(staleSession, afterStaleWrite)
expect(afterRestart.tabsByWorktree[WORKTREE_ID]).toEqual([])
const untrustedReplacement = sanitizeWorkspaceSessionTerminalRetirements(
{
...staleSession,
terminalPtyIncarnationsByPaneKey: { 'terminal:left': 'incarnation-b' }
},
afterRestart
)
expect(untrustedReplacement.tabsByWorktree[WORKTREE_ID]).toEqual([])
expect(untrustedReplacement.terminalPtyIncarnationsByPaneKey).toBeUndefined()
})
it('migrates legacy tombstones into one repo watermark', () => {
const stale = {
...getDefaultWorkspaceSession(),
terminalSurfaceTombstonesByPaneKey: {
'terminal:left': {
worktreeId: WORKTREE_ID,
parentTabId: 'terminal',
leafId: 'left',
ptyId: 'pty-left',
incarnationId: 'incarnation-a',
retiredAt: 42
}
}
}
const migrated = sanitizeWorkspaceSessionTerminalRetirements(stale, stale)
expect(migrated.terminalSurfaceTombstonesByPaneKey).toEqual({})
expect(migrated.terminalTopologyRevisionByRepoId).toEqual({ [REPO_ID]: 1 })
})
it('keeps retirement state proportional to repos across many worktrees and closed panes', () => {
let session = getDefaultWorkspaceSession()
for (let index = 0; index < 1_000; index += 1) {
session = retireTerminalSurfaceFromPersistence(session, {
worktreeId: `${REPO_ID}::/worktree-${index}`,
parentTabId: `terminal-${index}`,
leafId: `leaf-${index}`,
ptyId: `pty-${index}`,
incarnationId: `incarnation-${index}`
})
}
expect(session.terminalSurfaceTombstonesByPaneKey).toEqual({})
expect(Object.keys(session.terminalTopologyRevisionByRepoId ?? {})).toEqual([REPO_ID])
expect(session.terminalTopologyRevisionByRepoId?.[REPO_ID]).toBe(1_000)
})
})
@@ -0,0 +1,258 @@
import type { WorkspaceSessionState, WorkspaceVisibleTabType } from '../../shared/types'
import {
pruneTabGroupLayoutAfterRetirement,
repairMobileSessionTabGroupsAfterRetirement,
retireLeavesFromTerminalLayout,
type RetiredTerminalSurface
} from './mobile-session-terminal-retirement'
import {
advanceTerminalTopologyRevision,
rebaseWorkspaceSessionTerminalMembership
} from './workspace-session-terminal-membership-authority'
function visibleTypeForContentType(
contentType: string | undefined
): WorkspaceVisibleTabType | undefined {
if (contentType === 'terminal') {
return 'terminal'
}
if (contentType === 'browser') {
return 'browser'
}
if (contentType === 'simulator') {
return 'simulator'
}
return contentType ? 'editor' : undefined
}
function layoutContainsLeaf(
node: WorkspaceSessionState['terminalLayoutsByTabId'][string]['root'],
leafId: string
): boolean {
if (!node) {
return false
}
return node.type === 'leaf'
? node.leafId === leafId
: layoutContainsLeaf(node.first, leafId) || layoutContainsLeaf(node.second, leafId)
}
function recordTerminalSurfaceRetirement(
session: WorkspaceSessionState,
surface: RetiredTerminalSurface,
paneKey: string
): WorkspaceSessionState {
const terminalPtyIncarnationsByPaneKey = {
...session.terminalPtyIncarnationsByPaneKey
}
delete terminalPtyIncarnationsByPaneKey[paneKey]
const terminalSurfaceTombstonesByPaneKey = {
...session.terminalSurfaceTombstonesByPaneKey
}
delete terminalSurfaceTombstonesByPaneKey[paneKey]
return advanceTerminalTopologyRevision(
{
...session,
terminalPtyIncarnationsByPaneKey,
terminalSurfaceTombstonesByPaneKey
},
surface.worktreeId
)
}
export function retireTerminalSurfaceFromPersistence(
session: WorkspaceSessionState,
surface: RetiredTerminalSurface
): WorkspaceSessionState {
const paneKey = `${surface.parentTabId}:${surface.leafId}`
const boundIncarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey]
if (surface.incarnationId && boundIncarnationId && boundIncarnationId !== surface.incarnationId) {
return session
}
const persistedTabs = session.tabsByWorktree[surface.worktreeId] ?? []
const persistedTab = persistedTabs.find((tab) => tab.id === surface.parentTabId)
const layout = session.terminalLayoutsByTabId[surface.parentTabId]
const exactLeafInLayout = Boolean(layout && layoutContainsLeaf(layout.root, surface.leafId))
const leafPtyId = exactLeafInLayout ? layout?.ptyIdsByLeafId?.[surface.leafId] : undefined
if (leafPtyId && leafPtyId !== surface.ptyId) {
return session
}
const isLegacyFinalSurface = !layout && persistedTab?.ptyId === surface.ptyId
if (!exactLeafInLayout && !isLegacyFinalSurface) {
// Why: tab.ptyId may describe a live sibling. The absent exact leaf still
// needs a tombstone, but sibling evidence must not remove its parent.
return recordTerminalSurfaceRetirement(session, surface, paneKey)
}
const nextLayout =
exactLeafInLayout && layout
? retireLeavesFromTerminalLayout(layout, new Set([surface.leafId]))
: null
const removeParent = !nextLayout
const nextTabsForWorktree = removeParent
? persistedTabs.filter((tab) => tab.id !== surface.parentTabId)
: persistedTabs.map((tab) =>
tab.id === surface.parentTabId
? {
...tab,
ptyId:
nextLayout.ptyIdsByLeafId?.[nextLayout.activeLeafId ?? ''] ??
Object.values(nextLayout.ptyIdsByLeafId ?? {})[0] ??
null
}
: tab
)
const terminalLayoutsByTabId = { ...session.terminalLayoutsByTabId }
if (nextLayout) {
terminalLayoutsByTabId[surface.parentTabId] = nextLayout
} else {
delete terminalLayoutsByTabId[surface.parentTabId]
}
const unifiedTabsForWorktree = session.unifiedTabs?.[surface.worktreeId] ?? []
const unifiedTabs = session.unifiedTabs
? {
...session.unifiedTabs,
[surface.worktreeId]: removeParent
? unifiedTabsForWorktree.filter(
(tab) => tab.id !== surface.parentTabId && tab.entityId !== surface.parentTabId
)
: unifiedTabsForWorktree
}
: undefined
const validTopLevelIds = new Set((unifiedTabs?.[surface.worktreeId] ?? []).map((tab) => tab.id))
for (const tab of nextTabsForWorktree) {
validTopLevelIds.add(tab.id)
}
const persistedGroups = session.tabGroups?.[surface.worktreeId]
const repairedGroups = repairMobileSessionTabGroupsAfterRetirement(
persistedGroups,
validTopLevelIds
)
const tabGroups = session.tabGroups
? {
...session.tabGroups,
[surface.worktreeId]: (repairedGroups ?? []).map((group) => ({
...group,
worktreeId: surface.worktreeId
}))
}
: undefined
const retainedGroupIds = new Set(repairedGroups?.map((group) => group.id) ?? [])
const repairedGroupLayout = pruneTabGroupLayoutAfterRetirement(
session.tabGroupLayouts?.[surface.worktreeId],
retainedGroupIds
)
const tabGroupLayouts = session.tabGroupLayouts ? { ...session.tabGroupLayouts } : undefined
if (tabGroupLayouts) {
if (repairedGroupLayout) {
tabGroupLayouts[surface.worktreeId] = repairedGroupLayout
} else {
delete tabGroupLayouts[surface.worktreeId]
}
}
const previousActiveTabId = session.activeTabIdByWorktree?.[surface.worktreeId]
const activeTabStillExists = previousActiveTabId && validTopLevelIds.has(previousActiveTabId)
const nextActiveTabId =
(activeTabStillExists ? previousActiveTabId : undefined) ??
repairedGroups?.find(
(group) => group.id === session.activeGroupIdByWorktree?.[surface.worktreeId]
)?.activeTabId ??
repairedGroups?.[0]?.activeTabId ??
[...validTopLevelIds][0] ??
null
const activeTabIdByWorktree = {
...session.activeTabIdByWorktree,
[surface.worktreeId]: nextActiveTabId
}
const activeTabTypeByWorktree = { ...session.activeTabTypeByWorktree }
const activeUnifiedTab = unifiedTabs?.[surface.worktreeId]?.find(
(tab) => tab.id === nextActiveTabId
)
const nextActiveType = visibleTypeForContentType(activeUnifiedTab?.contentType)
if (nextActiveType) {
activeTabTypeByWorktree[surface.worktreeId] = nextActiveType
} else if (!nextActiveTabId) {
delete activeTabTypeByWorktree[surface.worktreeId]
}
const activeGroupIdByWorktree = { ...session.activeGroupIdByWorktree }
const nextActiveGroupId =
repairedGroups?.find((group) => group.tabOrder.includes(nextActiveTabId ?? ''))?.id ??
repairedGroups?.[0]?.id
if (nextActiveGroupId) {
activeGroupIdByWorktree[surface.worktreeId] = nextActiveGroupId
} else {
delete activeGroupIdByWorktree[surface.worktreeId]
}
const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId }
if (removeParent) {
delete remoteSessionIdsByTabId[surface.parentTabId]
} else if (remoteSessionIdsByTabId[surface.parentTabId] === surface.ptyId) {
remoteSessionIdsByTabId[surface.parentTabId] =
nextLayout?.ptyIdsByLeafId?.[nextLayout.activeLeafId ?? ''] ??
Object.values(nextLayout?.ptyIdsByLeafId ?? {})[0] ??
''
if (!remoteSessionIdsByTabId[surface.parentTabId]) {
delete remoteSessionIdsByTabId[surface.parentTabId]
}
}
return recordTerminalSurfaceRetirement(
{
...session,
activeTabId:
session.activeTabId === surface.parentTabId ? nextActiveTabId : session.activeTabId,
tabsByWorktree: {
...session.tabsByWorktree,
[surface.worktreeId]: nextTabsForWorktree
},
terminalLayoutsByTabId,
activeTabIdByWorktree,
...(unifiedTabs ? { unifiedTabs } : {}),
...(tabGroups ? { tabGroups } : {}),
...(tabGroupLayouts ? { tabGroupLayouts } : {}),
...(session.activeGroupIdByWorktree ? { activeGroupIdByWorktree } : {}),
...(session.activeTabTypeByWorktree ? { activeTabTypeByWorktree } : {}),
...(session.remoteSessionIdsByTabId ? { remoteSessionIdsByTabId } : {})
},
surface,
paneKey
)
}
export function sanitizeWorkspaceSessionTerminalRetirements(
incoming: WorkspaceSessionState,
prior: WorkspaceSessionState | undefined
): WorkspaceSessionState {
if (
!prior?.terminalSurfaceTombstonesByPaneKey &&
!incoming.terminalSurfaceTombstonesByPaneKey &&
!prior?.terminalTopologyRevisionByRepoId
) {
return incoming
}
const bindings = {
...prior?.terminalPtyIncarnationsByPaneKey,
...incoming.terminalPtyIncarnationsByPaneKey
}
const tombstones = {
...prior?.terminalSurfaceTombstonesByPaneKey,
...incoming.terminalSurfaceTombstonesByPaneKey
}
const hasLegacyTombstones = Object.keys(tombstones).length > 0
let next: WorkspaceSessionState = {
...incoming,
terminalPtyIncarnationsByPaneKey: hasLegacyTombstones
? bindings
: incoming.terminalPtyIncarnationsByPaneKey,
terminalSurfaceTombstonesByPaneKey: tombstones
}
for (const tombstone of Object.values(tombstones)) {
next = retireTerminalSurfaceFromPersistence(next, tombstone)
}
return rebaseWorkspaceSessionTerminalMembership(
{ ...next, terminalSurfaceTombstonesByPaneKey: {} },
prior
)
}
@@ -0,0 +1,268 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type {
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab
} from '../../shared/runtime-types'
import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement'
import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement'
const WORKTREE_ID = 'repo::/worktree'
function splitSnapshot(): RuntimeMobileSessionTabsSnapshot {
const parentLayout = {
root: {
type: 'split' as const,
direction: 'vertical' as const,
first: { type: 'leaf' as const, leafId: 'left' },
second: { type: 'leaf' as const, leafId: 'right' }
},
activeLeafId: 'left',
expandedLeafId: 'left',
ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' },
buffersByLeafId: { left: 'left buffer', right: 'right buffer' },
titlesByLeafId: { left: 'Left', right: 'Right' }
}
return {
worktree: WORKTREE_ID,
publicationEpoch: 'host',
snapshotVersion: 4,
activeGroupId: 'terminals',
activeTabId: 'terminal::left',
activeTabType: 'terminal',
tabGroups: [
{
id: 'terminals',
activeTabId: 'terminal',
tabOrder: ['terminal', 'notes'],
recentTabIds: ['notes', 'terminal']
},
{ id: 'secondary', activeTabId: 'other', tabOrder: ['other'] }
],
tabGroupLayout: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'terminals' },
second: { type: 'leaf', groupId: 'secondary' }
},
tabs: [
{
type: 'terminal',
id: 'terminal::left',
parentTabId: 'terminal',
leafId: 'left',
ptyId: 'pty-left',
title: 'Left',
parentLayout,
isActive: true
},
{
type: 'terminal',
id: 'terminal::right',
parentTabId: 'terminal',
leafId: 'right',
ptyId: 'pty-right',
title: 'Right',
parentLayout,
isActive: false
},
{
type: 'markdown',
id: 'notes',
title: 'Notes',
filePath: '/worktree/notes.md',
relativePath: 'notes.md',
language: 'markdown',
mode: 'edit',
isDirty: false,
sourceFileId: 'notes.md',
sourceFilePath: '/worktree/notes.md',
sourceRelativePath: 'notes.md',
documentVersion: '1',
isActive: false
},
{
type: 'file',
id: 'other',
title: 'Other',
filePath: '/worktree/other.ts',
relativePath: 'other.ts',
language: 'typescript',
isDirty: false,
isActive: false
}
]
}
}
describe('mobile session terminal retirement', () => {
it('retires one split leaf and rewrites every surviving sibling layout', () => {
const result = retireTerminalSurfacesFromSnapshot({
snapshot: splitSnapshot(),
ptyId: 'pty-left'
})
expect(result?.snapshot).toMatchObject({
snapshotVersion: 5,
activeGroupId: 'terminals',
activeTabId: 'terminal::right',
activeTabType: 'terminal'
})
expect(result?.snapshot.tabs).toHaveLength(3)
expect(result?.snapshot.tabs[0]).toMatchObject({
id: 'terminal::right',
isActive: true,
parentLayout: {
root: { type: 'leaf', leafId: 'right' },
activeLeafId: 'right',
expandedLeafId: null,
ptyIdsByLeafId: { right: 'pty-right' },
buffersByLeafId: { right: 'right buffer' },
titlesByLeafId: { right: 'Right' }
}
})
})
it('removes the final parent, empty group, and group-layout leaf', () => {
const first = retireTerminalSurfacesFromSnapshot({
snapshot: splitSnapshot(),
ptyId: 'pty-left'
})!
const result = retireTerminalSurfacesFromSnapshot({
snapshot: first.snapshot,
ptyId: 'pty-right'
})
expect(result?.snapshot).toMatchObject({
activeGroupId: 'terminals',
activeTabId: 'notes',
activeTabType: 'markdown',
tabGroupLayout: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'terminals' },
second: { type: 'leaf', groupId: 'secondary' }
}
})
expect(result?.snapshot.tabGroups?.[0]).toMatchObject({
id: 'terminals',
activeTabId: 'notes',
tabOrder: ['notes']
})
expect(result?.snapshot.tabs.map((tab) => tab.id)).toEqual(['notes', 'other'])
})
it('does not retire an exact surface rebound to a replacement PTY', () => {
const snapshot = splitSnapshot()
const rebound = {
...snapshot,
tabs: snapshot.tabs.map((tab) =>
tab.type === 'terminal' && tab.leafId === 'left'
? {
...tab,
ptyId: 'pty-replacement',
parentLayout: tab.parentLayout
? {
...tab.parentLayout,
ptyIdsByLeafId: {
...tab.parentLayout.ptyIdsByLeafId,
left: 'pty-replacement'
}
}
: undefined
}
: tab
)
}
expect(
retireTerminalSurfacesFromSnapshot({
snapshot: rebound,
ptyId: 'pty-left',
exactSurfaces: [{ parentTabId: 'terminal', leafId: 'left' }]
})
).toBeNull()
})
it('de-persists an exact leaf without removing its live sibling', () => {
const session = {
...getDefaultWorkspaceSession(),
activeTabId: 'terminal',
activeTabIdByWorktree: { [WORKTREE_ID]: 'terminal' },
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'terminal',
ptyId: 'pty-left',
worktreeId: WORKTREE_ID,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
terminal: (splitSnapshot().tabs[0] as RuntimeMobileSessionTerminalTab).parentLayout!
}
}
const result = retireTerminalSurfaceFromPersistence(session, {
worktreeId: WORKTREE_ID,
parentTabId: 'terminal',
leafId: 'left',
ptyId: 'pty-left'
})
expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([
expect.objectContaining({ id: 'terminal', ptyId: 'pty-right' })
])
expect(result.terminalLayoutsByTabId.terminal).toMatchObject({
root: { type: 'leaf', leafId: 'right' },
activeLeafId: 'right',
ptyIdsByLeafId: { right: 'pty-right' }
})
})
it('retires a permanently exited surface despite a stale sleeping record', () => {
const session = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'terminal',
ptyId: 'pty-left',
worktreeId: WORKTREE_ID,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
terminal: {
root: { type: 'leaf' as const, leafId: 'left' },
activeLeafId: 'left',
expandedLeafId: null,
ptyIdsByLeafId: { left: 'pty-left' }
}
},
sleepingAgentSessionsByPaneKey: {
'terminal:left': {} as never
}
}
const result = retireTerminalSurfaceFromPersistence(session, {
worktreeId: WORKTREE_ID,
parentTabId: 'terminal',
leafId: 'left',
ptyId: 'pty-left'
})
expect(result.tabsByWorktree[WORKTREE_ID]).toEqual([])
expect(result.terminalLayoutsByTabId.terminal).toBeUndefined()
})
})
@@ -0,0 +1,286 @@
import type {
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalTab
} from '../../shared/runtime-types'
import type {
TabGroupLayoutNode,
TerminalLayoutSnapshot,
TerminalPaneLayoutNode
} from '../../shared/types'
export type RetiredTerminalSurface = {
worktreeId: string
parentTabId: string
leafId: string
ptyId: string
incarnationId?: string
retiredAt?: number
}
function pruneTerminalPane(
node: TerminalPaneLayoutNode | null,
retiredLeafIds: ReadonlySet<string>
): TerminalPaneLayoutNode | null {
if (!node) {
return null
}
if (node.type === 'leaf') {
return retiredLeafIds.has(node.leafId) ? null : node
}
const first = pruneTerminalPane(node.first, retiredLeafIds)
const second = pruneTerminalPane(node.second, retiredLeafIds)
if (first && second) {
return { ...node, first, second }
}
return first ?? second
}
function collectTerminalLeafIds(node: TerminalPaneLayoutNode | null): string[] {
if (!node) {
return []
}
return node.type === 'leaf'
? [node.leafId]
: [...collectTerminalLeafIds(node.first), ...collectTerminalLeafIds(node.second)]
}
function omitLeafRecords<T>(
values: Record<string, T> | undefined,
retiredLeafIds: ReadonlySet<string>
): Record<string, T> | undefined {
if (!values) {
return undefined
}
const retained = Object.fromEntries(
Object.entries(values).filter(([leafId]) => !retiredLeafIds.has(leafId))
)
return Object.keys(retained).length > 0 ? retained : undefined
}
export function retireLeavesFromTerminalLayout(
layout: TerminalLayoutSnapshot,
retiredLeafIds: ReadonlySet<string>
): TerminalLayoutSnapshot | null {
const root = pruneTerminalPane(layout.root, retiredLeafIds)
if (!root) {
return null
}
const retainedLeafIds = collectTerminalLeafIds(root)
const retainedLeafIdSet = new Set(retainedLeafIds)
const activeLeafId =
layout.activeLeafId && retainedLeafIdSet.has(layout.activeLeafId)
? layout.activeLeafId
: retainedLeafIds[0]!
return {
...layout,
root,
activeLeafId,
expandedLeafId:
layout.expandedLeafId && retainedLeafIdSet.has(layout.expandedLeafId)
? layout.expandedLeafId
: null,
ptyIdsByLeafId: omitLeafRecords(layout.ptyIdsByLeafId, retiredLeafIds),
buffersByLeafId: omitLeafRecords(layout.buffersByLeafId, retiredLeafIds),
scrollbackRefsByLeafId: omitLeafRecords(layout.scrollbackRefsByLeafId, retiredLeafIds),
titlesByLeafId: omitLeafRecords(layout.titlesByLeafId, retiredLeafIds)
}
}
export function pruneTabGroupLayoutAfterRetirement(
layout: TabGroupLayoutNode | undefined,
retainedGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | undefined {
if (!layout) {
return undefined
}
if (layout.type === 'leaf') {
return retainedGroupIds.has(layout.groupId) ? layout : undefined
}
const first = pruneTabGroupLayoutAfterRetirement(layout.first, retainedGroupIds)
const second = pruneTabGroupLayoutAfterRetirement(layout.second, retainedGroupIds)
if (first && second) {
return { ...layout, first, second }
}
return first ?? second
}
function chooseGroupActiveTab(
group: RuntimeMobileSessionTabGroup,
retainedTabIds: ReadonlySet<string>
): string | null {
if (group.activeTabId && retainedTabIds.has(group.activeTabId)) {
return group.activeTabId
}
const recent = (group.recentTabIds ?? []).toReversed().find((tabId) => retainedTabIds.has(tabId))
return recent ?? group.tabOrder.find((tabId) => retainedTabIds.has(tabId)) ?? null
}
export function repairMobileSessionTabGroupsAfterRetirement(
groups: readonly RuntimeMobileSessionTabGroup[] | undefined,
validTopLevelIds: ReadonlySet<string>
): RuntimeMobileSessionTabGroup[] | undefined {
if (!groups) {
return undefined
}
const repaired = groups.flatMap((group) => {
const tabOrder = group.tabOrder.filter((tabId) => validTopLevelIds.has(tabId))
if (tabOrder.length === 0) {
return []
}
const retained = new Set(tabOrder)
const recentTabIds = group.recentTabIds?.filter((tabId) => retained.has(tabId))
return [
{
...group,
tabOrder,
activeTabId: chooseGroupActiveTab(group, retained),
...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {})
}
]
})
return repaired.length > 0 ? repaired : undefined
}
function topLevelTabId(tab: RuntimeMobileSessionSnapshotTab): string {
return tab.type === 'terminal' ? tab.parentTabId : tab.id
}
function chooseActiveSurface(
tabs: readonly RuntimeMobileSessionSnapshotTab[],
previousActiveId: string | null,
groups: readonly RuntimeMobileSessionTabGroup[] | undefined,
previousActiveGroupId: string | null
): RuntimeMobileSessionSnapshotTab | null {
const previous = previousActiveId ? tabs.find((tab) => tab.id === previousActiveId) : undefined
if (previous) {
return previous
}
const activeGroup =
groups?.find((group) => group.id === previousActiveGroupId) ?? groups?.[0] ?? null
const activeTopLevelId = activeGroup?.activeTabId
return (
(activeTopLevelId
? (tabs.find((tab) => topLevelTabId(tab) === activeTopLevelId && tab.isActive) ??
tabs.find((tab) => topLevelTabId(tab) === activeTopLevelId))
: undefined) ??
tabs.find((tab) => tab.isActive) ??
tabs[0] ??
null
)
}
function terminalMatchesRetirement(
tab: RuntimeMobileSessionTerminalTab,
ptyId: string,
exactSurfaceKeys: ReadonlySet<string>,
exactOnly: boolean
): boolean {
const surfaceKey = `${tab.parentTabId}\0${tab.leafId}`
if (exactSurfaceKeys.has(surfaceKey)) {
const leafPtyId = tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId]
return (!tab.ptyId || tab.ptyId === ptyId) && (!leafPtyId || leafPtyId === ptyId)
}
if (exactOnly) {
return false
}
return tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId
}
export function retireTerminalSurfacesFromSnapshot(args: {
snapshot: RuntimeMobileSessionTabsSnapshot
ptyId: string
exactSurfaces?: readonly Pick<RetiredTerminalSurface, 'parentTabId' | 'leafId'>[]
exactOnly?: boolean
}): { snapshot: RuntimeMobileSessionTabsSnapshot; retired: RetiredTerminalSurface[] } | null {
const exactSurfaceKeys = new Set(
(args.exactSurfaces ?? []).map((surface) => `${surface.parentTabId}\0${surface.leafId}`)
)
const retiredTabs = args.snapshot.tabs.filter(
(tab): tab is RuntimeMobileSessionTerminalTab =>
tab.type === 'terminal' &&
terminalMatchesRetirement(tab, args.ptyId, exactSurfaceKeys, args.exactOnly === true)
)
if (retiredTabs.length === 0) {
return null
}
const retiredLeafIdsByParent = new Map<string, Set<string>>()
for (const tab of retiredTabs) {
const leafIds = retiredLeafIdsByParent.get(tab.parentTabId) ?? new Set<string>()
leafIds.add(tab.leafId)
retiredLeafIdsByParent.set(tab.parentTabId, leafIds)
}
const retiredIds = new Set(retiredTabs.map((tab) => tab.id))
let tabs = args.snapshot.tabs.filter((tab) => !retiredIds.has(tab.id))
tabs = tabs.map((tab) => {
if (tab.type !== 'terminal') {
return tab
}
const retiredLeafIds = retiredLeafIdsByParent.get(tab.parentTabId)
if (!retiredLeafIds) {
return tab
}
const sourceLayout =
tab.parentLayout ??
retiredTabs.find((retired) => retired.parentTabId === tab.parentTabId)?.parentLayout
const parentLayout = sourceLayout
? retireLeavesFromTerminalLayout(sourceLayout, retiredLeafIds)
: undefined
return {
...tab,
...(parentLayout ? { parentLayout } : {}),
isActive:
tab.isActive ||
retiredTabs.some((retired) => retired.parentTabId === tab.parentTabId && retired.isActive)
}
})
const validTopLevelIds = new Set(tabs.map(topLevelTabId))
const tabGroups = repairMobileSessionTabGroupsAfterRetirement(
args.snapshot.tabGroups,
validTopLevelIds
)
const active = chooseActiveSurface(
tabs,
args.snapshot.activeTabId,
tabGroups,
args.snapshot.activeGroupId
)
tabs = tabs.map((tab) => ({ ...tab, isActive: tab.id === active?.id }))
const activeTopLevelId = active ? topLevelTabId(active) : null
const activeGroupId =
(activeTopLevelId
? tabGroups?.find((group) => group.tabOrder.includes(activeTopLevelId))?.id
: undefined) ??
tabGroups?.[0]?.id ??
null
const retainedGroupIds = new Set(tabGroups?.map((group) => group.id) ?? [])
return {
snapshot: {
...args.snapshot,
snapshotVersion: args.snapshot.snapshotVersion + 1,
activeGroupId,
activeTabId: active?.id ?? null,
activeTabType: active?.type ?? null,
...(tabGroups ? { tabGroups } : { tabGroups: undefined }),
...(args.snapshot.tabGroupLayout
? {
tabGroupLayout: pruneTabGroupLayoutAfterRetirement(
args.snapshot.tabGroupLayout,
retainedGroupIds
)
}
: {}),
tabs
},
retired: retiredTabs.map((tab) => ({
worktreeId: args.snapshot.worktree,
parentTabId: tab.parentTabId,
leafId: tab.leafId,
ptyId: args.ptyId
}))
}
}
@@ -0,0 +1,292 @@
import { describe, expect, it, vi } from 'vitest'
import type {
RuntimeCreateAgentSessionRequest,
RuntimeCreateAgentSessionResult
} from '../../shared/agent-session-host-authority'
import { OrcaRuntimeService } from './orca-runtime'
function operationId(now = Date.now()): string {
return `${now}-0123456789abcdef0123456789abcdef`
}
function request(
clientOperationId: string,
overrides: Partial<RuntimeCreateAgentSessionRequest> = {}
): RuntimeCreateAgentSessionRequest {
return {
clientOperationId,
worktree: 'id:worktree-1',
agent: 'codex',
prompt: 'do the thing',
presentation: 'background',
...overrides
}
}
function terminal() {
return {
handle: 'term_operation',
tabId: '11111111-1111-4111-8111-111111111111',
paneKey: '11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222',
ptyId: 'pty-operation',
worktreeId: 'worktree-1',
title: null,
surface: 'background' as const
}
}
function createRuntime(provider?: {
supportsAgentSessionClaims?: () => boolean
supportsAgentSessionCreateOperations?: () => boolean
}) {
const runtime = new OrcaRuntimeService(
{
getSettings: () => ({
disabledTuiAgents: [],
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {}
})
} as never,
undefined,
provider ? { getLocalProvider: () => provider as never } : undefined
)
const internal = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: ReturnType<typeof vi.fn>
markLocalWorkspaceTrustedForAgent: ReturnType<typeof vi.fn>
markRemoteWorkspaceTrustedForAgent: ReturnType<typeof vi.fn>
}
internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({
id: 'worktree-1',
path: '/tmp/worktree-1',
connectionId: null
}))
internal.markLocalWorkspaceTrustedForAgent = vi.fn()
internal.markRemoteWorkspaceTrustedForAgent = vi.fn()
return runtime
}
describe('agent-session create operation ledger', () => {
it('selects legacy before trust, spawn, or ledger state for an old daemon', async () => {
const provider = {
supportsAgentSessionClaims: vi.fn(() => false),
supportsAgentSessionCreateOperations: vi.fn(() => false)
}
const runtime = createRuntime(provider)
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
const internal = runtime as unknown as {
markLocalWorkspaceTrustedForAgent: ReturnType<typeof vi.fn>
}
const id = operationId()
await expect(runtime.createAgentSession(request(id))).rejects.toThrow(
'agent_session_legacy_required'
)
await expect(
runtime.ensureAgentSession({
kind: 'explicit',
worktree: 'id:worktree-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'provider-session-1' }
})
).rejects.toThrow('agent_session_legacy_required')
expect(createTerminal).not.toHaveBeenCalled()
expect(internal.markLocalWorkspaceTrustedForAgent).not.toHaveBeenCalled()
provider.supportsAgentSessionCreateOperations.mockReturnValue(true)
await expect(runtime.createAgentSession(request(id))).resolves.toMatchObject({
disposition: 'created'
})
provider.supportsAgentSessionCreateOperations.mockReturnValue(false)
await expect(runtime.createAgentSession(request(id))).resolves.toMatchObject({
disposition: 'replayed'
})
expect(createTerminal).toHaveBeenCalledOnce()
})
it('requests exact client legacy fallback before nested SSH side effects', async () => {
const runtime = createRuntime()
const internal = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: ReturnType<typeof vi.fn>
}
internal.resolveTerminalWorkspaceLaunchScope.mockResolvedValue({
id: 'worktree-1',
path: '/remote/worktree-1',
connectionId: 'ssh-1'
})
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
await expect(
runtime.ensureAgentSession({
kind: 'explicit',
worktree: 'id:worktree-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'provider-session-1' }
})
).rejects.toThrow('agent_session_legacy_required')
expect(createTerminal).not.toHaveBeenCalled()
})
it('selects nested SSH legacy fallback before reading a Pi transcript path locally', async () => {
const runtime = createRuntime()
const internal = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: ReturnType<typeof vi.fn>
markRemoteWorkspaceTrustedForAgent: ReturnType<typeof vi.fn>
}
internal.resolveTerminalWorkspaceLaunchScope.mockResolvedValue({
id: 'worktree-1',
path: '/remote/worktree-1',
connectionId: 'ssh-1'
})
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
await expect(
runtime.ensureAgentSession({
kind: 'explicit',
worktree: 'id:worktree-1',
agent: 'pi',
providerSession: {
key: 'session_id',
id: 'provider-session-1',
transcriptPath: '/remote-only/pi/session.jsonl'
}
})
).rejects.toThrow('agent_session_legacy_required')
expect(createTerminal).not.toHaveBeenCalled()
expect(internal.markRemoteWorkspaceTrustedForAgent).not.toHaveBeenCalled()
})
it('replays the same completed operation without spawning again', async () => {
const runtime = createRuntime()
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
const id = operationId()
await expect(
runtime.createAgentSession(request(id), { clientId: 'device-a' })
).resolves.toMatchObject({ disposition: 'created' })
await expect(
runtime.createAgentSession(request(id), { clientId: 'device-a' })
).resolves.toMatchObject({ disposition: 'replayed' })
expect(createTerminal).toHaveBeenCalledOnce()
})
it('joins concurrent retries and conflicts on a changed fingerprint', async () => {
const runtime = createRuntime()
let finish!: (result: ReturnType<typeof terminal>) => void
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const id = operationId()
const first = runtime.createAgentSession(request(id), { clientId: 'device-a' })
const joined = runtime.createAgentSession(request(id), { clientId: 'device-a' })
await expect(
runtime.createAgentSession(request(id, { prompt: 'changed' }), { clientId: 'device-a' })
).rejects.toThrow('agent_session_operation_conflict')
await expect(
runtime.createAgentSession(request(id, { agentArgs: '--profile changed' }), {
clientId: 'device-a'
})
).rejects.toThrow('agent_session_operation_conflict')
finish(terminal())
await expect(first).resolves.toMatchObject({ disposition: 'created' })
await expect(joined).resolves.toMatchObject({ disposition: 'replayed' })
expect(createTerminal).toHaveBeenCalledOnce()
})
it('isolates operation ids by authenticated caller', async () => {
const runtime = createRuntime()
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
const id = operationId()
await runtime.createAgentSession(request(id), { clientId: 'device-a' })
await runtime.createAgentSession(request(id), { clientId: 'device-b' })
expect(createTerminal).toHaveBeenCalledTimes(2)
})
it('rejects an expired unseen operation before terminal creation', async () => {
const runtime = createRuntime()
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
const expired = operationId(Date.now() - 25 * 60 * 60 * 1_000)
await expect(
runtime.createAgentSession(request(expired), { clientId: 'device-a' })
).rejects.toThrow('agent_session_operation_expired')
expect(createTerminal).not.toHaveBeenCalled()
})
it('releases a failed pre-spawn operation for a safe retry', async () => {
const runtime = createRuntime()
const createTerminal = vi
.spyOn(runtime, 'createTerminal')
.mockRejectedValueOnce(new Error('pre-spawn failure'))
.mockResolvedValueOnce(terminal())
const id = operationId()
await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow(
'pre-spawn failure'
)
await expect(
runtime.createAgentSession(request(id), { clientId: 'device-a' })
).resolves.toMatchObject({ disposition: 'created' })
expect(createTerminal).toHaveBeenCalledTimes(2)
expect(createTerminal.mock.calls[0]?.[1]).toMatchObject({
tabId: createTerminal.mock.calls[1]?.[1]?.tabId,
leafId: createTerminal.mock.calls[1]?.[1]?.leafId,
preAllocatedHandle: createTerminal.mock.calls[1]?.[1]?.preAllocatedHandle,
agentSessionCreateOperationId:
createTerminal.mock.calls[1]?.[1]?.agentSessionCreateOperationId
})
expect(createTerminal.mock.calls[0]?.[1]?.agentSessionCreateOperationId).toMatch(
/^[A-Za-z0-9_-]{43}$/
)
})
it.each([
['controller admission fails', 'agent_session_exited_during_start'],
['publication fails', 'post-spawn publication failure']
])('retains a replay fence when %s after physical spawn commit', async (_case, message) => {
const runtime = createRuntime()
const failure = new Error(message)
const createTerminal = vi
.spyOn(runtime, 'createTerminal')
.mockImplementation(async (_worktree, opts) => {
opts?.onPtySpawnCommitted?.()
throw failure
})
const id = operationId()
await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow(
failure.message
)
await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow(
failure.message
)
expect(createTerminal).toHaveBeenCalledOnce()
})
it('retains a replay fence when the provider reports an unknown spawn outcome', async () => {
const runtime = createRuntime()
const failure = Object.assign(new Error('cleanup could not prove exit'), {
agentSessionOperationOutcome: 'unknown' as const
})
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockRejectedValue(failure)
const id = operationId()
const attempts: Promise<RuntimeCreateAgentSessionResult>[] = [
runtime.createAgentSession(request(id), { clientId: 'device-a' }),
runtime.createAgentSession(request(id), { clientId: 'device-a' })
]
await expect(Promise.all(attempts)).rejects.toThrow(failure.message)
await expect(runtime.createAgentSession(request(id), { clientId: 'device-a' })).rejects.toThrow(
failure.message
)
expect(createTerminal).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,666 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot
} from '../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../shared/types'
import { sanitizeWorkspaceSessionTerminalRetirements } from './mobile-session-terminal-persistence-retirement'
import { OrcaRuntimeService } from './orca-runtime'
const WORKTREE_ID = 'repo::/worktree'
const REPO_ID = 'repo'
function makeSplitSnapshot(): RuntimeMobileSessionTabsSnapshot {
const parentLayout = {
root: {
type: 'split' as const,
direction: 'vertical' as const,
first: { type: 'leaf' as const, leafId: 'left' },
second: { type: 'leaf' as const, leafId: 'right' }
},
activeLeafId: 'left',
expandedLeafId: 'left',
ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' }
}
return {
worktree: WORKTREE_ID,
publicationEpoch: 'renderer',
snapshotVersion: 1,
activeGroupId: 'group',
activeTabId: 'tab::left',
activeTabType: 'terminal',
tabGroups: [{ id: 'group', activeTabId: 'tab', tabOrder: ['tab'] }],
tabs: [
{
type: 'terminal',
id: 'tab::left',
parentTabId: 'tab',
leafId: 'left',
ptyId: 'pty-left',
title: 'Left',
parentLayout,
isActive: true
},
{
type: 'terminal',
id: 'tab::right',
parentTabId: 'tab',
leafId: 'right',
ptyId: 'pty-right',
title: 'Right',
parentLayout,
isActive: false
}
]
}
}
function syncSplit(runtime: OrcaRuntimeService, snapshot = makeSplitSnapshot()): void {
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
title: 'Terminal',
activeLeafId: 'left',
layout:
snapshot.tabs[0]?.type === 'terminal'
? (snapshot.tabs[0].parentLayout?.root ?? null)
: null
}
],
leaves: [
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
leafId: 'left',
paneRuntimeId: 1,
ptyId: 'pty-left'
},
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
leafId: 'right',
paneRuntimeId: 2,
ptyId: 'pty-right'
}
],
mobileSessionTabs: [snapshot]
})
}
function makePersistedSplitSession(): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'tab',
ptyId: 'pty-left',
worktreeId: WORKTREE_ID,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
tab: {
root: {
type: 'split' as const,
direction: 'vertical' as const,
first: { type: 'leaf' as const, leafId: 'left' },
second: { type: 'leaf' as const, leafId: 'right' }
},
activeLeafId: 'left',
expandedLeafId: null,
ptyIdsByLeafId: { left: 'pty-left', right: 'pty-right' }
}
}
}
}
describe('OrcaRuntimeService terminal surface retirement', () => {
it('releases each early-exit fence after its matching registration is rejected', () => {
const runtime = new OrcaRuntimeService()
const internals = runtime as unknown as {
earlyExitedPtyIncarnations: Map<string, string | null>
}
for (let index = 0; index < 1_000; index += 1) {
const ptyId = `pty-early-${index}`
const incarnationId = `incarnation-${index}`
runtime.beginPtyRegistration(ptyId, incarnationId)
runtime.onPtyExit(ptyId, 0, incarnationId)
expect(() => runtime.assertPtyRegistrationAllowed(ptyId, incarnationId)).toThrow(
'agent_session_exited_during_start'
)
runtime.releaseRejectedPtyRegistrationFence(ptyId, incarnationId)
}
expect(internals.earlyExitedPtyIncarnations.size).toBe(0)
})
it('does not retain fences for completed surface-less lifecycles', () => {
const runtime = new OrcaRuntimeService()
const internals = runtime as unknown as {
earlyExitedPtyIncarnations: Map<string, string | null>
pendingPtyRegistrationIncarnations: Map<string, string | null>
}
for (let index = 0; index < 1_000; index += 1) {
runtime.onPtySpawned(`pty-headless-${index}`, `incarnation-${index}`, {
awaitsRegistration: false
})
runtime.onPtyExit(`pty-headless-${index}`, 0, `incarnation-${index}`)
}
expect(internals.earlyExitedPtyIncarnations.size).toBe(0)
expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0)
})
it('fences an early-exited replacement even when its pane already exists', () => {
const runtime = new OrcaRuntimeService()
runtime.attachWindow(1)
syncSplit(runtime)
runtime.registerPty('pty-left', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'left',
incarnationId: 'incarnation-old'
})
runtime.onPtySpawned('pty-left', 'incarnation-replacement')
runtime.onPtyExit('pty-left', 0, 'incarnation-replacement')
expect(() =>
runtime.assertPtyRegistrationAllowed('pty-left', 'incarnation-replacement')
).toThrow('agent_session_exited_during_start')
runtime.releaseRejectedPtyRegistrationFence('pty-left', 'incarnation-replacement')
const internals = runtime as unknown as {
earlyExitedPtyIncarnations: Map<string, string | null>
pendingPtyRegistrationIncarnations: Map<string, string | null>
}
expect(internals.earlyExitedPtyIncarnations.size).toBe(0)
expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0)
})
it('retires the exact split leaf and rejects a stale renderer resurrection', async () => {
const runtime = new OrcaRuntimeService()
runtime.attachWindow(1)
const staleSnapshot = makeSplitSnapshot()
syncSplit(runtime, staleSnapshot)
runtime.onPtyExit('pty-left', 0)
expect(await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).toMatchObject({
activeTabId: 'tab::right',
tabs: [
{
id: 'tab::right',
status: 'ready',
terminal: expect.stringMatching(/^term_/),
isActive: true,
parentLayout: {
root: { type: 'leaf', leafId: 'right' },
activeLeafId: 'right',
expandedLeafId: null,
ptyIdsByLeafId: { right: 'pty-right' }
}
}
]
})
syncSplit(runtime, { ...staleSnapshot, snapshotVersion: 2 })
const afterStaleFrame = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(afterStaleFrame.tabs.map((tab) => tab.id)).toEqual(['tab::right'])
})
it('rejects one stale shared-PTY surface without removing its live sibling', () => {
const session = makePersistedSplitSession()
session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-shared'
session.terminalLayoutsByTabId.tab = {
root: { type: 'leaf', leafId: 'right' },
activeLeafId: 'right',
expandedLeafId: null,
ptyIdsByLeafId: { right: 'pty-shared' }
}
session.terminalPtyIncarnationsByPaneKey = { 'tab:right': 'incarnation-current' }
session.terminalTopologyRevisionByRepoId = { [REPO_ID]: 1 }
const runtime = new OrcaRuntimeService({ getWorkspaceSession: () => session } as never)
runtime.attachWindow(1)
runtime.registerPty('pty-shared', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'right',
incarnationId: 'incarnation-current'
})
const snapshot = makeSplitSnapshot()
const incoming = {
...snapshot,
tabs: snapshot.tabs.map((tab) =>
tab.type === 'terminal'
? {
...tab,
ptyId: 'pty-shared',
parentLayout: tab.parentLayout
? {
...tab.parentLayout,
ptyIdsByLeafId: { left: 'pty-shared', right: 'pty-shared' }
}
: undefined
}
: tab
)
}
type IncomingTerminalTab = Extract<(typeof incoming.tabs)[number], { type: 'terminal' }>
const rightTab = incoming.tabs.find(
(tab): tab is IncomingTerminalTab => tab.type === 'terminal' && tab.leafId === 'right'
)!
const hostSnapshot: RuntimeMobileSessionTabsSnapshot = {
...incoming,
activeTabId: rightTab.id,
tabs: [
{
...rightTab,
parentLayout: {
root: { type: 'leaf', leafId: 'right' },
activeLeafId: 'right',
expandedLeafId: null,
ptyIdsByLeafId: { right: 'pty-shared' }
}
}
]
}
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
title: 'Terminal',
activeLeafId: 'right',
layout: { type: 'leaf', leafId: 'right' }
}
],
leaves: [
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
leafId: 'right',
paneRuntimeId: 2,
ptyId: 'pty-shared'
}
],
mobileSessionTabs: [hostSnapshot]
})
;(
runtime as unknown as {
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
}
).mobileSessionTabsByWorktree.set(WORKTREE_ID, hostSnapshot)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
title: 'Terminal',
activeLeafId: 'right',
layout: incoming.tabs[0]?.type === 'terminal' ? incoming.tabs[0].parentLayout!.root : null
}
],
leaves: [
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
leafId: 'left',
paneRuntimeId: 1,
ptyId: 'pty-shared'
},
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
leafId: 'right',
paneRuntimeId: 2,
ptyId: 'pty-shared'
}
],
mobileSessionTabs: [incoming]
})
const internalSnapshot = (
runtime as unknown as {
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
}
).mobileSessionTabsByWorktree.get(WORKTREE_ID)
expect(internalSnapshot?.tabs).toEqual([
expect.objectContaining({ id: 'tab::right', ptyId: 'pty-shared' })
])
})
it('honors a legacy persisted tombstone before its first migration write', async () => {
const session = makePersistedSplitSession()
session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-right'
session.terminalLayoutsByTabId.tab = {
root: { type: 'leaf', leafId: 'right' },
activeLeafId: 'right',
expandedLeafId: null,
ptyIdsByLeafId: { right: 'pty-right' }
}
Object.assign(session, {
terminalSurfaceTombstonesByPaneKey: {
'tab:left': {
worktreeId: WORKTREE_ID,
parentTabId: 'tab',
leafId: 'left',
ptyId: 'pty-left',
incarnationId: 'incarnation-left',
retiredAt: 42
}
}
})
const runtime = new OrcaRuntimeService({ getWorkspaceSession: () => session } as never)
runtime.attachWindow(1)
runtime.registerPty('pty-right', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'right',
incarnationId: 'incarnation-right'
})
syncSplit(runtime)
const tabs = (await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs
expect(tabs).toHaveLength(1)
expect(tabs[0]).toMatchObject({ type: 'terminal', ptyId: 'pty-right' })
})
it('publishes the host-rebased layout after a stale client pane update', async () => {
let session = makePersistedSplitSession()
session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-right'
session.terminalLayoutsByTabId.tab = {
root: { type: 'leaf', leafId: 'right' },
activeLeafId: 'right',
expandedLeafId: null,
ptyIdsByLeafId: { right: 'pty-right' }
}
Object.assign(session, {
terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 }
})
const runtime = new OrcaRuntimeService({
getWorkspaceSession: () => session,
setWorkspaceSession: (incoming: WorkspaceSessionState) => {
session = sanitizeWorkspaceSessionTerminalRetirements(incoming, session)
}
} as never)
await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
await runtime.updateMobileSessionPaneLayout(`id:${WORKTREE_ID}`, {
tabId: 'tab',
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'left' },
second: { type: 'leaf', leafId: 'right' }
},
expandedLeafId: null,
titlesByLeafId: { right: 'Survivor' }
})
const tabs = (await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs
expect(tabs).toHaveLength(1)
expect(tabs[0]).toMatchObject({
type: 'terminal',
ptyId: 'pty-right',
parentLayout: {
root: { type: 'leaf', leafId: 'right' }
}
})
})
it('retires a permanently exited surface despite a stale sleeping record', async () => {
const session = {
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey: { 'tab:left': {} as never }
}
const runtime = new OrcaRuntimeService({
getWorkspaceSession: () => session,
setWorkspaceSession: vi.fn(),
flushOrThrow: vi.fn()
} as never)
runtime.attachWindow(1)
syncSplit(runtime)
runtime.onPtyExit('pty-left', 0)
const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
expect(result.tabs.find((tab) => tab.id === 'tab::left')).toBeUndefined()
expect(result.tabs.find((tab) => tab.id === 'tab::right')).toMatchObject({
status: 'ready'
})
})
it('ignores a delayed exit from an older incarnation of a reused PTY id', async () => {
const setWorkspaceSession = vi.fn()
const runtime = new OrcaRuntimeService({
getWorkspaceSession: () => makePersistedSplitSession(),
setWorkspaceSession
} as never)
runtime.attachWindow(1)
syncSplit(runtime)
runtime.registerPty('pty-left', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'left',
incarnationId: 'incarnation-a'
})
runtime.registerPty('pty-left', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'left',
incarnationId: 'incarnation-b'
})
runtime.onPtyExit('pty-left', 0, 'incarnation-a')
expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([
expect.objectContaining({ id: 'tab::left', status: 'ready' }),
expect.objectContaining({ id: 'tab::right', status: 'ready' })
])
expect(setWorkspaceSession).not.toHaveBeenCalled()
})
it('retires a durable surface after reconnect proves a newer incarnation', async () => {
const session = makePersistedSplitSession()
const setWorkspaceSession = vi.fn()
const runtime = new OrcaRuntimeService({
getWorkspaceSession: () => session,
setWorkspaceSession,
flushOrThrow: vi.fn()
} as never)
runtime.attachWindow(1)
syncSplit(runtime)
runtime.registerPty('pty-left', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'left',
incarnationId: 'incarnation-before-reconnect'
})
runtime.acceptPtyIncarnationForExit('pty-left', 'incarnation-after-reconnect')
runtime.onPtyExit('pty-left', 0, 'incarnation-after-reconnect')
expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([
expect.objectContaining({ id: 'tab::right', status: 'ready' })
])
expect(setWorkspaceSession).toHaveBeenCalledWith(
expect.objectContaining({
terminalLayoutsByTabId: {
tab: expect.objectContaining({
root: { type: 'leaf', leafId: 'right' },
ptyIdsByLeafId: { right: 'pty-right' }
})
}
})
)
})
it('publishes only same-repo retirements individually accepted by persistence', async () => {
let session = makePersistedSplitSession()
session.terminalLayoutsByTabId.tab.ptyIdsByLeafId = {
left: 'pty-shared',
right: 'pty-shared'
}
session.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = 'pty-shared'
session.terminalPtyIncarnationsByPaneKey = {
'tab:left': 'incarnation-exiting',
'tab:right': 'incarnation-newer'
}
const setWorkspaceSession = vi.fn((next: WorkspaceSessionState) => {
session = next
})
const runtime = new OrcaRuntimeService({
getWorkspaceSession: () => session,
setWorkspaceSession,
flushOrThrow: vi.fn()
} as never)
runtime.attachWindow(1)
const snapshot = makeSplitSnapshot()
const sharedSnapshot: RuntimeMobileSessionTabsSnapshot = {
...snapshot,
tabs: snapshot.tabs.map((tab) =>
tab.type === 'terminal'
? {
...tab,
ptyId: 'pty-shared',
parentLayout: tab.parentLayout
? {
...tab.parentLayout,
ptyIdsByLeafId: { left: 'pty-shared', right: 'pty-shared' }
}
: undefined
}
: tab
)
}
syncSplit(runtime, sharedSnapshot)
runtime.registerPty('pty-shared', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'left',
incarnationId: 'incarnation-exiting'
})
const published: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event))
runtime.onPtyExit('pty-shared', 0, 'incarnation-exiting')
expect(session.terminalLayoutsByTabId.tab).toMatchObject({
root: { type: 'leaf', leafId: 'right' },
ptyIdsByLeafId: { right: 'pty-shared' }
})
expect(session.terminalPtyIncarnationsByPaneKey).toEqual({
'tab:right': 'incarnation-newer'
})
expect(published.at(-1)?.tabs).toEqual([
expect.objectContaining({
ptyId: 'pty-shared',
parentLayout: expect.objectContaining({ root: { type: 'leaf', leafId: 'right' } })
})
])
expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([
expect.objectContaining({
ptyId: 'pty-shared',
parentLayout: expect.objectContaining({ root: { type: 'leaf', leafId: 'right' } })
})
])
expect(setWorkspaceSession).toHaveBeenCalledOnce()
unsubscribe()
})
it('de-persists an exact surface even when there is no mobile snapshot', () => {
const session = makePersistedSplitSession()
const setWorkspaceSession = vi.fn()
const flushOrThrow = vi.fn()
const runtime = new OrcaRuntimeService({
getWorkspaceSession: () => session,
setWorkspaceSession,
flushOrThrow
} as never)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
title: 'Terminal',
activeLeafId: 'left',
layout: { type: 'leaf', leafId: 'left' }
}
],
leaves: [
{
tabId: 'tab',
worktreeId: WORKTREE_ID,
leafId: 'left',
paneRuntimeId: 1,
ptyId: 'pty-left'
}
]
})
runtime.registerPty('pty-left', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'left',
incarnationId: 'incarnation-a'
})
runtime.onPtyExit('pty-left', 0, 'incarnation-a')
expect(setWorkspaceSession).toHaveBeenCalledWith(
expect.objectContaining({
terminalLayoutsByTabId: {
tab: expect.objectContaining({
root: { type: 'leaf', leafId: 'right' },
ptyIdsByLeafId: { right: 'pty-right' }
})
},
terminalSurfaceTombstonesByPaneKey: {},
terminalTopologyRevisionByRepoId: { [REPO_ID]: 1 }
})
)
expect(flushOrThrow).toHaveBeenCalledOnce()
})
it('does not publish absence when the durable retirement flush fails', async () => {
const session = makePersistedSplitSession()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const runtime = new OrcaRuntimeService({
getWorkspaceSession: () => session,
setWorkspaceSession: vi.fn(),
flushOrThrow: vi.fn(() => {
throw new Error('disk unavailable')
})
} as never)
runtime.attachWindow(1)
syncSplit(runtime)
runtime.registerPty('pty-left', WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'left',
incarnationId: 'incarnation-a'
})
const events: unknown[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((event) => events.push(event))
runtime.onPtyExit('pty-left', 0, 'incarnation-a')
expect((await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)).tabs).toEqual([
expect.objectContaining({ id: 'tab::left' }),
expect.objectContaining({ id: 'tab::right' })
])
expect(events).toEqual([])
expect(errorSpy).toHaveBeenCalledWith(
'[runtime] failed to persist terminal retirement:',
expect.any(Error)
)
unsubscribe()
errorSpy.mockRestore()
})
})
+210 -18
View File
@@ -93,6 +93,10 @@ import {
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { makePaneKey } from '../../shared/stable-pane-id'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
import type {
AgentSessionExecutionClaim,
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest } from './rpc/core'
@@ -10537,6 +10541,153 @@ describe('OrcaRuntimeService', () => {
)
})
it('does not register or publish a PTY incarnation that exited before spawn resolved', async () => {
const runtime = new OrcaRuntimeService(store)
const tabId = '11111111-1111-4111-8111-111111111111'
const leafId = '22222222-2222-4222-8222-222222222222'
runtime.setPtyController({
spawn: vi.fn(async () => {
runtime.beginPtyRegistration('pty-exited-during-start', 'incarnation-exited-during-start')
runtime.onPtyExit('pty-exited-during-start', 0, 'incarnation-exited-during-start')
return {
id: 'pty-exited-during-start',
incarnationId: 'incarnation-exited-during-start'
}
}),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await expect(
runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'codex',
presentation: 'background',
tabId,
leafId,
persistHostSessionBinding: true
})
).rejects.toThrow('agent_session_exited_during_start')
await expect(runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({
terminals: []
})
await expect(runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({
tabs: []
})
const internals = runtime as unknown as {
handleByPtyId: Map<string, string>
ptysById: Map<string, unknown>
}
expect(internals.handleByPtyId.has('pty-exited-during-start')).toBe(false)
expect(internals.ptysById.has('pty-exited-during-start')).toBe(false)
})
it('adopts the execution owner canonical surface for repeated structured resumes', async () => {
let canonicalOwner:
| {
claim: AgentSessionExecutionClaim
generation: string
phase: 'live'
ptyId: string
surface: AgentSessionSurfaceBinding
}
| undefined
const spawn = vi.fn(async (options) => {
const ensure = options.agentSessionEnsure
expect(ensure).toBeDefined()
canonicalOwner ??= {
claim: ensure!.claim,
generation: 'generation-1',
phase: 'live',
ptyId: 'pty-claimed',
surface: ensure!.surface
}
return {
id: 'pty-claimed',
agentSessionEnsure: {
disposition: spawn.mock.calls.length === 1 ? ('created' as const) : ('adopted' as const),
owner: canonicalOwner
}
}
})
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const request = {
kind: 'explicit' as const,
worktree: `id:${TEST_WORKTREE_ID}`,
agent: 'codex' as const,
providerSession: { key: 'session_id' as const, id: 'provider-session-1' }
}
const first = await runtime.ensureAgentSession(request)
const second = await runtime.ensureAgentSession(request)
expect(first.disposition).toBe('created')
expect(second.disposition).toBe('adopted')
expect(second.terminal).toMatchObject({
handle: first.terminal.handle,
tabId: first.terminal.tabId,
paneKey: first.terminal.paneKey
})
expect(spawn).toHaveBeenCalledTimes(2)
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
command: expect.stringContaining("'resume' 'provider-session-1'"),
agentSessionEnsure: expect.objectContaining({
claim: expect.objectContaining({ agent: 'codex' })
})
})
)
})
it('builds structured fresh drafts with supported launch preferences on the host', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-draft' })
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => ({
...store.getSettings(),
disabledTuiAgents: [],
agentCmdOverrides: { claude: 'host-claude' },
agentDefaultArgs: { claude: '--host-default' },
agentDefaultEnv: { claude: { HOST_PROFILE: 'true' } }
})
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await runtime.createAgentSession(
{
clientOperationId: `${Date.now()}-${'ab'.repeat(16)}`,
worktree: `id:${TEST_WORKTREE_ID}`,
agent: 'claude',
prompt: 'review before sending',
promptDelivery: 'draft',
agentArgs: '--permission-mode plan',
launchPreferences: { model: 'opus', effort: 'high' }
},
{ clientId: 'renderer-1', clientKind: 'runtime' }
)
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
command: expect.stringMatching(
/^host-claude '--model' 'opus'.*'--permission-mode' 'plan'.*--prefill 'review before sending'/
),
env: expect.objectContaining({ HOST_PROFILE: 'true' })
})
)
expect(spawn.mock.calls[0]?.[0]?.command).not.toContain('--host-default')
})
it('applies Settings agent defaults to bare agent command terminal creates', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtimeStore = {
@@ -17932,7 +18083,7 @@ describe('OrcaRuntimeService', () => {
)
})
it('does not publish exited saved PTY bindings as ready terminal streams', async () => {
it('retires exited saved PTY bindings instead of publishing a pending ghost', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
@@ -17966,14 +18117,12 @@ describe('OrcaRuntimeService', () => {
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(result.tabs).toEqual([
expect.objectContaining({
type: 'terminal',
id: 'tab-1::pane:1',
status: 'pending-handle',
terminal: null
})
])
expect(result).toMatchObject({
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
})
})
it('resolves mobile terminal surfaces by exact split leaf', async () => {
@@ -19779,7 +19928,7 @@ describe('OrcaRuntimeService', () => {
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => {
it('pushes PTY-backed mobile session retirement when a server PTY exits', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
@@ -19801,14 +19950,10 @@ describe('OrcaRuntimeService', () => {
expect(events).toEqual([
expect.objectContaining({
tabs: [
expect.objectContaining({
type: 'terminal',
parentTabId: 'laptop-tab',
status: 'pending-handle',
terminal: null
})
]
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
})
])
await expect(runtime.readTerminal(laptopTerminal.handle)).resolves.toMatchObject({
@@ -22259,6 +22404,53 @@ describe('OrcaRuntimeService', () => {
expect(listProcesses).toHaveBeenCalledTimes(1)
})
it('keeps a live persisted PTY whose pane binding has not reconnected yet', async () => {
const ptyId = 'persisted-pty'
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'host-tab',
ptyId,
worktreeId: TEST_WORKTREE_ID,
title: 'Persisted Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId })
}
})
)
const kill = vi.fn(() => true)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: () => true,
kill,
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: ptyId, cwd: TEST_WORKTREE_PATH, title: 'Live' }]
})
const result = await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
reason: 'cleanup'
})
expect(result).toEqual({
closed: true,
refused: true,
refusalReason: 'live-host-pty',
snapshotRepublished: true
})
expect(kill).not.toHaveBeenCalled()
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1)
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined()
})
it('keeps an explicit user close destructive while the PTY is live', async () => {
const { runtime, closeTerminalTab } = makeAdoptedLiveTabRuntime()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,233 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { parsePairingCode } from '../../shared/pairing'
import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection'
import type {
RuntimeEnsureAgentSessionResult,
RuntimeEnsureAgentSessionRequest
} from '../../shared/agent-session-host-authority'
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
import type { SubprocessHandle } from '../daemon/session'
import { TerminalHost } from '../daemon/terminal-host'
import { OrcaRuntimeService } from './orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
const TEST_TIMEOUT_MS = 15_000
const REQUEST_TIMEOUT_MS = 5_000
type ControlledSubprocess = SubprocessHandle & { exit: (code?: number) => void }
function createControlledSubprocess(): ControlledSubprocess {
let onExit: ((code: number) => void) | null = null
let exited = false
const exit = (code = 0): void => {
if (exited) {
return
}
exited = true
onExit?.(code)
}
return {
pid: 41_000,
getForegroundProcess: () => (exited ? null : 'claude'),
write: vi.fn(),
resize: vi.fn(),
kill: () => exit(0),
forceKill: () => exit(137),
signal: vi.fn(),
onData: vi.fn(),
onExit: (listener) => {
onExit = listener
},
dispose: vi.fn(),
exit
}
}
function requirePairing(server: OrcaRuntimeRpcServer, name: string) {
const offer = server.createPairingOffer({ name, scope: 'runtime' })
if (!offer.available) {
throw new Error('pairing unavailable')
}
const pairing = parsePairingCode(offer.pairingUrl)
if (!pairing) {
throw new Error('invalid pairing')
}
return pairing
}
describe('remote agent-session host authority integration', () => {
const cleanups: (() => void | Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).toReversed()) {
await cleanup()
}
})
it(
'deduplicates racing remote resumes, adopts retries, and retires exited surfaces',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-authority-repro-'))
cleanups.push(() => rmSync(userDataPath, { recursive: true, force: true }))
const subprocesses: ControlledSubprocess[] = []
const spawnSubprocess = vi.fn(() => {
const subprocess = createControlledSubprocess()
subprocesses.push(subprocess)
return subprocess
})
const host = new TerminalHost({ spawnSubprocess })
cleanups.push(() => host.dispose())
const store = {
getSettings: () => ({
workspaceDir: userDataPath,
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: '',
disabledTuiAgents: [],
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultEnv: {}
}),
getRepos: () => [],
getRepo: () => undefined,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
getProjects: () => []
}
const runtime = new OrcaRuntimeService(store as never)
let nextRequestedSession = 0
runtime.setPtyController({
spawn: async (options) => {
const requestedSessionId = `remote-repro-${++nextRequestedSession}`
let resolvedSessionId = requestedSessionId
const result = await host.createOrAttach({
sessionId: requestedSessionId,
cols: options.cols,
rows: options.rows,
cwd: options.cwd,
env: options.env,
command: options.command,
startupCommandDelivery: options.startupCommandDelivery,
launchAgent: options.launchAgent,
agentSessionEnsure: options.agentSessionEnsure,
streamClient: {
onData: (data) => runtime.onPtyData(resolvedSessionId, data, Date.now()),
onExit: (code) => runtime.onPtyExit(resolvedSessionId, code)
},
onSessionResolved: (sessionId) => {
resolvedSessionId = sessionId
}
})
return {
id: result.agentSessionEnsure?.owner.ptyId ?? resolvedSessionId,
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
}
},
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'claude'
})
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
cleanups.push(() => server.stop())
const firstClient = new RemoteRuntimeRequestConnection(requirePairing(server, 'client-one'))
const secondClient = new RemoteRuntimeRequestConnection(requirePairing(server, 'client-two'))
const retryClient = new RemoteRuntimeRequestConnection(requirePairing(server, 'retry-client'))
cleanups.push(() => firstClient.close())
cleanups.push(() => secondClient.close())
cleanups.push(() => retryClient.close())
const request: RuntimeEnsureAgentSessionRequest = {
kind: 'explicit',
worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}`,
agent: 'claude',
providerSession: { key: 'session_id', id: 'provider-session-repro' },
presentation: 'background'
}
const [first, second] = await Promise.all([
firstClient.request<RuntimeEnsureAgentSessionResult>(
'terminal.ensureAgentSession',
request,
REQUEST_TIMEOUT_MS
),
secondClient.request<RuntimeEnsureAgentSessionResult>(
'terminal.ensureAgentSession',
request,
REQUEST_TIMEOUT_MS
)
])
expect(first.ok).toBe(true)
expect(second.ok).toBe(true)
if (!first.ok || !second.ok) {
throw new Error('structured resume failed')
}
expect([first.result.disposition, second.result.disposition].sort()).toEqual([
'adopted',
'created'
])
expect(second.result.terminal).toMatchObject({
handle: first.result.terminal.handle,
tabId: first.result.terminal.tabId,
paneKey: first.result.terminal.paneKey,
ptyId: first.result.terminal.ptyId
})
expect(spawnSubprocess).toHaveBeenCalledOnce()
expect(host.listSessions()).toHaveLength(1)
// Why: a retry cannot prove whether its previous response arrived, so
// the provider identity—not a new client operation—must recover the owner.
const retry = await retryClient.request<RuntimeEnsureAgentSessionResult>(
'terminal.ensureAgentSession',
request,
REQUEST_TIMEOUT_MS
)
expect(retry).toMatchObject({
ok: true,
result: {
disposition: 'adopted',
terminal: {
handle: first.result.terminal.handle,
tabId: first.result.terminal.tabId,
paneKey: first.result.terminal.paneKey,
ptyId: first.result.terminal.ptyId
}
}
})
expect(spawnSubprocess).toHaveBeenCalledOnce()
subprocesses[0]?.exit(0)
await vi.waitFor(async () => {
const [terminals, tabs] = await Promise.all([
firstClient.request<{ terminals: unknown[] }>(
'terminal.list',
{ worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` },
REQUEST_TIMEOUT_MS
),
secondClient.request<RuntimeMobileSessionTabsResult>(
'session.tabs.list',
{ worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` },
REQUEST_TIMEOUT_MS
)
])
expect(terminals).toMatchObject({ ok: true, result: { terminals: [] } })
expect(tabs).toMatchObject({ ok: true, result: { tabs: [] } })
})
}
)
})
+3 -1
View File
@@ -6,6 +6,7 @@ import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core'
import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery'
import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types'
import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access'
import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../shared/agent-session-host-authority'
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return {
@@ -49,7 +50,8 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
'no_active_terminal',
'repo_not_found',
'timeout',
'invalid_limit'
'invalid_limit',
...AGENT_SESSION_RPC_ERROR_CODES
])
const COMPUTER_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(Object.values(COMPUTER_ERROR_CODES))
@@ -0,0 +1,325 @@
import { describe, expect, it, vi } from 'vitest'
import {
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION
} from '../../../../shared/protocol-version'
import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../../shared/agent-session-host-authority'
import { AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../../shared/agent-session-host-authority'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { AGENT_SESSION_METHODS } from './agent-session'
import { TERMINAL_METHODS } from './terminal'
function request(method: string, params: unknown): RpcRequest {
return { id: 'request-1', authToken: 'token', method, params }
}
function terminalResult(disposition: 'created' | 'adopted' | 'replayed' = 'created') {
return {
terminal: {
handle: 'term_1',
worktreeId: 'worktree-1',
title: null
},
disposition
}
}
function runtimeStub() {
return {
getRuntimeId: () => 'runtime-1',
ensureAgentSession: vi.fn().mockResolvedValue(terminalResult()),
createAgentSession: vi.fn().mockResolvedValue(terminalResult())
}
}
describe('agent session RPC methods', () => {
it('dispatches an explicit structured resume without an authoritative command', async () => {
const runtime = runtimeStub()
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: AGENT_SESSION_METHODS
})
const response = await dispatcher.dispatch(
request('terminal.ensureAgentSession', {
kind: 'explicit',
worktree: 'id:worktree-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'provider-session-1' },
agentArgs: '--profile review',
launchPreferences: { model: 'gpt-5', effort: 'high' },
presentation: 'focused',
placement: { tabId: 'tab-1', leafId: 'leaf-1' }
})
)
expect(response).toMatchObject({ ok: true, result: { disposition: 'created' } })
expect(runtime.ensureAgentSession).toHaveBeenCalledWith(
{
kind: 'explicit',
worktree: 'id:worktree-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'provider-session-1' },
agentArgs: '--profile review',
launchPreferences: { model: 'gpt-5', effort: 'high' },
presentation: 'focused',
placement: { tabId: 'tab-1', leafId: 'leaf-1' }
},
{}
)
})
it('keeps automatic authority checkpoint-only', async () => {
const runtime = runtimeStub()
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: AGENT_SESSION_METHODS
})
const response = await dispatcher.dispatch(
request('terminal.ensureAgentSession', {
kind: 'automatic',
sleepingCheckpointId: 'checkpoint_123456789012345678901',
worktree: 'id:forged',
placement: { tabId: 'forged' }
})
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.ensureAgentSession).not.toHaveBeenCalled()
})
it('rejects mismatched agent/provider identity before runtime mutation', async () => {
const runtime = runtimeStub()
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: AGENT_SESSION_METHODS
})
const response = await dispatcher.dispatch(
request('terminal.ensureAgentSession', {
kind: 'explicit',
worktree: 'id:worktree-1',
agent: 'antigravity',
providerSession: { key: 'session_id', id: 'provider-session-1' }
})
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.ensureAgentSession).not.toHaveBeenCalled()
})
it('rejects opaque fresh-launch authority and malformed operation IDs', async () => {
const runtime = runtimeStub()
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: AGENT_SESSION_METHODS
})
const opaque = await dispatcher.dispatch(
request('terminal.createAgentSession', {
clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef',
worktree: 'id:worktree-1',
agent: 'codex',
command: 'codex resume provider-session-1'
})
)
const malformed = await dispatcher.dispatch(
request('terminal.createAgentSession', {
clientOperationId: 'not-time-sortable',
worktree: 'id:worktree-1',
agent: 'codex'
})
)
expect(opaque).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(malformed).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.createAgentSession).not.toHaveBeenCalled()
})
it('preserves legacy agent-bearing terminal.create requests for mixed-version clients', async () => {
const createTerminal = vi.fn().mockResolvedValue({ handle: 'term-1' })
const dedupeTerminalCreate = vi.fn(
async (
_clientIdentity: string,
worktree: string | undefined,
_clientMutationId: string | undefined,
_reconcileExisting: boolean,
run: (worktree: string | undefined, handle: string | undefined) => Promise<unknown>
) => run(worktree, undefined)
)
const runtime = {
getRuntimeId: () => 'runtime-1',
createTerminal,
dedupeTerminalCreate
}
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: TERMINAL_METHODS
})
const response = await dispatcher.dispatch(
request('terminal.create', {
worktree: 'id:worktree-1',
command: 'codex resume provider-session-1',
launchAgent: 'codex'
})
)
expect(response.ok).toBe(true)
expect(dedupeTerminalCreate).toHaveBeenCalledWith(
'local',
'id:worktree-1',
undefined,
false,
expect.any(Function)
)
expect(createTerminal).toHaveBeenCalledWith('id:worktree-1', {
command: 'codex resume provider-session-1',
startupCommandDelivery: undefined,
env: undefined,
launchAgent: 'codex',
title: undefined,
focus: false,
rendererBacked: false,
activate: false,
presentation: undefined,
tabId: undefined,
leafId: undefined
})
})
it('rejects future-dated operation IDs before runtime mutation', async () => {
const runtime = runtimeStub()
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: AGENT_SESSION_METHODS
})
const now = 1_752_883_200_000
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now)
const response = await dispatcher.dispatch(
request('terminal.createAgentSession', {
clientOperationId: `${now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 1}-0123456789abcdef0123456789abcdef`,
worktree: 'id:worktree-1',
agent: 'codex'
})
)
dateNow.mockRestore()
expect(response).toMatchObject({
ok: false,
error: { code: 'agent_session_operation_invalid' }
})
expect(runtime.createAgentSession).not.toHaveBeenCalled()
})
it('passes authenticated caller identity outside the request payload', async () => {
const runtime = runtimeStub()
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: AGENT_SESSION_METHODS
})
const replies: RpcResponse[] = []
await dispatcher.dispatchStreaming(
request('terminal.createAgentSession', {
clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef',
worktree: 'id:worktree-1',
agent: 'codex',
prompt: 'Fix the race',
promptDelivery: 'draft',
agentArgs: '--profile review',
launchPreferences: { model: 'gpt-5', effort: 'high' },
viewMode: 'chat'
}),
(response) => replies.push(JSON.parse(response) as RpcResponse),
{ clientId: 'authenticated-device', clientKind: 'runtime' }
)
expect(replies).toHaveLength(1)
expect(replies[0]).toMatchObject({ ok: true, result: { disposition: 'created' } })
expect(runtime.createAgentSession).toHaveBeenCalledWith(
{
clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef',
worktree: 'id:worktree-1',
agent: 'codex',
prompt: 'Fix the race',
promptDelivery: 'draft',
agentArgs: '--profile review',
launchPreferences: { model: 'gpt-5', effort: 'high' },
viewMode: 'chat'
},
{ clientId: 'authenticated-device', clientKind: 'runtime' }
)
})
it('rejects draft delivery without a non-empty prompt', async () => {
const runtime = runtimeStub()
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: AGENT_SESSION_METHODS
})
const response = await dispatcher.dispatch(
request('terminal.createAgentSession', {
clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef',
worktree: 'id:worktree-1',
agent: 'claude',
prompt: ' ',
promptDelivery: 'draft'
})
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.createAgentSession).not.toHaveBeenCalled()
})
it('rejects oversized structured agent arguments before runtime mutation', async () => {
const runtime = runtimeStub()
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: AGENT_SESSION_METHODS
})
const response = await dispatcher.dispatch(
request('terminal.createAgentSession', {
clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef',
worktree: 'id:worktree-1',
agent: 'codex',
agentArgs: 'a'.repeat(16 * 1024 + 1)
})
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.createAgentSession).not.toHaveBeenCalled()
})
it.each(AGENT_SESSION_RPC_ERROR_CODES)('preserves stable runtime error %s', async (code) => {
const runtime = runtimeStub()
runtime.ensureAgentSession.mockRejectedValueOnce(new Error(code))
const dispatcher = new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: AGENT_SESSION_METHODS
})
const response = await dispatcher.dispatch(
request('terminal.ensureAgentSession', {
kind: 'automatic',
sleepingCheckpointId: 'checkpoint_123456789012345678901'
})
)
expect(response).toMatchObject({ ok: false, error: { code } })
})
it('advertises the capability without moving the mixed-version protocol fence', () => {
expect(RUNTIME_PROTOCOL_VERSION).toBe(3)
expect(MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION).toBe(2)
expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY)
})
})
@@ -0,0 +1,228 @@
import { z } from 'zod'
import {
getAgentResumeArgv,
hasUnsafeProviderSessionIdChars,
RESUMABLE_TUI_AGENTS
} from '../../../../shared/agent-session-resume'
import type {
RuntimeAgentSessionRpcCaller,
RuntimeCreateAgentSessionRequest,
RuntimeCreateAgentSessionResult,
RuntimeEnsureAgentSessionRequest,
RuntimeEnsureAgentSessionResult
} from '../../../../shared/agent-session-host-authority'
import {
AGENT_SESSION_OPERATION_FUTURE_SKEW_MS,
parseAgentSessionOperationTimestamp
} from '../../../../shared/agent-session-host-authority'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { defineMethod, type RpcAnyMethod } from '../core'
const MAX_WORKTREE_SELECTOR_LENGTH = 32_768
const MAX_TRANSCRIPT_PATH_BYTES = 16 * 1024
const MAX_PROMPT_BYTES = 256 * 1024
const MAX_AGENT_ARGS_BYTES = 16 * 1024
const MAX_LAUNCH_PREFERENCE_LENGTH = 512
const StrictNonEmptyString = (max: number, message: string) =>
z
.string()
.min(1, message)
.max(max, message)
.refine((value) => value === value.trim(), `${message}; surrounding whitespace is invalid`)
const WorktreeSelector = StrictNonEmptyString(
MAX_WORKTREE_SELECTOR_LENGTH,
'Invalid worktree selector'
)
const Presentation = z.enum(['background', 'focused'])
const Placement = z
.object({
tabId: z
.string()
.min(1)
.max(512)
.refine(isValidTerminalTabId, 'Invalid terminal tab ID')
.optional(),
leafId: z.string().min(1).max(128).optional()
})
.strict()
.refine((value) => value.tabId !== undefined || value.leafId !== undefined, {
message: 'Placement must include a tab or leaf ID'
})
const LaunchPreferences = z
.object({
model: StrictNonEmptyString(
MAX_LAUNCH_PREFERENCE_LENGTH,
'Invalid model preference'
).optional(),
effort: StrictNonEmptyString(
MAX_LAUNCH_PREFERENCE_LENGTH,
'Invalid effort preference'
).optional(),
mode: StrictNonEmptyString(MAX_LAUNCH_PREFERENCE_LENGTH, 'Invalid mode preference').optional()
})
.strict()
const PromptDelivery = z.enum(['auto-submit', 'draft'])
const AgentArgs = z
.string()
.refine(
(value) => Buffer.byteLength(value, 'utf8') <= MAX_AGENT_ARGS_BYTES,
'Agent arguments are too large'
)
.nullable()
const ProviderSession = z
.object({
key: z.enum(['session_id', 'conversation_id']),
id: StrictNonEmptyString(512, 'Invalid provider session ID').refine(
(value) => !value.startsWith('-') && !hasUnsafeProviderSessionIdChars(value),
'Invalid provider session ID'
),
transcriptPath: z
.string()
.min(1)
.refine((value) => value === value.trim(), 'Invalid transcript path')
.refine(
(value) =>
!hasUnsafeProviderSessionIdChars(value) &&
Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES,
'Invalid transcript path'
)
.optional()
})
.strict()
const AutomaticEnsure = z
.object({
kind: z.literal('automatic'),
sleepingCheckpointId: z
.string()
.min(32)
.max(128)
.regex(/^[A-Za-z0-9_-]+$/),
presentation: Presentation.optional()
})
.strict()
const ExplicitEnsure = z
.object({
kind: z.literal('explicit'),
worktree: WorktreeSelector,
agent: z.enum(RESUMABLE_TUI_AGENTS),
providerSession: ProviderSession,
agentArgs: AgentArgs.optional(),
launchPreferences: LaunchPreferences.optional(),
presentation: Presentation.optional(),
placement: Placement.optional()
})
.strict()
.superRefine((value, context) => {
if (getAgentResumeArgv(value.agent, value.providerSession) === null) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['providerSession'],
message: 'Provider session is not resumable for this agent'
})
}
})
export const EnsureAgentSessionParams: z.ZodType<RuntimeEnsureAgentSessionRequest> =
z.discriminatedUnion('kind', [AutomaticEnsure, ExplicitEnsure])
export const CreateAgentSessionParams: z.ZodType<RuntimeCreateAgentSessionRequest> = z
.object({
clientOperationId: z
.string()
.refine(
(value) => parseAgentSessionOperationTimestamp(value) !== null,
'Invalid agent operation ID'
),
worktree: WorktreeSelector,
agent: z.string().refine(isTuiAgent, 'Unknown agent preset'),
prompt: z
.string()
.refine(
(value) => Buffer.byteLength(value, 'utf8') <= MAX_PROMPT_BYTES,
'Prompt is too large'
)
.optional(),
promptDelivery: PromptDelivery.optional(),
agentArgs: AgentArgs.optional(),
launchPreferences: LaunchPreferences.optional(),
startupCwd: z.string().min(1).max(MAX_WORKTREE_SELECTOR_LENGTH).optional(),
presentation: Presentation.optional(),
placement: Placement.optional(),
viewMode: z.enum(['terminal', 'chat']).optional()
})
.strict()
.superRefine((value, context) => {
if (value.promptDelivery === 'draft' && !value.prompt?.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['prompt'],
message: 'Draft delivery requires a non-empty prompt'
})
}
})
type AgentSessionRuntime = OrcaRuntimeService & {
ensureAgentSession(
request: RuntimeEnsureAgentSessionRequest,
caller?: RuntimeAgentSessionRpcCaller
): Promise<RuntimeEnsureAgentSessionResult>
createAgentSession(
request: RuntimeCreateAgentSessionRequest,
caller?: RuntimeAgentSessionRpcCaller
): Promise<RuntimeCreateAgentSessionResult>
}
function callerContext(
clientId: string | undefined,
clientKind: 'mobile' | 'runtime' | undefined,
signal: AbortSignal | undefined
): RuntimeAgentSessionRpcCaller {
return {
...(clientId !== undefined ? { clientId } : {}),
...(clientKind !== undefined ? { clientKind } : {}),
...(signal ? { signal } : {})
}
}
function assertOperationTimestampWithinFutureSkew(clientOperationId: string): void {
const timestamp = parseAgentSessionOperationTimestamp(clientOperationId)
if (timestamp === null || timestamp > Date.now() + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS) {
// Why: a future-dated ID could look new again after its idempotency tombstone is collected.
throw new Error('agent_session_operation_invalid')
}
}
export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.ensureAgentSession',
params: EnsureAgentSessionParams,
handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) =>
(runtime as AgentSessionRuntime).ensureAgentSession(
params,
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
)
}),
defineMethod({
name: 'terminal.createAgentSession',
params: CreateAgentSessionParams,
handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => {
assertOperationTimestampWithinFutureSkew(params.clientOperationId)
return (runtime as AgentSessionRuntime).createAgentSession(
params,
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
)
}
})
]
+2
View File
@@ -35,6 +35,7 @@ import { CLIPBOARD_METHODS } from './clipboard'
import { HOST_CAPABILITY_METHODS } from './host-capabilities'
import { EMULATOR_METHODS } from './emulator'
import { PAIRING_METHODS } from './pairing'
import { AGENT_SESSION_METHODS } from './agent-session'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@@ -45,6 +46,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...AUTOMATION_METHODS,
...REPO_METHODS,
...WORKTREE_METHODS,
...AGENT_SESSION_METHODS,
...TERMINAL_METHODS,
...BROWSER_CORE_METHODS,
...BROWSER_SCREENCAST_METHODS,
@@ -10,7 +10,7 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
withSpan(
'runtime.session-tabs.close',
async (span) => {
if (!params.reason && context.clientKind !== 'mobile') {
if (!params.reason && context.clientKind === undefined) {
const result = await context.runtime.refuseUnattributedMobileSessionTabClose(
params.worktree,
params.tabId
@@ -35,7 +35,8 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
attribution: 'session-tab-close',
origin: context.clientKind ?? 'in-process',
closeReason:
params.reason ?? (context.clientKind === 'mobile' ? 'legacy-mobile-user' : 'missing'),
params.reason ??
(context.clientKind ? `legacy-${context.clientKind}-user` : 'missing'),
connectionGeneration: context.connectionId ?? 'in-process',
requestId: context.requestId ?? 'in-process'
}
@@ -28,8 +28,8 @@ export const ActivateTab = WorktreeTabSelector.extend({
})
export const CloseTab = ActivateTab.extend({
// Why: optional so legacy clients still validate; policy conservatively
// attributes only authenticated mobile callers as user intent.
// Why: optional preserves authenticated legacy user closes; lifecycle intent
// uses the additive evidence-bearing method instead.
reason: z.literal('user').optional()
})
@@ -136,6 +136,28 @@ describe('session tab RPC methods', () => {
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
})
it('preserves reasonless explicit closes from authenticated legacy runtime clients', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
refuseUnattributedMobileSessionTabClose: vi.fn(),
closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const replies: string[] = []
await dispatcher.dispatchStreaming(
makeRequest('session.tabs.close', { worktree: 'id:wt-1', tabId: 'tab-1' }),
(response) => replies.push(response),
{ clientKind: 'runtime', pairedDeviceId: 'legacy-runtime' }
)
expect(replies).toHaveLength(1)
expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', {
reason: 'user'
})
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
})
it.each(['pty-exit', 'cleanup'] as const)(
'rejects %s on the legacy close endpoint before host adjudication',
async (reason) => {
@@ -262,7 +284,7 @@ describe('session tab RPC methods', () => {
})
})
it('dispatches terminal creation with the requested tab group', async () => {
it('dispatches ordinary terminal creation with the requested tab group', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
createMobileSessionTerminal: vi.fn().mockResolvedValue({
@@ -321,7 +343,9 @@ describe('session tab RPC methods', () => {
activate: true,
select: undefined,
clientNavigationId: undefined,
navigation: 'all'
navigation: 'all',
clientMutationId: undefined,
signal: undefined
})
})
@@ -357,7 +381,7 @@ describe('session tab RPC methods', () => {
)
})
it('dispatches terminal creation with a requested agent preset', async () => {
it('preserves legacy agent creation for mixed-version clients', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
createMobileSessionTerminal: vi.fn().mockResolvedValue({
@@ -390,13 +414,16 @@ describe('session tab RPC methods', () => {
afterTabId: undefined,
targetGroupId: undefined,
command: undefined,
cwd: undefined,
startupCommandDelivery: undefined,
agent: 'codex',
agentPrompt: 'Review this diff',
activate: undefined,
select: undefined,
clientNavigationId: undefined,
navigation: 'all'
navigation: 'all',
clientMutationId: undefined,
signal: undefined
})
})
+2
View File
@@ -356,6 +356,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'terminal.close',
'terminal.closeTab',
'terminal.create',
'terminal.createAgentSession',
'terminal.ensureAgentSession',
'terminal.focus',
'terminal.agentStatus',
'terminal.getAutoRestoreFit',
@@ -0,0 +1,293 @@
import type {
Tab,
TabGroup,
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
WorkspaceSessionState
} from '../../shared/types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement'
function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set<string>): void {
if (!node) {
return
}
if (node.type === 'leaf') {
ids.add(node.leafId)
return
}
collectLeafIds(node.first, ids)
collectLeafIds(node.second, ids)
}
function layoutHasSameMembership(
candidate: TerminalLayoutSnapshot,
current: TerminalLayoutSnapshot
): boolean {
const candidateIds = new Set<string>()
const currentIds = new Set<string>()
collectLeafIds(candidate.root, candidateIds)
collectLeafIds(current.root, currentIds)
return (
candidateIds.size === currentIds.size &&
[...candidateIds].every((leafId) => currentIds.has(leafId))
)
}
function rebaseLayout(
candidate: TerminalLayoutSnapshot | undefined,
current: TerminalLayoutSnapshot | undefined
): TerminalLayoutSnapshot | undefined {
if (!current) {
return undefined
}
if (!candidate || !layoutHasSameMembership(candidate, current)) {
return current
}
return {
...candidate,
// Why: renderer layout metadata may move, but only host-persisted live bindings may name PTYs.
ptyIdsByLeafId: {
...candidate.ptyIdsByLeafId,
...current.ptyIdsByLeafId
}
}
}
function terminalUnifiedTabMatches(tab: Tab, terminalTabIds: ReadonlySet<string>): boolean {
return (
tab.contentType === 'terminal' &&
(terminalTabIds.has(tab.id) || terminalTabIds.has(tab.entityId))
)
}
function rebaseUnifiedTabs(
candidate: readonly Tab[],
current: readonly Tab[],
terminalTabIds: ReadonlySet<string>
): Tab[] {
const result = candidate.filter(
(tab) => tab.contentType !== 'terminal' || terminalUnifiedTabMatches(tab, terminalTabIds)
)
const representedTerminalIds = new Set(
result.filter((tab) => tab.contentType === 'terminal').flatMap((tab) => [tab.id, tab.entityId])
)
for (const tab of current) {
if (
terminalUnifiedTabMatches(tab, terminalTabIds) &&
!representedTerminalIds.has(tab.id) &&
!representedTerminalIds.has(tab.entityId)
) {
result.push(tab)
}
}
return result
}
function rebaseTabGroups(
groups: readonly TabGroup[],
validTabIds: ReadonlySet<string>
): TabGroup[] {
return groups.flatMap((group) => {
const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId))
if (tabOrder.length === 0) {
return []
}
const activeTabId =
group.activeTabId && tabOrder.includes(group.activeTabId)
? group.activeTabId
: (tabOrder[0] ?? null)
const recentTabIds = group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId))
return [
{
...group,
tabOrder,
activeTabId,
...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {})
}
]
})
}
function rebaseIncarnationBindings(
session: WorkspaceSessionState,
prior: WorkspaceSessionState
): Record<string, string> | undefined {
const terminalTabIds = new Set(
Object.values(session.tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id))
)
const allowedPaneKeys = new Set<string>()
for (const tabId of terminalTabIds) {
const layout = session.terminalLayoutsByTabId[tabId]
if (!layout) {
continue
}
const leafIds = new Set<string>()
collectLeafIds(layout.root, leafIds)
for (const leafId of leafIds) {
allowedPaneKeys.add(`${tabId}:${leafId}`)
}
}
const merged = {
...session.terminalPtyIncarnationsByPaneKey,
...prior.terminalPtyIncarnationsByPaneKey
}
const retained = Object.fromEntries(
Object.entries(merged).filter(([paneKey]) => {
const separator = paneKey.lastIndexOf(':')
if (separator < 1) {
return false
}
const tabId = paneKey.slice(0, separator)
return session.terminalLayoutsByTabId[tabId]
? allowedPaneKeys.has(paneKey)
: terminalTabIds.has(tabId)
})
)
return Object.keys(retained).length > 0 ? retained : undefined
}
export function advanceTerminalTopologyRevision(
session: WorkspaceSessionState,
worktreeId: string
): WorkspaceSessionState {
const repoId = getRepoIdFromWorktreeId(worktreeId)
return {
...session,
terminalTopologyRevisionByRepoId: {
...session.terminalTopologyRevisionByRepoId,
[repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1
}
}
}
export function hasHostAuthoritativeTerminalMembership(
session: WorkspaceSessionState | undefined,
worktreeId: string
): boolean {
const repoId = getRepoIdFromWorktreeId(worktreeId)
return (
(session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 ||
Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some(
(tombstone) => tombstone.worktreeId === worktreeId
)
)
}
export function rebaseWorkspaceSessionTerminalMembership(
incoming: WorkspaceSessionState,
prior: WorkspaceSessionState | undefined
): WorkspaceSessionState {
if (!prior?.terminalTopologyRevisionByRepoId) {
return incoming
}
const terminalTopologyRevisionByRepoId = { ...incoming.terminalTopologyRevisionByRepoId }
for (const [repoId, revision] of Object.entries(prior.terminalTopologyRevisionByRepoId)) {
terminalTopologyRevisionByRepoId[repoId] = Math.max(
revision,
terminalTopologyRevisionByRepoId[repoId] ?? 0
)
}
const tabsByWorktree = { ...incoming.tabsByWorktree }
const terminalLayoutsByTabId = { ...incoming.terminalLayoutsByTabId }
const unifiedTabs = { ...incoming.unifiedTabs }
const tabGroups = { ...incoming.tabGroups }
const tabGroupLayouts = { ...incoming.tabGroupLayouts }
const activeTabIdByWorktree = { ...incoming.activeTabIdByWorktree }
let includeUnifiedTabs = incoming.unifiedTabs !== undefined
let includeTabGroups = incoming.tabGroups !== undefined
let includeTabGroupLayouts = incoming.tabGroupLayouts !== undefined
let rebasedMembership = false
const worktreeIds = new Set([
...Object.keys(prior.tabsByWorktree),
...Object.keys(incoming.tabsByWorktree)
])
for (const worktreeId of worktreeIds) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
const revision = terminalTopologyRevisionByRepoId[repoId] ?? 0
const priorRevision = prior.terminalTopologyRevisionByRepoId[repoId] ?? 0
const incomingRevision = incoming.terminalTopologyRevisionByRepoId?.[repoId] ?? 0
if (revision <= 0 || incomingRevision > priorRevision) {
continue
}
rebasedMembership = true
const currentTabs = prior.tabsByWorktree[worktreeId] ?? []
const candidateTabsById = new Map(
(incoming.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])
)
const terminalTabIds = new Set(currentTabs.map((tab) => tab.id))
const tabs = currentTabs.map((current) => {
const candidate = candidateTabsById.get(current.id)
return candidate ? { ...candidate, ptyId: current.ptyId } : current
})
for (const candidate of incoming.tabsByWorktree[worktreeId] ?? []) {
if (!terminalTabIds.has(candidate.id)) {
delete terminalLayoutsByTabId[candidate.id]
}
}
for (const tabId of terminalTabIds) {
const layout = rebaseLayout(
incoming.terminalLayoutsByTabId[tabId],
prior.terminalLayoutsByTabId[tabId]
)
if (layout) {
terminalLayoutsByTabId[tabId] = layout
} else {
delete terminalLayoutsByTabId[tabId]
}
}
const rebasedUnifiedTabs = rebaseUnifiedTabs(
incoming.unifiedTabs?.[worktreeId] ?? [],
prior.unifiedTabs?.[worktreeId] ?? [],
terminalTabIds
)
if (includeUnifiedTabs || rebasedUnifiedTabs.length > 0) {
unifiedTabs[worktreeId] = rebasedUnifiedTabs
includeUnifiedTabs = true
}
const validTabIds = new Set([...terminalTabIds, ...rebasedUnifiedTabs.map((tab) => tab.id)])
const rebasedGroups = rebaseTabGroups(
incoming.tabGroups?.[worktreeId] ?? prior.tabGroups?.[worktreeId] ?? [],
validTabIds
)
if (includeTabGroups || rebasedGroups.length > 0) {
tabGroups[worktreeId] = rebasedGroups
includeTabGroups = true
}
const rebasedGroupLayout = pruneTabGroupLayoutAfterRetirement(
incoming.tabGroupLayouts?.[worktreeId] ?? prior.tabGroupLayouts?.[worktreeId],
new Set(rebasedGroups.map((group) => group.id))
)
if (rebasedGroupLayout) {
tabGroupLayouts[worktreeId] = rebasedGroupLayout
includeTabGroupLayouts = true
} else {
delete tabGroupLayouts[worktreeId]
}
if (!validTabIds.has(activeTabIdByWorktree[worktreeId] ?? '')) {
activeTabIdByWorktree[worktreeId] =
(prior.activeTabIdByWorktree?.[worktreeId] &&
validTabIds.has(prior.activeTabIdByWorktree[worktreeId] ?? '')
? prior.activeTabIdByWorktree[worktreeId]
: (rebasedGroups[0]?.activeTabId ?? tabs[0]?.id)) ?? null
}
tabsByWorktree[worktreeId] = tabs
}
const next: WorkspaceSessionState = {
...incoming,
terminalTopologyRevisionByRepoId,
...(rebasedMembership
? {
tabsByWorktree,
terminalLayoutsByTabId,
activeTabIdByWorktree,
...(includeUnifiedTabs ? { unifiedTabs } : {}),
...(includeTabGroups ? { tabGroups } : {}),
...(includeTabGroupLayouts ? { tabGroupLayouts } : {})
}
: {})
}
return rebasedMembership
? { ...next, terminalPtyIncarnationsByPaneKey: rebaseIncarnationBindings(next, prior) }
: next
}
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SshRelaySession } from './ssh-relay-session'
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') }))
vi.mock('./ssh-channel-multiplexer', () => ({
SshChannelMultiplexer: class MockSshChannelMultiplexer {
notify = vi.fn()
request = muxRequestMock
onNotification = vi.fn().mockReturnValue(() => {})
onRequest = vi.fn().mockReturnValue(() => {})
onDispose = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
isDisposed = vi.fn().mockReturnValue(false)
}
}))
vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({
installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([])
}))
vi.mock('../providers/ssh-pty-provider', () => ({
SshPtyProvider: class MockSshPtyProvider {
onData = vi.fn().mockReturnValue(() => {})
onReplay = vi.fn().mockReturnValue(() => {})
onExit = vi.fn().mockReturnValue(() => {})
attach = vi.fn().mockResolvedValue(undefined)
attachForReconnect = vi.fn().mockResolvedValue({})
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-filesystem-provider', () => ({
SshFilesystemProvider: class MockSshFilesystemProvider {
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-git-provider', () => ({
SshGitProvider: class MockSshGitProvider {}
}))
vi.mock('../ipc/pty', () => ({
registerSshPtyProvider: vi.fn(),
unregisterSshPtyProvider: vi.fn(),
getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }),
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
setPtyOwnership: vi.fn(),
restorePtyIncarnation: vi.fn(),
isCurrentPtyExit: vi.fn(() => true),
answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data)
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
registerSshFilesystemProvider: vi.fn(),
unregisterSshFilesystemProvider: vi.fn(),
getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() })
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
registerSshGitProvider: vi.fn(),
unregisterSshGitProvider: vi.fn()
}))
const { registerSshPtyProvider, clearProviderPtyState, deletePtyOwnership, isCurrentPtyExit } =
await import('../ipc/pty')
describe('SSH relay PTY incarnation exits', () => {
beforeEach(() => {
vi.clearAllMocks()
muxRequestMock.mockResolvedValue([])
mockDeploySuccess()
vi.mocked(isCurrentPtyExit).mockReturnValue(true)
})
it('drops a stale exit before ownership cleanup and propagates a current incarnation', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() }
const session = new SshRelaySession(
'target-1',
getMainWindow,
mockStore,
mockPortForward,
runtime as never
)
await session.establish(mockConn)
const provider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as {
onExit: ReturnType<typeof vi.fn>
}
const onExit = provider.onExit.mock.calls[0]?.[0] as (payload: {
id: string
code: number
incarnationId: string
}) => void
vi.mocked(isCurrentPtyExit).mockReturnValueOnce(false)
onExit({ id: 'ssh:target-1@@pty-reused', code: 0, incarnationId: 'old-incarnation' })
expect(clearProviderPtyState).not.toHaveBeenCalled()
expect(deletePtyOwnership).not.toHaveBeenCalled()
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled()
expect(runtime.onPtyExit).not.toHaveBeenCalled()
expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything())
onExit({ id: 'ssh:target-1@@pty-reused', code: 7, incarnationId: 'current-incarnation' })
expect(runtime.onPtyExit).toHaveBeenCalledWith(
'ssh:target-1@@pty-reused',
7,
'current-incarnation'
)
})
})
@@ -0,0 +1,283 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SshRelaySession } from './ssh-relay-session'
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') }))
vi.mock('./ssh-channel-multiplexer', () => ({
SshChannelMultiplexer: class MockSshChannelMultiplexer {
notify = vi.fn()
request = muxRequestMock
onNotification = vi.fn().mockReturnValue(() => {})
onRequest = vi.fn().mockReturnValue(() => {})
onDispose = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
isDisposed = vi.fn().mockReturnValue(false)
}
}))
vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({
installRemoteManagedAgentHooks: vi.fn()
}))
vi.mock('../providers/ssh-pty-provider', () => ({
isSshPtyNotFoundError: (error: unknown) => String(error).includes('not found'),
isSshPtyIdentityMismatchError: (error: unknown) => String(error).includes('identity mismatch'),
SshPtyProvider: class MockSshPtyProvider {
onData = vi.fn().mockReturnValue(() => {})
onReplay = vi.fn().mockReturnValue(() => {})
onExit = vi.fn().mockReturnValue(() => {})
attach = vi.fn().mockResolvedValue(undefined)
attachForReconnect = vi.fn().mockResolvedValue({})
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-filesystem-provider', () => ({
SshFilesystemProvider: class MockSshFilesystemProvider {
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-git-provider', () => ({
SshGitProvider: class MockSshGitProvider {}
}))
vi.mock('../ipc/pty', () => ({
registerSshPtyProvider: vi.fn(),
unregisterSshPtyProvider: vi.fn(),
getSshPtyProvider: vi.fn(),
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
setPtyOwnership: vi.fn(),
restorePtyIncarnation: vi.fn(),
isCurrentPtyExit: vi.fn(() => true),
answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data)
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
registerSshFilesystemProvider: vi.fn(),
unregisterSshFilesystemProvider: vi.fn(),
getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() })
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
registerSshGitProvider: vi.fn(),
unregisterSshGitProvider: vi.fn()
}))
const {
registerSshPtyProvider,
getSshPtyProvider,
getPtyIdsForConnection,
setPtyOwnership,
restorePtyIncarnation
} = await import('../ipc/pty')
const APP_PTY_ID = 'ssh:target-1@@pty-live'
const INCARNATION_LEAF_ID = '11111111-1111-4111-8111-111111111111'
function detachedLease() {
return {
targetId: 'target-1',
ptyId: 'pty-live',
state: 'detached' as const,
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: INCARNATION_LEAF_ID
}
}
function emitExitDuringAttach(payload: { id: string; code: number; incarnationId?: string }): void {
const registeredProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as {
onExit: ReturnType<typeof vi.fn>
}
const exitHandler = registeredProvider.onExit.mock.calls[0]?.[0] as
| ((exit: typeof payload) => void)
| undefined
queueMicrotask(() => exitHandler?.(payload))
}
describe('SshRelaySession reconnect incarnation ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
muxRequestMock.mockReset()
muxRequestMock.mockResolvedValue([])
mockDeploySuccess()
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
})
it('restores and persists exact incarnation proof from reconnect attach', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
const incarnationId = 'incarnation-reconnect'
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect: vi.fn().mockResolvedValue({ incarnationId }),
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType<
typeof mockStore.getSshRemotePtyLeases
>)
const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() }
const session = new SshRelaySession(
'target-1',
getMainWindow,
mockStore,
mockPortForward,
runtime as never
)
await session.establish(mockConn)
expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId)
expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', {
tabId: 'tab-1',
leafId: INCARNATION_LEAF_ID,
incarnationId
})
expect(runtime.onPtySpawned).not.toHaveBeenCalled()
expect(mockStore.persistPtyBinding).toHaveBeenCalledWith({
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: INCARNATION_LEAF_ID,
ptyId: APP_PTY_ID,
incarnationId
})
expect(vi.mocked(mockStore.persistPtyBinding).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(mockStore.markSshRemotePtyLease).mock.invocationCallOrder[0]!
)
})
it('does not restore a PTY whose matching exit shares the attach reply batch', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
const incarnationId = 'incarnation-exited-during-attach'
const runtime = {
acceptPtyIncarnationForExit: vi.fn(),
onPtyExit: vi.fn(),
onPtySpawned: vi.fn(),
registerPty: vi.fn()
}
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect: vi.fn().mockImplementation(async () => {
emitExitDuringAttach({ id: APP_PTY_ID, code: 0, incarnationId })
emitExitDuringAttach({ id: APP_PTY_ID, code: 0, incarnationId: 'incarnation-stale' })
return { incarnationId, replay: 'dead-output' }
}),
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType<
typeof mockStore.getSshRemotePtyLeases
>)
const session = new SshRelaySession(
'target-1',
getMainWindow,
mockStore,
mockPortForward,
runtime as never
)
await session.establish(mockConn)
expect(runtime.onPtyExit).toHaveBeenCalledWith(APP_PTY_ID, 0, incarnationId)
expect(runtime.acceptPtyIncarnationForExit).toHaveBeenCalledWith(APP_PTY_ID, incarnationId)
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId)
expect(setPtyOwnership).not.toHaveBeenCalled()
expect(mockStore.persistPtyBinding).not.toHaveBeenCalled()
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(
'target-1',
'pty-live',
'terminated'
)
expect(
vi
.mocked(mockWindow.webContents.send)
.mock.calls.some(([channel]) => channel === 'pty:replay')
).toBe(false)
})
it('ignores an older incarnation exit while reconnecting a reused PTY id', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
const currentIncarnationId = 'incarnation-current'
const runtime = {
acceptPtyIncarnationForExit: vi.fn(),
onPtyExit: vi.fn(),
onPtySpawned: vi.fn(),
registerPty: vi.fn()
}
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect: vi.fn().mockImplementation(async () => {
emitExitDuringAttach({
id: APP_PTY_ID,
code: 0,
incarnationId: 'incarnation-old'
})
return { incarnationId: currentIncarnationId, replay: 'live-output' }
}),
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType<
typeof mockStore.getSshRemotePtyLeases
>)
const session = new SshRelaySession(
'target-1',
getMainWindow,
mockStore,
mockPortForward,
runtime as never
)
await session.establish(mockConn)
expect(runtime.onPtyExit).not.toHaveBeenCalled()
expect(runtime.acceptPtyIncarnationForExit).not.toHaveBeenCalled()
expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', {
tabId: 'tab-1',
leafId: INCARNATION_LEAF_ID,
incarnationId: currentIncarnationId
})
expect(setPtyOwnership).toHaveBeenCalledWith(APP_PTY_ID, 'target-1')
expect(mockStore.persistPtyBinding).toHaveBeenCalledWith(
expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId })
)
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', {
id: APP_PTY_ID,
data: 'live-output'
})
})
it('keeps the attached PTY when incarnation backfill persistence fails', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
const incarnationId = 'incarnation-reconnect'
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect: vi.fn().mockResolvedValue({ incarnationId }),
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType<
typeof mockStore.getSshRemotePtyLeases
>)
vi.mocked(mockStore.persistPtyBinding).mockImplementationOnce(() => {
throw new Error('disk full')
})
const runtime = { onPtySpawned: vi.fn(), registerPty: vi.fn() }
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const session = new SshRelaySession(
'target-1',
getMainWindow,
mockStore,
mockPortForward,
runtime as never
)
await expect(session.establish(mockConn)).resolves.toBeUndefined()
expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', {
tabId: 'tab-1',
leafId: INCARNATION_LEAF_ID,
incarnationId
})
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-live', 'attached')
expect(consoleError).toHaveBeenCalledWith(
'[ssh-relay-session] Failed to persist reconnect incarnation:',
expect.any(Error)
)
consoleError.mockRestore()
})
})
@@ -19,7 +19,8 @@ export function createMockDeps(): SshRelaySessionTestDeps {
getRepos: vi.fn().mockReturnValue([]),
getSshRemotePtyLeases: vi.fn().mockReturnValue([]),
markSshRemotePtyLease: vi.fn(),
markSshRemotePtyLeases: vi.fn()
markSshRemotePtyLeases: vi.fn(),
persistPtyBinding: vi.fn()
} as unknown as Store
const mockPortForward = {
removeAllForwards: vi.fn()
+3 -1
View File
@@ -69,7 +69,9 @@ vi.mock('../ipc/pty', () => ({
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
setPtyOwnership: vi.fn()
setPtyOwnership: vi.fn(),
restorePtyIncarnation: vi.fn(),
isCurrentPtyExit: vi.fn(() => true)
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
+82 -17
View File
@@ -7,11 +7,9 @@ import { execCommand } from './ssh-relay-deploy-helpers'
import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
import type { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
import { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import {
SshPtyProvider,
isSshPtyIdentityMismatchError,
isSshPtyNotFoundError
} from '../providers/ssh-pty-provider'
import { SshPtyProvider } from '../providers/ssh-pty-provider'
import type { SshPtyExitCallback } from '../providers/ssh-pty-provider-contract'
import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
import { SshGitProvider } from '../providers/ssh-git-provider'
@@ -34,7 +32,9 @@ import {
clearPtyOwnershipForConnection,
clearProviderPtyState,
deletePtyOwnership,
setPtyOwnership
setPtyOwnership,
restorePtyIncarnation,
isCurrentPtyExit
} from '../ipc/pty'
import {
recordHiddenRendererPtyDataDrop,
@@ -71,6 +71,9 @@ import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed'
type SshPtyExitPayload = Parameters<SshPtyExitCallback>[0]
type PendingPtyReattach = { exits: SshPtyExitPayload[] }
type RemoteCliBridgeEnv = {
remoteHome: string
binDir: string
@@ -147,6 +150,7 @@ export class SshRelaySession {
private hostPlatform: RemoteHostPlatform | null = null
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
private forwardedReattachReplayByPty = new Map<string, ForwardedReplayFingerprint>()
private pendingPtyReattaches = new Map<string, PendingPtyReattach>()
constructor(
readonly targetId: string,
@@ -956,19 +960,32 @@ export class SshRelaySession {
}
})
ptyProvider.onExit((payload) => {
const relayPtyId = toRelaySshPtyId(this.targetId, payload.id)
clearProviderPtyState(payload.id)
deletePtyOwnership(payload.id)
this.forwardedReattachReplayByPty.delete(payload.id)
this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated')
this.runtime?.onPtyExit(payload.id, payload.code)
const win = this.getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:exit', payload)
const pendingReattach = this.pendingPtyReattaches.get(payload.id)
if (pendingReattach) {
// Why: attach response and exit can share one transport batch, before incarnation restoration runs.
pendingReattach.exits.push(payload)
return
}
if (!isCurrentPtyExit(payload)) {
return
}
this.retireExitedPty(payload)
})
}
private retireExitedPty(payload: SshPtyExitPayload): void {
const relayPtyId = toRelaySshPtyId(this.targetId, payload.id)
clearProviderPtyState(payload.id)
deletePtyOwnership(payload.id)
this.forwardedReattachReplayByPty.delete(payload.id)
this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated')
this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId)
const win = this.getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:exit', payload)
}
}
private replayFingerprint(data: string): string {
const head = data.slice(0, REPLAY_FINGERPRINT_EDGE_CHARS)
const tail = data.slice(-REPLAY_FINGERPRINT_EDGE_CHARS)
@@ -1001,6 +1018,7 @@ export class SshRelaySession {
const activeLeases = this.store
.getSshRemotePtyLeases(this.targetId)
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')
const activeLeaseByPtyId = new Map(activeLeases.map((lease) => [lease.ptyId, lease]))
const leasedPtyIds = activeLeases.map((lease) => lease.ptyId)
// Why: pass pane identity so the relay can reject cross-generation id collisions; tabId falls back for pre-leafId leases.
const expectedIdentityByPtyId = new Map(
@@ -1028,6 +1046,9 @@ export class SshRelaySession {
if (!shouldContinue()) {
return
}
const appPtyId = toAppSshPtyId(this.targetId, ptyId)
const pendingReattach: PendingPtyReattach = { exits: [] }
this.pendingPtyReattaches.set(appPtyId, pendingReattach)
try {
const expectedIdentity = expectedIdentityByPtyId.get(ptyId)
const attachResult =
@@ -1037,15 +1058,55 @@ export class SshRelaySession {
if (!shouldContinue()) {
return
}
const appPtyId = toAppSshPtyId(this.targetId, ptyId)
const exitDuringAttach = pendingReattach.exits.find(
(exit) =>
!exit.incarnationId ||
!attachResult.incarnationId ||
exit.incarnationId === attachResult.incarnationId
)
if (exitDuringAttach) {
if (attachResult.incarnationId) {
restorePtyIncarnation(appPtyId, attachResult.incarnationId)
this.runtime?.acceptPtyIncarnationForExit(appPtyId, attachResult.incarnationId)
}
this.retireExitedPty(exitDuringAttach)
continue
}
setPtyOwnership(appPtyId, this.targetId)
if (attachResult.incarnationId) {
restorePtyIncarnation(appPtyId, attachResult.incarnationId)
const lease = activeLeaseByPtyId.get(ptyId)
if (lease?.worktreeId && lease.tabId && lease.leafId) {
this.runtime?.registerPty(appPtyId, lease.worktreeId, this.targetId, {
tabId: lease.tabId,
leafId: lease.leafId,
incarnationId: attachResult.incarnationId
})
// Why: reconnect may be the first new-relay response that can backfill exact exit fencing.
try {
this.store.persistPtyBinding({
worktreeId: lease.worktreeId,
tabId: lease.tabId,
leafId: lease.leafId,
ptyId: appPtyId,
incarnationId: attachResult.incarnationId
})
} catch (error) {
// Why: this backfill improves future fencing but must not disconnect an already-live relay PTY.
console.error('[ssh-relay-session] Failed to persist reconnect incarnation:', error)
}
} else {
this.runtime?.onPtySpawned(appPtyId, attachResult.incarnationId, {
awaitsRegistration: false
})
}
}
this.store.markSshRemotePtyLease(this.targetId, ptyId, 'attached')
this.forwardReattachReplay(appPtyId, attachResult.replay ?? '')
} catch (err) {
if (!isSshPtyNotFoundError(err)) {
throw err
}
const appPtyId = toAppSshPtyId(this.targetId, ptyId)
if (isSshPtyIdentityMismatchError(err)) {
console.warn(
`[ssh-relay-session] Ignoring stale PTY ${ptyId} for ${this.targetId} after relay identity mismatch: ${
@@ -1068,6 +1129,10 @@ export class SshRelaySession {
if (win && !win.isDestroyed()) {
win.webContents.send('pty:exit', { id: appPtyId, code: -1 })
}
} finally {
if (this.pendingPtyReattaches.get(appPtyId) === pendingReattach) {
this.pendingPtyReattaches.delete(appPtyId)
}
}
}
}
+217 -43
View File
@@ -40,10 +40,15 @@ import {
} from './pty-handler'
import type { RelayDispatcher } from './dispatcher'
type TestRequestContext = {
isStale: () => boolean
signal?: AbortSignal
}
function createMockDispatcher() {
const requestHandlers = new Map<
string,
(params: Record<string, unknown>, context?: { isStale: () => boolean }) => Promise<unknown>
(params: Record<string, unknown>, context?: TestRequestContext) => Promise<unknown>
>()
const notificationHandlers = new Map<string, (params: Record<string, unknown>) => void>()
const notifications: { method: string; params?: Record<string, unknown> }[] = []
@@ -52,10 +57,7 @@ function createMockDispatcher() {
onRequest: vi.fn(
(
method: string,
handler: (
params: Record<string, unknown>,
context?: { isStale: () => boolean }
) => Promise<unknown>
handler: (params: Record<string, unknown>, context?: TestRequestContext) => Promise<unknown>
) => {
requestHandlers.set(method, handler)
}
@@ -73,7 +75,7 @@ function createMockDispatcher() {
async callRequest(
method: string,
params: Record<string, unknown> = {},
context?: { isStale: () => boolean }
context?: TestRequestContext
) {
const handler = requestHandlers.get(method)
if (!handler) {
@@ -97,6 +99,24 @@ describe('PtyHandler', () => {
let dispatcher: ReturnType<typeof createMockDispatcher>
let handler: PtyHandler
async function spawnPty(
params: Record<string, unknown> = {}
): Promise<{ id: string; incarnationId: string }> {
return (await dispatcher.callRequest('pty.spawn', params)) as {
id: string
incarnationId: string
}
}
async function attachPty(
params: Record<string, unknown>
): Promise<{ incarnationId: string; replay?: string }> {
return (await dispatcher.callRequest('pty.attach', params)) as {
incarnationId: string
replay?: string
}
}
beforeEach(() => {
vi.useFakeTimers()
mockPtySpawn.mockReset()
@@ -176,8 +196,8 @@ describe('PtyHandler', () => {
})
it('spawns a PTY and returns an id', async () => {
const result = await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
expect(result).toEqual({ id: 'pty-1' })
const result = await spawnPty({ cols: 80, rows: 24 })
expect(result).toEqual({ id: 'pty-1', incarnationId: expect.any(String) })
expect(mockPtySpawn).toHaveBeenCalled()
expect(handler.activePtyCount).toBe(1)
})
@@ -224,6 +244,128 @@ describe('PtyHandler', () => {
expect(spawnOptions.env.NODE_ENV).toBe('production')
})
it('replays an operation-owned spawn after its first response becomes stale', async () => {
const operationId = 'a'.repeat(43)
await dispatcher.callRequest(
'pty.spawn',
{ cols: 80, rows: 24, agentSessionCreateOperationId: operationId },
{ isStale: () => mockPtySpawn.mock.calls.length > 0 }
)
const replayed = await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
agentSessionCreateOperationId: operationId
})
expect(replayed).toEqual({ id: 'pty-1', incarnationId: expect.any(String) })
expect(mockPtySpawn).toHaveBeenCalledOnce()
expect(mockPtyInstance.kill).not.toHaveBeenCalled()
expect(handler.activePtyCount).toBe(1)
})
it('retains an operation fence when publication fails after native spawn', async () => {
const operationId = 'f'.repeat(43)
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(() => {
throw new Error('listener publication failed')
})
})
const request = {
cols: 80,
rows: 24,
agentSessionCreateOperationId: operationId
}
await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow(
'listener publication failed'
)
await expect(dispatcher.callRequest('pty.spawn', request)).rejects.toThrow(
'listener publication failed'
)
expect(mockPtySpawn).toHaveBeenCalledOnce()
expect(handler.activePtyCount).toBe(1)
})
it('releases a canceled operation before native spawn after module preflight', async () => {
let finishModuleLoad!: (value: { spawn: typeof mockPtySpawn }) => void
const moduleLoad = new Promise<{ spawn: typeof mockPtySpawn }>((resolve) => {
finishModuleLoad = resolve
})
const internals = handler as unknown as {
loadPty(): Promise<{ spawn: typeof mockPtySpawn } | null>
}
const loadPty = vi.spyOn(internals, 'loadPty').mockReturnValueOnce(moduleLoad)
const abort = new AbortController()
const operationId = 'c'.repeat(43)
const request = { cols: 80, rows: 24, agentSessionCreateOperationId: operationId }
const spawning = dispatcher.callRequest('pty.spawn', request, {
isStale: () => abort.signal.aborted,
signal: abort.signal
})
abort.abort()
finishModuleLoad({ spawn: mockPtySpawn })
await expect(spawning).rejects.toThrow('client_disconnected')
expect(mockPtySpawn).not.toHaveBeenCalled()
loadPty.mockResolvedValue({ spawn: mockPtySpawn })
await expect(dispatcher.callRequest('pty.spawn', request)).resolves.toMatchObject({
id: expect.stringMatching(/^pty-/)
})
expect(mockPtySpawn).toHaveBeenCalledOnce()
})
it('rejects malformed create operation ids before spawning', async () => {
await expect(
dispatcher.callRequest('pty.spawn', { agentSessionCreateOperationId: 'not-valid' })
).rejects.toThrow('agent_session_operation_invalid')
expect(mockPtySpawn).not.toHaveBeenCalled()
})
it('adopts only the exact claimed owner generation on relay retry', async () => {
const agentSessionEnsure = {
claim: {
digestVersion: 1,
keyId: 'claim-key',
identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
agent: 'codex'
},
surface: {
worktreeId: 'repo::/tmp/worktree',
tabId: '11111111-1111-4111-8111-111111111111',
leafId: '22222222-2222-4222-8222-222222222222',
terminalHandle: 'term_claimed'
}
}
const first = (await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
agentSessionEnsure
})) as Record<string, unknown>
const second = (await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
agentSessionEnsure
})) as Record<string, unknown>
expect(first).toMatchObject({
id: 'pty-1',
agentSessionEnsure: { disposition: 'created' }
})
expect(second).toMatchObject({
id: 'pty-1',
agentSessionEnsure: { disposition: 'adopted' }
})
expect(second.agentSessionEnsure).toMatchObject({
owner: (first.agentSessionEnsure as { owner: unknown }).owner
})
expect(mockPtySpawn).toHaveBeenCalledOnce()
})
it('normalizes a missing native binding as degraded node-pty availability', async () => {
mockPtySpawn.mockImplementationOnce(() => {
throw new Error(
@@ -768,8 +910,9 @@ describe('PtyHandler', () => {
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
let spawn!: { id: string; incarnationId: string }
try {
await dispatcher.callRequest('pty.spawn', {
spawn = await spawnPty({
env: { HOME: homeDir },
command: 'echo fallback',
commandDelivery: 'provider',
@@ -799,11 +942,14 @@ describe('PtyHandler', () => {
data: '\x1b]777;orca-shell-ready'
})
const result = await dispatcher.callRequest('pty.attach', {
const result = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({ replay: '\x1b]777;orca-shell-ready' })
expect(result).toEqual({
incarnationId: spawn.incarnationId,
replay: '\x1b]777;orca-shell-ready'
})
}
)
@@ -885,16 +1031,16 @@ describe('PtyHandler', () => {
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
const spawn = await spawnPty()
dataCallback?.('prompt$ ')
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
try {
const result = await dispatcher.callRequest('pty.attach', {
const result = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({ replay: 'prompt$ ' })
expect(result).toEqual({ incarnationId: spawn.incarnationId, replay: 'prompt$ ' })
} finally {
aliveSpy.mockRestore()
}
@@ -914,7 +1060,13 @@ describe('PtyHandler', () => {
}
mockPtySpawn.mockReturnValue(term)
await dispatcher.callRequest('pty.spawn', {}, { isStale: () => true })
await dispatcher.callRequest(
'pty.spawn',
{},
{
isStale: () => mockPtySpawn.mock.calls.length > 0
}
)
// Why: assert via the captured spy reference rather than term.kill because
// disposeManagedPty() neutralizes managed.pty.kill (replaces it with a
@@ -938,7 +1090,7 @@ describe('PtyHandler', () => {
await dispatcher.callRequest(
'pty.spawn',
{ command: 'echo stale', commandDelivery: 'provider' },
{ isStale: () => true }
{ isStale: () => mockPtySpawn.mock.calls.length > 0 }
)
vi.advanceTimersByTime(50)
@@ -1048,7 +1200,7 @@ describe('PtyHandler', () => {
id: 'pty-1',
suppressReplayNotification: true
})
).resolves.toEqual({ replay: 'prompt' })
).resolves.toEqual({ replay: 'prompt', incarnationId: expect.any(String) })
})
it('leaves startup queries untouched for an unsupported relay capability version', async () => {
@@ -1264,15 +1416,15 @@ describe('PtyHandler', () => {
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
const spawn = await spawnPty()
dataCallback!('buffered output')
const result = await dispatcher.callRequest('pty.attach', {
const result = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({ replay: 'buffered output' })
expect(result).toEqual({ incarnationId: spawn.incarnationId, replay: 'buffered output' })
expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.replay', expect.anything())
vi.advanceTimersByTime(8)
expect(dispatcher.notify).not.toHaveBeenCalledWith('pty.data', expect.anything())
@@ -1288,13 +1440,13 @@ describe('PtyHandler', () => {
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
const spawn = await spawnPty()
dataCallback!('buffered output')
dispatcher.notify.mockClear()
const result = await dispatcher.callRequest('pty.attach', { id: 'pty-1' })
const result = await attachPty({ id: 'pty-1' })
expect(result).toEqual({})
expect(result).toEqual({ incarnationId: spawn.incarnationId })
expect(dispatcher.notify).toHaveBeenCalledWith('pty.replay', {
id: 'pty-1',
data: 'buffered output'
@@ -1308,8 +1460,9 @@ describe('PtyHandler', () => {
const oldTabId = process.env.ORCA_TAB_ID
delete process.env.ORCA_PANE_KEY
delete process.env.ORCA_TAB_ID
let spawn!: { id: string; incarnationId: string }
try {
await dispatcher.callRequest('pty.spawn', {
spawn = await spawnPty({
env: { FOO: 'bar' },
paneKey: 'tab-a:leaf-a',
tabId: 'tab-a'
@@ -1332,7 +1485,7 @@ describe('PtyHandler', () => {
expect(spawnOptions.env.ORCA_TAB_ID).toBeUndefined()
await expect(
dispatcher.callRequest('pty.attach', {
attachPty({
id: 'pty-1',
expectedPaneKey: 'tab-b:leaf-b',
expectedTabId: 'tab-b'
@@ -1340,12 +1493,12 @@ describe('PtyHandler', () => {
).rejects.toThrow('PTY "pty-1" not found')
await expect(
dispatcher.callRequest('pty.attach', {
attachPty({
id: 'pty-1',
expectedPaneKey: 'tab-a:leaf-a',
expectedTabId: 'tab-a'
})
).resolves.toEqual({})
).resolves.toEqual({ incarnationId: spawn.incarnationId })
})
it('notifies on PTY exit and removes from map', async () => {
@@ -1358,11 +1511,15 @@ describe('PtyHandler', () => {
})
})
await dispatcher.callRequest('pty.spawn', {})
const spawn = await spawnPty()
expect(handler.activePtyCount).toBe(1)
exitCallback!({ exitCode: 0 })
expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: 0 })
expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', {
id: 'pty-1',
code: 0,
incarnationId: spawn.incarnationId
})
expect(handler.activePtyCount).toBe(0)
})
@@ -1379,7 +1536,7 @@ describe('PtyHandler', () => {
})
})
await dispatcher.callRequest('pty.spawn', {})
const spawn = await spawnPty()
dataCallback!('final output')
exitCallback!({ exitCode: 0 })
@@ -1387,7 +1544,11 @@ describe('PtyHandler', () => {
id: 'pty-1',
data: 'final output'
})
expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.exit', { id: 'pty-1', code: 0 })
expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'pty.exit', {
id: 'pty-1',
code: 0,
incarnationId: spawn.incarnationId
})
})
it('writes data to PTY via pty.data notification', async () => {
@@ -1544,7 +1705,13 @@ describe('PtyHandler', () => {
it('does not retry stale-spawn cleanup after the Windows kill deadline', async () => {
await withWindowsPlatform(async () => {
const mockKill = mockKillablePty()
await dispatcher.callRequest('pty.spawn', {}, { isStale: () => true })
await dispatcher.callRequest(
'pty.spawn',
{},
{
isStale: () => mockPtySpawn.mock.calls.length > 0
}
)
expectBareKills(mockKill, 1)
vi.advanceTimersByTime(5000)
expectBareKills(mockKill, 1)
@@ -1623,7 +1790,7 @@ describe('PtyHandler', () => {
const exits: { id: string; paneKey?: string }[] = []
handler.setExitListener((evt) => exits.push(evt))
await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-fallback:0' } })
const spawn = await spawnPty({ env: { ORCA_PANE_KEY: 'tab-fallback:0' } })
await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
vi.advanceTimersByTime(5000)
@@ -1634,7 +1801,11 @@ describe('PtyHandler', () => {
expect(mockKill).toHaveBeenCalledWith('SIGTERM')
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: 137 })
expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', {
id: 'pty-1',
code: 137,
incarnationId: spawn.incarnationId
})
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-fallback:0' }])
expect(handler.activePtyCount).toBe(0)
})
@@ -1770,22 +1941,22 @@ describe('PtyHandler', () => {
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
const spawn = await spawnPty()
dataCallback!('initial output')
const r1 = await dispatcher.callRequest('pty.attach', {
const r1 = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(r1).toEqual({ replay: 'initial output' })
expect(r1).toEqual({ incarnationId: spawn.incarnationId, replay: 'initial output' })
dataCallback!(' more')
const r2 = await dispatcher.callRequest('pty.attach', {
const r2 = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(r2).toEqual({ replay: 'initial output more' })
expect(r2).toEqual({ incarnationId: spawn.incarnationId, replay: 'initial output more' })
})
it('second app restart still replays full buffer', async () => {
@@ -1798,30 +1969,33 @@ describe('PtyHandler', () => {
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
const spawn = await spawnPty()
dataCallback!('$ while true; do date; done\r\n')
dataCallback!('Mon Apr 28\r\n')
await dispatcher.callRequest('pty.attach', {
const firstAttach = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(firstAttach.incarnationId).toBe(spawn.incarnationId)
dataCallback!('Tue Apr 29\r\n')
await dispatcher.callRequest('pty.attach', {
const secondAttach = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(secondAttach.incarnationId).toBe(spawn.incarnationId)
dataCallback!('Wed Apr 30\r\n')
const result = await dispatcher.callRequest('pty.attach', {
const result = await attachPty({
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({
incarnationId: spawn.incarnationId,
replay: '$ while true; do date; done\r\nMon Apr 28\r\nTue Apr 29\r\nWed Apr 30\r\n'
})
})
+207 -13
View File
@@ -3,6 +3,7 @@ import type { IPty } from 'node-pty'
import type * as NodePty from 'node-pty'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { resolveWindowsGitBashShellPath } from '../main/git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell'
import type { RelayDispatcher, RequestContext } from './dispatcher'
@@ -47,6 +48,17 @@ import {
type PtyIngressEmission
} from '../shared/pty-startup-ingress'
import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend'
import {
agentSessionOwnerBindingsEqual,
ClaimedAgentPtyOwnerRegistry
} from '../shared/claimed-agent-pty-owner'
import {
AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION,
AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION,
isAgentSessionExecutionClaim,
isAgentSessionSurfaceBinding,
type AgentSessionOwnerBinding
} from '../shared/agent-session-host-authority'
function isMissingNodePtyNativeBinding(error: unknown): boolean {
return (
@@ -57,6 +69,7 @@ function isMissingNodePtyNativeBinding(error: unknown): boolean {
type ManagedPty = {
id: string
incarnationId: string
pty: IPty
initialCwd: string
buffered: string
@@ -83,8 +96,20 @@ type ManagedPty = {
startupIngress?: PtyStartupIngress
startupIngressIntent?: ReturnType<typeof parsePtyStartupIngressIntent>
ownerBackend: PtyOwnerBackend
agentSessionOwners?: AgentSessionOwnerBinding[]
}
type RelayAgentSessionCreateResult = {
id: string
incarnationId: string
replay?: string
agentSessionEnsure?: unknown
}
const AGENT_SESSION_CREATE_OPERATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/
const AGENT_SESSION_CREATE_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1000
const AGENT_SESSION_CREATE_OPERATION_LIMIT = 4_096
type PendingPtyOutput = {
data: string
rawLength?: number
@@ -200,10 +225,12 @@ function resolvePtyShellOverride(shellOverride: string): string {
type PtyProcessSummary = {
id: string
incarnationId: string
cwd: string
title: string
worktreeId?: string
terminalHandle?: string
agentSessionOwners?: AgentSessionOwnerBinding[]
}
type SerializedPtyEntry = {
@@ -221,6 +248,7 @@ type SerializedPtyEntry = {
envToDelete?: string[]
/** Optional for state serialized by relays predating the credential guard. */
gitCredentialPromptGuarded?: boolean
agentSessionOwners?: AgentSessionOwnerBinding[]
}
function sanitizeEnvToDelete(value: unknown): string[] {
@@ -283,6 +311,11 @@ export class PtyHandler {
private exitListener: PtyExitListener | null = null
// Why: env augmenters run on every spawn so each PTY sees live hook coords without the dispatcher knowing about agent hooks.
private envAugmenters: PtyEnvAugmenter[] = []
private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry()
private readonly agentSessionCreateOperations = new Map<
string,
Promise<RelayAgentSessionCreateResult>
>()
constructor(dispatcher: RelayDispatcher, graceTimeMs = DEFAULT_GRACE_TIME_MS) {
this.dispatcher = dispatcher
@@ -535,8 +568,13 @@ export class PtyHandler {
this.clearStartupCommandTimer(managed)
this.releaseRelayIngress(managed)
this.flushPtyOutput(managed.id)
this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode })
this.dispatcher.notify('pty.exit', {
id: managed.id,
code: exitCode,
incarnationId: managed.incarnationId
})
this.notifyExitListener(managed)
this.agentSessionOwners.release(managed.id)
this.ptys.delete(managed.id)
this.clearPtyFlowState(managed.id)
// Why: release the ptmx fd on natural exit, else the master fd leaks until GC (docs/fix-pty-fd-leak.md).
@@ -583,6 +621,11 @@ export class PtyHandler {
this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p))
this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p))
this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p))
this.dispatcher.onRequest('pty.getCapabilities', async () => ({
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION,
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
}))
this.dispatcher.onRequest('pty.listProcesses', () => this.listProcesses())
this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell())
this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p))
@@ -800,14 +843,141 @@ export class PtyHandler {
private async spawn(
params: Record<string, unknown>,
context?: RequestContext
): Promise<{ id: string }> {
): Promise<RelayAgentSessionCreateResult> {
const operationId = params.agentSessionCreateOperationId
if (operationId === undefined) {
return await this.spawnOnce(params, context)
}
if (
typeof operationId !== 'string' ||
!AGENT_SESSION_CREATE_OPERATION_ID_PATTERN.test(operationId)
) {
throw new Error('agent_session_operation_invalid')
}
const existing = this.agentSessionCreateOperations.get(operationId)
if (existing) {
return await existing
}
if (this.agentSessionCreateOperations.size >= AGENT_SESSION_CREATE_OPERATION_LIMIT) {
throw new Error('agent_session_operation_capacity')
}
const operation = this.spawnOnce(params, context)
this.agentSessionCreateOperations.set(operationId, operation)
try {
const result = await operation
this.expireAgentSessionCreateOperation(operationId, operation)
return result
} catch (error) {
const outcomeUnknown =
typeof error === 'object' &&
error !== null &&
'agentSessionOperationOutcome' in error &&
error.agentSessionOperationOutcome === 'unknown'
if (outcomeUnknown) {
// Why: the native PTY may be live; replay the same failure instead of spawning again.
this.expireAgentSessionCreateOperation(operationId, operation)
} else if (this.agentSessionCreateOperations.get(operationId) === operation) {
this.agentSessionCreateOperations.delete(operationId)
}
throw error
}
}
private expireAgentSessionCreateOperation(
operationId: string,
operation: Promise<RelayAgentSessionCreateResult>
): void {
const timer = setTimeout(() => {
if (this.agentSessionCreateOperations.get(operationId) === operation) {
this.agentSessionCreateOperations.delete(operationId)
}
}, AGENT_SESSION_CREATE_OPERATION_RETENTION_MS)
timer.unref?.()
}
private async spawnOnce(
params: Record<string, unknown>,
context?: RequestContext
): Promise<RelayAgentSessionCreateResult> {
const env = params.env as Record<string, string> | undefined
const worktreeId = env?.ORCA_WORKTREE_ID
const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined
const cwd = typeof params.cwd === 'string' ? params.cwd : resolveDefaultCwd()
const finishCreation = this.beginPtyCreation([worktreePath, cwd])
let physicalSpawnCommitted = false
const markPhysicalSpawnCommitted = (): void => {
physicalSpawnCommitted = true
}
try {
return await this.spawnAfterAdmission(params, context)
const ensure = params.agentSessionEnsure as { claim?: unknown; surface?: unknown } | undefined
if (!ensure) {
return await this.spawnAfterAdmission(params, context, markPhysicalSpawnCommitted)
}
if (
!isAgentSessionExecutionClaim(ensure.claim) ||
!isAgentSessionSurfaceBinding(ensure.surface)
) {
throw new Error('agent_session_identity_required')
}
const claim = ensure.claim
const surface = ensure.surface
const result = await this.agentSessionOwners.ensure({
claim,
surface,
spawn: async ({ generation }) => {
const created = await this.spawnAfterAdmission(
params,
context,
markPhysicalSpawnCommitted
)
const managed = this.ptys.get(created.id)
if (managed) {
managed.agentSessionOwners = [
{
claim,
generation,
phase: 'live',
ptyId: created.id,
surface
}
]
}
return { ptyId: created.id }
},
isLive: (owner) => {
const managed = this.ptys.get(owner.ptyId)
return Boolean(
managed &&
!managed.disposed &&
(!managed.pty.pid || isProcessAlive(managed.pty.pid)) &&
managed.agentSessionOwners?.some((candidate) =>
agentSessionOwnerBindingsEqual(candidate, owner)
)
)
}
})
const managed = this.ptys.get(result.owner.ptyId)
if (!managed || managed.disposed) {
this.agentSessionOwners.release(result.owner.ptyId, result.owner.generation)
throw new Error('agent_session_exited_during_start')
}
managed.agentSessionOwners = this.agentSessionOwners.listForPty(managed.id)
return {
id: managed.id,
incarnationId: managed.incarnationId,
agentSessionEnsure: result,
...(result.disposition === 'adopted' && managed.buffered
? { replay: managed.buffered }
: {})
}
} catch (error) {
if (!physicalSpawnCommitted) {
throw error
}
const message = error instanceof Error ? error.message : String(error)
throw Object.assign(new Error(message), {
agentSessionOperationOutcome: 'unknown' as const
})
} finally {
finishCreation()
}
@@ -815,8 +985,9 @@ export class PtyHandler {
private async spawnAfterAdmission(
params: Record<string, unknown>,
context?: RequestContext
): Promise<{ id: string }> {
context?: RequestContext,
onPhysicalSpawnCommitted?: () => void
): Promise<{ id: string; incarnationId: string }> {
const pty = await this.loadPty()
if (!pty) {
throw new Error('node-pty is not available on this remote host')
@@ -875,7 +1046,16 @@ export class PtyHandler {
emitReadyMarker: shouldEmitShellReadyMarker
})
// Why: SSH exec channels give a minimal env; a login shell sources startup files so PATH includes Homebrew/nvm/user CLIs.
if (context?.signal?.aborted || context?.isStale()) {
// Why: cancellation remains side-effect-free until the exact native spawn seam.
throw new Error('client_disconnected')
}
// Why: SSH exec channels give the relay a minimal environment without
// .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH
// includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh).
// When overlays are injected, the launch wrapper keeps those paths after
// user startup files re-export their defaults.
let term: IPty
try {
term = pty.spawn(shell, shellLaunch.args, {
@@ -895,6 +1075,7 @@ export class PtyHandler {
}
throw error
}
onPhysicalSpawnCommitted?.()
// Why: capture paneKey so the exit listener can evict per-pane caches without a separate ptyId→paneKey map.
const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined
@@ -909,6 +1090,7 @@ export class PtyHandler {
: undefined
const managed: ManagedPty = {
id,
incarnationId: randomUUID(),
pty: term,
initialCwd: cwd,
buffered: '',
@@ -942,8 +1124,10 @@ export class PtyHandler {
: {})
}
this.wireAndStore(managed)
if (context?.isStale()) {
// Why: a client reconnect mid-spawn discards the response, so no renderer can own this PTY — shut it down.
if (context?.isStale() && !params.agentSessionEnsure && !params.agentSessionCreateOperationId) {
// Why: if the client reconnected while pty.spawn was in flight, the
// response is discarded and no renderer can own this PTY. Shut it down
// immediately so it does not linger as an unreachable remote shell.
this.releaseStartupCommand(managed)
this.requestGracefulKill(managed, 'terminate stale')
} else if (managed.startupCommand) {
@@ -954,10 +1138,12 @@ export class PtyHandler {
: STARTUP_COMMAND_WRITE_DELAY_MS
)
}
return { id }
return { id, incarnationId: managed.incarnationId }
}
private async attach(params: Record<string, unknown>): Promise<{ replay?: string }> {
private async attach(
params: Record<string, unknown>
): Promise<{ incarnationId: string; replay?: string }> {
const id = params.id as string
const managed = this.ptys.get(id)
// Why: after dispose, pty.kill is a POSIX no-op; treat disposed as not-found so failures aren't silent.
@@ -971,6 +1157,7 @@ export class PtyHandler {
this.releaseRelayIngress(managed)
this.flushPtyOutput(id)
this.notifyExitListener(managed)
this.agentSessionOwners.release(managed.id)
disposeManagedPty(managed)
this.ptys.delete(id)
this.clearPtyFlowState(id)
@@ -998,11 +1185,11 @@ export class PtyHandler {
this.pendingOutputByPty.delete(id)
this.clearOutputFlushTimerIfIdle()
if (params.suppressReplayNotification) {
return { replay: managed.buffered }
return { incarnationId: managed.incarnationId, replay: managed.buffered }
}
this.dispatcher.notify('pty.replay', { id, data: managed.buffered })
}
return {}
return { incarnationId: managed.incarnationId }
}
private writeData(params: Record<string, unknown>): void {
@@ -1206,10 +1393,14 @@ export class PtyHandler {
(await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell'
results.push({
id,
incarnationId: managed.incarnationId,
cwd: managed.initialCwd,
title,
...(managed.worktreeId ? { worktreeId: managed.worktreeId } : {}),
...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {})
...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}),
...(this.agentSessionOwners.listForPty(id).length
? { agentSessionOwners: this.agentSessionOwners.listForPty(id) }
: {})
})
}
return results
@@ -1321,6 +1512,7 @@ export class PtyHandler {
})
this.wireAndStore({
id: entry.id,
incarnationId: randomUUID(),
pty: term,
initialCwd: entry.cwd,
buffered: '',
@@ -1368,6 +1560,7 @@ export class PtyHandler {
if (this.disposePromise) {
return this.disposePromise
}
this.agentSessionCreateOperations.clear()
const disposePromise = this.disposePtys(options.waitForPhysicalExit !== false)
this.disposePromise = disposePromise
void disposePromise.catch(() => {
@@ -1427,6 +1620,7 @@ export class PtyHandler {
}
if (this.ptys.get(managed.id) === managed && !managed.disposed) {
this.notifyExitListener(managed)
this.agentSessionOwners.release(managed.id)
disposeManagedPty(managed)
this.ptys.delete(managed.id)
this.clearPtyFlowState(managed.id)
@@ -85,9 +85,7 @@ export function useAiVaultSessionLaunchActions({
)
)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Could not prepare this session for resume.'
)
notifyAiVaultSessionPreparationFailure(error)
}
},
[buildResumeCommand]
@@ -123,17 +121,21 @@ export function useAiVaultSessionLaunchActions({
...buildResumeStartup(preparedSession, targetId.worktreeId)
})
if (launchResult.tabId === null) {
void launchResult.runtimeLaunch.then((created) => {
if (!created) {
void launchResult.runtimeLaunch.then((outcome) => {
if (outcome.status === 'failed') {
toast.error(
translate(
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
'Could not launch {{value0}} in a new terminal.',
{ value0: agentLabel(session.agent) }
)
outcome.message ||
translate(
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
'Could not launch {{value0}} in a new terminal.',
{ value0: agentLabel(session.agent) }
)
)
return
}
if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) {
activateAiVaultResumeWorkspace(targetId.worktreeId)
}
showQueuedToast()
})
return
@@ -143,11 +145,7 @@ export function useAiVaultSessionLaunchActions({
}
showQueuedToast()
})
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : 'Could not prepare this session for resume.'
)
})
.catch(notifyAiVaultSessionPreparationFailure)
},
[activeWorktree?.id, activeWorktreeId, buildResumeStartup, targetState]
)
@@ -205,6 +203,17 @@ export function useAiVaultSessionLaunchActions({
}
}
function notifyAiVaultSessionPreparationFailure(error: unknown): void {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.right.sidebar.AiVaultPanel.prepareSessionResumeFailed',
'Could not prepare this session for resume.'
)
)
}
function resolveAiVaultTargetWorkspacePath(
state: AiVaultSessionResumeTargetState,
workspaceId: string
@@ -12,6 +12,7 @@ import {
hasAiVaultSessionDragData,
readAiVaultSessionDragData
} from '@/lib/ai-vault-session-drag'
import { getAiVaultAgentProviderSession } from '@/lib/ai-vault-resume-command'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { useAppStore } from '@/store'
import { resolveDropZone } from './tab-drop-zone'
@@ -227,6 +228,11 @@ export default function AiVaultSessionDropLayer({
if (!startup) {
throw new Error('Orca could not prepare this legacy Codex session. Retry resume.')
}
const providerSession = getAiVaultAgentProviderSession({
agent: payload.agent,
sessionId: payload.sessionId,
filePath: payload.sessionFilePath
})
const launchResult = launchAiVaultSessionInNewTab({
agent: payload.agent,
worktreeId,
@@ -234,18 +240,20 @@ export default function AiVaultSessionDropLayer({
...(startup.env ? { env: startup.env } : {}),
...(startup.envToDelete ? { envToDelete: startup.envToDelete } : {}),
...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
...(providerSession ? { providerSession } : {}),
targetGroupId: dropTarget.groupId,
splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone
})
if (launchResult.tabId === null) {
void launchResult.runtimeLaunch.then((created) => {
if (!created) {
void launchResult.runtimeLaunch.then((outcome) => {
if (outcome.status === 'failed') {
toast.error(
translate(
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
'Could not launch {{value0}} in a new terminal.',
{ value0: payload.agent }
)
outcome.message ||
translate(
'auto.lib.launch.agent.in.new.tab.11cce5cc77',
'Could not launch {{value0}} in a new terminal.',
{ value0: payload.agent }
)
)
return
}
@@ -257,7 +265,12 @@ export default function AiVaultSessionDropLayer({
})
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : 'Could not prepare this session for resume.'
error instanceof Error
? error.message
: translate(
'auto.components.right.sidebar.AiVaultPanel.prepareSessionResumeFailed',
'Could not prepare this session for resume.'
)
)
})
return true
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
createBrowserTab: vi.fn(),
createEmptySplitGroup: vi.fn(),
createTab: vi.fn(),
createWebRuntimeSessionTerminal: vi.fn(),
destroyWorkspaceWebviews: vi.fn(),
dispatchEvent: vi.fn(),
dropUnifiedTab: vi.fn(),
@@ -71,7 +72,7 @@ vi.mock('../../runtime/web-runtime-session', () => ({
activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab,
closeWebRuntimeSessionTab: mocks.closeWebRuntimeSessionTab,
createWebRuntimeSessionBrowserTab: vi.fn(),
createWebRuntimeSessionTerminal: vi.fn(),
createWebRuntimeSessionTerminal: mocks.createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive,
toHostSessionTabId: (tabId: string) => tabId
}))
@@ -164,6 +165,10 @@ function resetStore(): void {
describe('useTabGroupWorkspaceModel terminal activation focus', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.createWebRuntimeSessionTerminal.mockResolvedValue({
status: 'failed',
message: 'The workspace is not connected to a remote Orca host.'
})
resetStore()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0)
@@ -191,6 +196,18 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => {
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1', null)
})
it('falls back to a local shell when the typed remote-create outcome is unavailable', async () => {
mocks.createTab.mockReturnValue({ id: 'terminal-new' })
const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel')
const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' })
model.commands.newTerminalWithShell('zsh')
await vi.waitFor(() => expect(mocks.createTab).toHaveBeenCalled())
expect(mocks.createTab).toHaveBeenCalledWith('wt-1', 'group-1', 'zsh')
expect(mocks.setActiveTab).toHaveBeenCalledWith('terminal-new')
})
it('returns keyboard focus to the active split pane leaf when a terminal tab is activated', async () => {
storeBox.state = {
...storeBox.state,

Some files were not shown because too many files have changed in this diff Show More