mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(terminal): make remote agent sessions host-authoritative (#9687)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+460
-48
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) } : {}),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: [] } })
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user