Hibernate completed runtime-owned agents (#5473)

This commit is contained in:
Brennan Benson 2026-06-16 10:15:13 -07:00 committed by GitHub
parent 5599ad28cb
commit ee8327dde9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2235 additions and 100 deletions

View File

@ -150,6 +150,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await adapter.shutdown(id, { immediate: false })
expect(lastSubprocess.kill).toHaveBeenCalled()
})
it('force-kills immediately when requested', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
await adapter.shutdown(id, { immediate: true })
expect(lastSubprocess.kill).not.toHaveBeenCalled()
expect(lastSubprocess.forceKill).toHaveBeenCalled()
})
})
describe('sendSignal', () => {
@ -715,6 +722,27 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(false)
})
it('writes a final checkpoint before keepHistory shutdown', async () => {
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const { id } = await historyAdapter.spawn({
cols: 80,
rows: 24,
cwd: '/home/user',
sessionId: 'sleep-checkpoint'
})
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
lastSubprocess._simulateData('fresh output before sleep\r\n')
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
expect(checkpointSpy).toHaveBeenCalledWith(
id,
expect.objectContaining({ snapshotAnsi: expect.stringContaining('fresh output') })
)
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true)
})
it('returns cold restore data when disk history has unclean shutdown', async () => {
// Simulate a previous daemon crash: write history files without endedAt
const sessionId = 'cold-restore-test'

View File

@ -268,7 +268,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.client.request('kill', { sessionId: id })
// Why: sleep/exact-stop must preserve restorable terminal history,
// so force a final checkpoint before killing the daemon session.
if (opts.keepHistory) {
await this.checkpointSessions([id], { final: true })
}
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
this.activeSessionIds.delete(id)
this.dirtySessionVersions.delete(id)
this.coldRestoreCache.delete(id)

View File

@ -64,8 +64,8 @@ export class DaemonPtyProvider {
this.client.notify('resize', { sessionId: id, cols, rows })
}
async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.client.request('kill', { sessionId: id })
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
}
onData(callback: (payload: { id: string; data: string }) => void): () => void {

View File

@ -145,6 +145,15 @@ describe('DaemonPtyRouter', () => {
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
})
it('fails listProcesses closed when any routed adapter cannot list sessions', async () => {
const current = createAdapter('current', ['current-session'])
const legacy = createAdapter('legacy', ['legacy-session'])
vi.mocked(legacy.listProcesses).mockRejectedValueOnce(new Error('legacy unavailable'))
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.listProcesses()).rejects.toThrow('legacy unavailable')
})
it('merges startup reconciliation and updates route mappings', async () => {
const current = createAdapter('current', [], {
alive: ['current-alive'],

View File

@ -121,10 +121,10 @@ export class DaemonPtyRouter implements IPtyProvider {
}
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
const results = await Promise.allSettled(
this.allAdapters().map((adapter) => adapter.listProcesses())
)
return results.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
// Why: runtime exact-stop/liveness flows must fail closed if any adapter
// cannot provide a trustworthy process list.
const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses()))
return results.flat()
}
async getDefaultShell(): Promise<string> {

View File

@ -345,7 +345,7 @@ export class DaemonServer {
case 'kill':
this.lastInputAtBySessionId.delete(request.payload.sessionId)
this.host.kill(request.payload.sessionId)
this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
return {}
case 'signal':

View File

@ -235,6 +235,22 @@ describe('TerminalHost', () => {
expect(host.isKilled('session-1')).toBe(true)
})
it('force-kills immediately when requested', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.kill('session-1', { immediate: true })
expect(lastSubprocess.kill).not.toHaveBeenCalled()
expect(lastSubprocess.forceKill).toHaveBeenCalled()
expect(lastSubprocess.dispose).toHaveBeenCalled()
expect(host.isKilled('session-1')).toBe(true)
})
it('throws for non-existent session', () => {
expect(() => host.kill('missing')).toThrow('Session not found')
})

View File

@ -160,9 +160,13 @@ export class TerminalHost {
this.getAliveSession(sessionId).resize(cols, rows)
}
kill(sessionId: string): void {
kill(sessionId: string, opts: { immediate?: boolean } = {}): void {
const session = this.getAliveSession(sessionId)
this.recordTombstone(sessionId)
if (opts.immediate) {
session.forceKillAndDisposeSubprocess()
return
}
session.kill()
}

View File

@ -3,8 +3,10 @@
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
// Why: bump when adding daemon wire behavior so same-version old daemons do
// not silently accept the handshake and then reject new RPCs.
export const PROTOCOL_VERSION = 13
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const
export const PROTOCOL_VERSION = 14
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13
] as const
// ─── Session State Machine ──────────────────────────────────────────
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
@ -134,6 +136,7 @@ export type KillRequest = {
type: 'kill'
payload: {
sessionId: string
immediate?: boolean
}
}

View File

@ -298,6 +298,7 @@ describe('registerPtyHandlers', () => {
})
afterEach(() => {
vi.useRealTimers()
unregisterSshPtyProvider('ssh-1')
setLocalPtyProvider(new LocalPtyProvider())
if (savedOpenCodeConfigDir !== undefined) {
@ -1981,6 +1982,164 @@ describe('registerPtyHandlers', () => {
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1)
})
it('passes keepHistory through runtime controller stopAndWait', async () => {
vi.useFakeTimers()
const shutdown = vi.fn(async () => undefined)
const store = {
markSshRemotePtyLease: vi.fn()
}
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
registerSshPtyProvider('ssh-1', {
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
setPtyOwnership('remote-pty', 'ssh-1')
handlers.clear()
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
}
const stopPromise = controller.stopAndWait('remote-pty', { keepHistory: true })
await vi.advanceTimersByTimeAsync(1_200)
await expect(stopPromise).resolves.toBe(true)
expect(shutdown).toHaveBeenCalledWith('remote-pty', {
immediate: true,
keepHistory: true
})
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
'ssh-1',
'remote-pty',
'terminated'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1)
})
it('runtime controller stopAndWait fails when keepHistory allows the PTY to revive', async () => {
vi.useFakeTimers()
const shutdown = vi.fn(async () => undefined)
const listProcesses = vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'local-pty', cwd: '/tmp/demo', title: 'shell' }])
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses,
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
}
const stopPromise = controller.stopAndWait('local-pty', { keepHistory: true })
await vi.advanceTimersByTimeAsync(200)
await expect(stopPromise).resolves.toBe(false)
expect(shutdown).toHaveBeenCalledWith('local-pty', {
immediate: true,
keepHistory: true
})
expect(runtime.onPtyExit).not.toHaveBeenCalled()
})
it('runtime controller stopAndWait preserves ownership when proof fails after shutdown', async () => {
const shutdown = vi.fn(async () => undefined)
const listProcesses = vi.fn().mockRejectedValue(new Error('legacy unavailable'))
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses,
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise<boolean>
}
await expect(controller.stopAndWait('local-pty', { keepHistory: true })).resolves.toBe(
false
)
expect(shutdown).toHaveBeenCalledWith('local-pty', {
immediate: true,
keepHistory: true
})
expect(runtime.onPtyExit).not.toHaveBeenCalled()
})
it('runtime controller kill routes app-scoped SSH ids through the parsed provider when ownership is absent', async () => {
const localShutdown = vi.fn()
setLocalPtyProvider({
@ -2354,6 +2513,46 @@ describe('registerPtyHandlers', () => {
)
})
it('synthesizes runtime exit after ordinary daemon-backed pty kill', async () => {
const shutdown = vi.fn(async () => undefined)
const runtime = {
setPtyController: vi.fn(),
onPtyExit: vi.fn()
}
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown,
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:kill')!(null, { id: 'local-pty', keepHistory: true })
expect(shutdown).toHaveBeenCalledWith('local-pty', {
immediate: true,
keepHistory: true
})
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1)
})
it('waits for the desktop startup barrier before renderer local spawns resolve the provider', async () => {
const barrier = makeDeferred()
registerPtyHandlers(

View File

@ -91,6 +91,8 @@ const ptySizes = new Map<string, { cols: number; rows: number }>()
const lastInputAtByPty = new Map<string, number>()
const interactiveOutputCharsByPty = new Map<string, number>()
const activeRendererPtys = new Set<string>()
const KEEP_HISTORY_STOP_SETTLE_MS = 1_000
const KEEP_HISTORY_STOP_POLL_MS = 100
// Why: the agent-hooks server caches per-paneKey state (last prompt, last
// tool) that otherwise grows unbounded as panes come and go. Track the
// spawn-time paneKey so clearProviderPtyState can clear that cache on PTY
@ -289,6 +291,40 @@ function isPtyAlreadyGoneError(err: unknown): boolean {
return isSshPtyNotFoundError(err) || /Session not found/i.test(message)
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
if (typeof timer.unref === 'function') {
timer.unref()
}
})
}
async function isProviderPtyLive(provider: IPtyProvider, ptyId: string): Promise<boolean> {
return (await provider.listProcesses()).some((session) => session.id === ptyId)
}
async function verifyPtyStopped(
provider: IPtyProvider,
ptyId: string,
opts: { keepHistory?: boolean } | undefined
): Promise<boolean> {
if (await isProviderPtyLive(provider, ptyId)) {
return false
}
if (!opts?.keepHistory) {
return true
}
const deadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS
while (Date.now() < deadline) {
await delay(KEEP_HISTORY_STOP_POLL_MS)
if (await isProviderPtyLive(provider, ptyId)) {
return false
}
}
return true
}
function finishPtyShutdown(
id: string,
connectionId: string | null | undefined,
@ -1916,6 +1952,52 @@ export function registerPtyHandlers(
})
return true
},
stopAndWait: async (ptyId, opts) => {
let provider: IPtyProvider
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
connectionId ??= parsedSshId?.connectionId
try {
provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId)
} catch {
if (connectionId) {
// Why: an absent SSH provider means there is no live target left to
// await, but the relay lease must still be tombstoned.
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
return true
}
return false
}
try {
await provider.shutdown(ptyId, {
immediate: true,
keepHistory: opts?.keepHistory ?? false
})
} catch (err) {
if (!isPtyAlreadyGoneError(err)) {
console.warn(
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
)
return false
}
}
try {
if (!(await verifyPtyStopped(provider, ptyId, opts))) {
return false
}
} catch (err) {
console.warn(
`[pty] Failed to verify PTY ${ptyId} stopped: ${
err instanceof Error ? err.message : String(err)
}`
)
return false
}
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
return true
},
getForegroundProcess: async (ptyId) => {
try {
return await getProviderForPty(ptyId).getForegroundProcess(ptyId)
@ -1944,7 +2026,7 @@ export function registerPtyHandlers(
listProcesses: async () => {
const providerSessions = await Promise.all([
localProvider.listProcesses(),
...Array.from(sshProviders.values(), (provider) => provider.listProcesses().catch(() => []))
...Array.from(sshProviders.values(), (provider) => provider.listProcesses())
])
return providerSessions.flat()
},
@ -2748,6 +2830,7 @@ export function registerPtyHandlers(
// provider is unregistered; hydrated app-scoped ids can also arrive
// before ownership is rebuilt. Tombstone instead of falling back local.
finishPtyShutdown(args.id, connectionId, store)
runtime?.onPtyExit(args.id, -1)
return
}
try {
@ -2768,6 +2851,7 @@ export function registerPtyHandlers(
// and daemon shutdown paths do not emit onExit through the local provider's
// listener. Explicit cleanup is idempotent and covers already-dead PTYs.
finishPtyShutdown(args.id, connectionId, store)
runtime?.onPtyExit(args.id, -1)
})
ipcMain.handle(

View File

@ -1075,6 +1075,7 @@ describe('OrcaRuntimeService', () => {
expect(terminals.terminals[0]).toMatchObject({
worktreeId: 'repo-1::/tmp/worktree-a',
branch: 'feature/foo',
ptyId: 'pty-1',
title: 'Claude',
preview: 'hello from terminal'
})
@ -11546,6 +11547,384 @@ describe('OrcaRuntimeService', () => {
expect(killed).toBe(false)
})
it('stops exactly the expected live PTYs for a worktree', async () => {
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []
const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], []]
runtime.setPtyController({
write: () => true,
kill: () => false,
stopAndWait: async (ptyId, opts) => {
stopped.push(ptyId)
expect(opts).toEqual({ keepHistory: true })
runtime.onPtyExit(ptyId, -1)
return true
},
getForegroundProcess: async () => null,
listProcesses: async () => processLists.shift() ?? []
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
await expect(
runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1'], {
keepHistory: true
})
).resolves.toEqual({
stopped: 1,
stoppedPtyIds: ['pty-1'],
livePtyIds: ['pty-1'],
postStopVerified: true
})
expect(stopped).toEqual(['pty-1'])
})
it('reports recoverable post-stop liveness failure after exact terminal stop', async () => {
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []
const processLists = [
[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }],
new Error('daemon unavailable')
]
runtime.setPtyController({
write: () => true,
kill: () => false,
stopAndWait: async (ptyId) => {
stopped.push(ptyId)
runtime.onPtyExit(ptyId, -1)
return true
},
getForegroundProcess: async () => null,
listProcesses: async () => {
const next = processLists.shift()
if (next instanceof Error) {
throw next
}
return next ?? []
}
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
await expect(
runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1'])
).resolves.toEqual({
stopped: 1,
stoppedPtyIds: ['pty-1'],
livePtyIds: ['pty-1'],
postStopVerified: false,
postStopFailure: 'terminal_liveness_unavailable'
})
expect(stopped).toEqual(['pty-1'])
})
it('rejects exact terminal stop when async PTY stop fails', async () => {
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []
runtime.setPtyController({
write: () => true,
kill: () => false,
stopAndWait: async (ptyId, opts) => {
stopped.push(ptyId)
expect(opts).toEqual({ keepHistory: true })
return false
},
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }]
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
]
})
await expect(
runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1'], {
keepHistory: true
})
).rejects.toThrow('terminal_exact_stop_failed')
expect(stopped).toEqual(['pty-1'])
})
it('rejects exact terminal stop when the live PTY set has extras', async () => {
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []
runtime.setPtyController({
write: () => true,
kill: () => false,
stopAndWait: async (ptyId) => {
stopped.push(ptyId)
runtime.onPtyExit(ptyId, -1)
return true
},
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' },
{ id: 'pty-shell', cwd: '/tmp/worktree-a', title: 'Shell' }
]
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
},
{
tabId: 'tab-2',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Shell',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
},
{
tabId: 'tab-2',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 2,
ptyId: 'pty-shell'
}
]
})
await expect(
runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1'])
).rejects.toThrow('terminal_stop_pty_set_mismatch')
expect(stopped).toEqual([])
})
it('rejects exact terminal stop for multiple expected PTYs before stopping anything', async () => {
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []
runtime.setPtyController({
write: () => true,
kill: () => false,
stopAndWait: async (ptyId) => {
stopped.push(ptyId)
runtime.onPtyExit(ptyId, -1)
return true
},
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' },
{ id: 'pty-2', cwd: '/tmp/worktree-a', title: 'Codex' }
]
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
},
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:2',
paneRuntimeId: 2,
ptyId: 'pty-2'
}
]
})
await expect(
runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1', 'pty-2'])
).rejects.toThrow('terminal_exact_stop_requires_single_pty')
expect(stopped).toEqual([])
})
it('uses fresh post-stop liveness instead of stale renderer leaves', async () => {
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []
const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], []]
runtime.setPtyController({
write: () => true,
kill: () => false,
stopAndWait: async (ptyId) => {
stopped.push(ptyId)
runtime.onPtyExit(ptyId, -1)
return true
},
getForegroundProcess: async () => null,
listProcesses: async () => processLists.shift() ?? []
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
},
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:2',
paneRuntimeId: 2,
ptyId: 'stale-pty'
}
]
})
await expect(
runtime.stopExactTerminalsForWorktree('id:repo-1::/tmp/worktree-a', ['pty-1'])
).resolves.toMatchObject({
stoppedPtyIds: ['pty-1']
})
expect(stopped).toEqual(['pty-1'])
})
it('omits stale renderer leaves when fresh PTY liveness is required', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Stale',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'stale-pty'
}
]
})
const terminals = await runtime.listTerminals('id:repo-1::/tmp/worktree-a', undefined, {
requireFreshPtyLiveness: true
})
expect(terminals.terminals).toEqual([])
})
it('fails terminal listing closed when fresh PTY liveness is required and unavailable', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => {
throw new Error('provider unavailable')
}
})
await expect(
runtime.listTerminals('id:repo-1::/tmp/worktree-a', undefined, {
requireFreshPtyLiveness: true
})
).rejects.toThrow('terminal_liveness_unavailable')
})
it('rejects invalid positive limits for bounded list commands', async () => {
const runtime = new OrcaRuntimeService(store)

View File

@ -895,6 +895,7 @@ type RuntimePtyController = {
}): Promise<{ id: string }>
write(ptyId: string, data: string): boolean
kill(ptyId: string): boolean
stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise<boolean>
getForegroundProcess(ptyId: string): Promise<string | null>
hasChildProcesses?(ptyId: string): Promise<boolean>
clearBuffer?(ptyId: string): Promise<void>
@ -6385,7 +6386,8 @@ export class OrcaRuntimeService {
async listTerminals(
worktreeSelector?: string,
limit = DEFAULT_TERMINAL_LIST_LIMIT
limit = DEFAULT_TERMINAL_LIST_LIMIT,
opts: { requireFreshPtyLiveness?: boolean } = {}
): Promise<RuntimeTerminalListResult> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
@ -6443,7 +6445,11 @@ export class OrcaRuntimeService {
: targetWorktreeId
? []
: [...worktreesById.values()]
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees, targetWorktreeId)
const refreshedPtyLiveness =
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees, targetWorktreeId)
if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness) {
throw new Error('terminal_liveness_unavailable')
}
const livePtyWorktreeIds = new Set<string>()
for (const pty of this.ptysById.values()) {
@ -6459,6 +6465,9 @@ export class OrcaRuntimeService {
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
continue
}
if (opts.requireFreshPtyLiveness && leaf.ptyId && !refreshedPtyLiveness?.has(leaf.ptyId)) {
continue
}
if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) {
continue
}
@ -6476,6 +6485,9 @@ export class OrcaRuntimeService {
if (!pty.connected || ptyIdsFromLeaves.has(pty.ptyId)) {
continue
}
if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness?.has(pty.ptyId)) {
continue
}
if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) {
continue
}
@ -13033,6 +13045,109 @@ export class OrcaRuntimeService {
return { stopped }
}
async stopExactTerminalsForWorktree(
worktreeSelector: string,
expectedPtyIds: readonly string[],
opts: { keepHistory?: boolean } = {}
): Promise<{
stopped: number
stoppedPtyIds: string[]
livePtyIds: string[]
postStopVerified: boolean
postStopFailure?: string
remainingLivePtyIds?: string[]
}> {
// Why: hibernation may commit sleeping state only after the runtime proves
// the selected PTYs are still the complete live set for this worktree.
const graphEpoch = this.captureReadyGraphEpoch()
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
this.assertStableReadyGraph(graphEpoch)
const expected = new Set(expectedPtyIds.filter((ptyId) => ptyId.length > 0))
if (expected.size !== 1) {
throw new Error('terminal_exact_stop_requires_single_pty')
}
const resolvedWorktrees = [...(await this.getResolvedWorktreeMap()).values()]
const refreshedPtyLiveness =
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
if (!refreshedPtyLiveness) {
throw new Error('terminal_liveness_unavailable')
}
const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness)
if (!setsEqual(livePtyIds, expected)) {
const error = Object.assign(new Error('terminal_stop_pty_set_mismatch'), {
livePtyIds: [...livePtyIds].sort(),
expectedPtyIds: [...expected].sort()
})
throw error
}
if (!this.ptyController?.stopAndWait) {
throw new Error('terminal_exact_stop_unavailable')
}
const stoppedPtyIds: string[] = []
for (const ptyId of [...expected].sort()) {
if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) {
throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId })
}
stoppedPtyIds.push(ptyId)
}
const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
if (!postStopLiveness) {
return {
stopped: stoppedPtyIds.length,
stoppedPtyIds,
livePtyIds: [...livePtyIds].sort(),
postStopVerified: false,
postStopFailure: 'terminal_liveness_unavailable'
}
}
const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness)
if (remainingLivePtyIds.size > 0) {
return {
stopped: stoppedPtyIds.length,
stoppedPtyIds,
livePtyIds: [...livePtyIds].sort(),
postStopVerified: false,
postStopFailure: 'terminal_exact_stop_still_live',
remainingLivePtyIds: [...remainingLivePtyIds].sort()
}
}
return {
stopped: stoppedPtyIds.length,
stoppedPtyIds,
livePtyIds: [...livePtyIds].sort(),
postStopVerified: true
}
}
private getLivePtyIdsForWorktree(
worktreeId: string,
freshPtyIds?: ReadonlySet<string>
): Set<string> {
const ptyIds = new Set<string>()
for (const leaf of this.leaves.values()) {
if (
leaf.worktreeId === worktreeId &&
leaf.connected &&
leaf.ptyId &&
(!freshPtyIds || freshPtyIds.has(leaf.ptyId))
) {
ptyIds.add(leaf.ptyId)
}
}
for (const pty of this.ptysById.values()) {
if (
pty.worktreeId === worktreeId &&
pty.connected &&
(!freshPtyIds || freshPtyIds.has(pty.ptyId))
) {
ptyIds.add(pty.ptyId)
}
}
return ptyIds
}
async hasTerminalsForWorktree(worktreeSelector: string): Promise<boolean> {
const graphEpoch = this.captureReadyGraphEpoch()
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
@ -14141,9 +14256,9 @@ export class OrcaRuntimeService {
private async refreshPtyWorktreeRecordsFromController(
resolvedWorktrees: ResolvedWorktree[],
targetWorktreeId: string | null = null
): Promise<void> {
): Promise<Set<string> | null> {
if (!this.ptyController?.listProcesses) {
return
return null
}
const sessionsResult = await withTimeoutResult(
this.ptyController.listProcesses(),
@ -14151,7 +14266,7 @@ export class OrcaRuntimeService {
)
if (!sessionsResult.ok) {
// Why: a transient controller failure is not evidence that retained PTYs exited.
return
return null
}
const sessions = sessionsResult.value
const livePtyIds = new Set(sessions.map((session) => session.id))
@ -14175,6 +14290,7 @@ export class OrcaRuntimeService {
}
}
this.pruneDisconnectedPtyRecords()
return livePtyIds
}
private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void {
@ -14273,6 +14389,7 @@ export class OrcaRuntimeService {
return {
handle: this.issueHandle(leaf),
ptyId: leaf.ptyId,
worktreeId: leaf.worktreeId,
worktreePath: worktree?.path ?? '',
branch: worktree?.branch ?? '',
@ -15299,6 +15416,7 @@ export class OrcaRuntimeService {
return {
handle: this.issuePtyHandle(pty),
ptyId: pty.ptyId,
worktreeId: pty.worktreeId,
worktreePath: worktree?.path ?? '',
branch: worktree?.branch ?? '',
@ -19218,6 +19336,18 @@ function inferWorktreeIdFromPtyId(ptyId: string): string | null {
return parsePtySessionId(ptyId).worktreeId
}
function setsEqual<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
if (a.size !== b.size) {
return false
}
for (const value of a) {
if (!b.has(value)) {
return false
}
}
return true
}
function parseRuntimeWorktreeId(
worktreeId: string
): { repoId: string; worktreePath: string } | null {

View File

@ -8,6 +8,7 @@ function makeSummary(
): RuntimeTerminalSummary {
return {
handle,
ptyId: opts.ptyId ?? handle,
worktreeId: opts.worktreeId ?? 'wt_default',
worktreePath: opts.worktreePath ?? '/tmp/wt',
branch: opts.branch ?? 'main',

View File

@ -176,6 +176,7 @@ describe('orchestration RPC methods', () => {
): RuntimeTerminalSummary {
return {
handle,
ptyId: opts.ptyId ?? handle,
worktreeId: opts.worktreeId ?? 'wt_default',
worktreePath: opts.worktreePath ?? '/tmp/wt',
branch: opts.branch ?? 'main',

View File

@ -389,7 +389,8 @@ const TerminalHandle = z.object({
const TerminalListParams = z.object({
worktree: OptionalString,
limit: OptionalFiniteNumber
limit: OptionalFiniteNumber,
requireFreshPtyLiveness: z.boolean().optional()
})
const TerminalResolveActive = z.object({
@ -485,6 +486,11 @@ const TerminalStop = z.object({
worktree: requiredString('Missing worktree selector')
})
const TerminalStopExact = TerminalStop.extend({
expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1),
keepHistory: z.boolean().optional()
})
const AgentTeamsTmuxCompat = z.object({
teamId: requiredString('Missing agent team ID'),
token: requiredString('Missing agent team token'),
@ -617,7 +623,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.list',
params: TerminalListParams,
handler: async (params, { runtime }) => runtime.listTerminals(params.worktree, params.limit)
handler: async (params, { runtime }) =>
runtime.listTerminals(params.worktree, params.limit, {
requireFreshPtyLiveness: params.requireFreshPtyLiveness
})
}),
defineMethod({
name: 'terminal.resolveActive',
@ -747,6 +756,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params: TerminalStop,
handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree)
}),
defineMethod({
name: 'terminal.stopExact',
params: TerminalStopExact,
handler: async (params, { runtime }) =>
runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, {
keepHistory: params.keepHistory
})
}),
defineMethod({
name: 'terminal.resizeForClient',
params: TerminalResizeForClient,

View File

@ -2147,7 +2147,10 @@ describe('OrcaRuntimeRpcServer', () => {
})
expect(listResponse).toMatchObject({
id: 'req_list',
ok: true
ok: true,
result: {
terminals: [expect.objectContaining({ ptyId: 'pty-1' })]
}
})
const handle = (

View File

@ -5375,6 +5375,83 @@ describe('connectPanePty', () => {
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:terminal-1')
})
it('cold-spawns slept remote runtime PTYs instead of reattaching the preserved handle', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment('env-1')
const restoredPtyId = 'remote:env-1@@terminal-1'
const freshPtyId = 'remote:env-1@@terminal-2'
const transport = createMockTransport(freshPtyId)
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
transport.connect.mockImplementation(
async ({ callbacks, sessionId }: Record<string, unknown>) => {
capturedDataCallback.current = (callbacks as ConnectCallbacks | undefined)?.onData ?? null
if (sessionId) {
throw new Error('slept remote runtime PTYs must not reattach by sessionId')
}
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
| ((ptyId: string) => void)
| undefined
onPtySpawn?.(freshPtyId)
return freshPtyId
}
)
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId }]
},
ptyIdsByTabId: {
'tab-1': []
},
settings: {
...mockStoreState.settings,
activeRuntimeEnvironmentId: 'env-1',
agentCmdOverrides: {}
},
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
}
} as StoreState
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: restoredPtyId }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
capturedDataCallback.current?.('shell ready\r\n')
await new Promise((resolve) => setTimeout(resolve, 70))
expect(transport.attach).not.toHaveBeenCalled()
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(transport.connect).toHaveBeenCalledWith(
expect.not.objectContaining({ sessionId: expect.any(String) })
)
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, null)
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', restoredPtyId)
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, freshPtyId)
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', freshPtyId)
expect(transport.sendInput).toHaveBeenCalledWith(
"codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'\r"
)
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
})
it('constructs restored encoded remote PTYs with their owning runtime environment', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')

View File

@ -3378,10 +3378,15 @@ export function connectPanePty(
const existingPtyId = storeSnapshot.tabsByWorktree[deps.worktreeId]?.find(
(t) => t.id === deps.tabId
)?.ptyId
const hasSleepingAgentSession = Boolean(storeSnapshot.sleepingAgentSessionsByPaneKey[cacheKey])
const restoredSessionId = restoredPtyId ?? null
const sleptRemoteRuntimeSessionId =
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && hasSleepingAgentSession
? restoredSessionId
: null
const detachedLivePtyId =
existingPtyId && !hadExistingPaneTransportAtConnect
existingPtyId && !hadExistingPaneTransportAtConnect && !sleptRemoteRuntimeSessionId
? restoredSessionId
? restoredSessionId === existingPtyId
? restoredSessionId
@ -3389,11 +3394,18 @@ export function connectPanePty(
: existingPtyId
: null
const detachedRemoteLeafPtyId =
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) ? restoredSessionId : null
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && !hasSleepingAgentSession
? restoredSessionId
: null
const candidateReattachSessionId =
restoredSessionId && restoredSessionId !== detachedLivePtyId
? restoredSessionId
: detachedLivePtyId
if (sleptRemoteRuntimeSessionId) {
deps.syncPanePtyLayoutBinding(pane.id, null)
deps.clearTabPtyId(deps.tabId, sleptRemoteRuntimeSessionId)
prepareColdRestoreAgentResumeCommand()
}
const currentTabLivePtyIds = storeSnapshot.ptyIdsByTabId[deps.tabId] ?? []
const candidateHasEagerBuffer = Boolean(
candidateReattachSessionId &&

View File

@ -459,6 +459,42 @@ describe('dispatchTerminalNotification', () => {
expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled()
})
it('drops final-flush notifications for suppressed live ptys', () => {
mockState.suppressedPtyExitIds = { 'pty-1': true }
dispatchTerminalNotification('wt-primary', {
source: 'terminal-bell',
terminalTitle: 'codex',
paneKey
})
expect(window.api.notifications.dispatch).not.toHaveBeenCalled()
expect(mockState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockState.markTerminalTabUnread).not.toHaveBeenCalled()
expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled()
})
it('drops layout-fallback notifications when all tab PTYs are suppressed', () => {
mockState.suppressedPtyExitIds = { 'pty-1': true }
mockState.terminalLayoutsByTabId['tab-1'] = {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: {}
}
dispatchTerminalNotification('wt-primary', {
source: 'terminal-bell',
terminalTitle: 'codex',
paneKey
})
expect(window.api.notifications.dispatch).not.toHaveBeenCalled()
expect(mockState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockState.markTerminalTabUnread).not.toHaveBeenCalled()
expect(mockState.markTerminalPaneUnread).not.toHaveBeenCalled()
})
it('still drops stale notifications when neither pty liveness nor fresh hook status exists', () => {
mockState.ptyIdsByTabId = {}
mockState.agentStatusByPaneKey[paneKey] = {

View File

@ -27,7 +27,9 @@ export type TerminalNotificationEvent = {
function hasLivePtyForWorktree(state: StoreSnapshot, candidateWorktreeId: string): boolean {
const tabs = state.tabsByWorktree[candidateWorktreeId] ?? []
return tabs.some((tab) => (state.ptyIdsByTabId[tab.id] ?? []).length > 0)
return tabs.some((tab) =>
(state.ptyIdsByTabId[tab.id] ?? []).some((ptyId) => !isSuppressedPtyHint(state, ptyId))
)
}
function hasLivePtyForPaneKey(state: StoreSnapshot, paneKey: string | undefined): boolean {
@ -35,7 +37,10 @@ function hasLivePtyForPaneKey(state: StoreSnapshot, paneKey: string | undefined)
return false
}
const tabId = getPaneKeyTabId(paneKey)
return tabId !== null && (state.ptyIdsByTabId[tabId] ?? []).length > 0
return (
tabId !== null &&
(state.ptyIdsByTabId[tabId] ?? []).some((ptyId) => !isSuppressedPtyHint(state, ptyId))
)
}
function hasLivePtyForNotification(
@ -76,7 +81,9 @@ function isCurrentLivePaneKey(state: StoreSnapshot, worktreeId: string, paneKey:
return false
}
const livePtyIds = state.ptyIdsByTabId[parsed.tabId] ?? []
const livePtyIds = (state.ptyIdsByTabId[parsed.tabId] ?? []).filter(
(ptyId) => !isSuppressedPtyHint(state, ptyId)
)
if (livePtyIds.length === 0) {
return false
}

View File

@ -16,10 +16,23 @@ import {
recordAgentHibernationPaneOutput,
resetAgentHibernationOutputActivityForTests
} from './agent-hibernation-output-activity'
import { createCompatibleRuntimeStatusResponseIfNeeded } from '../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../runtime/runtime-rpc-client'
import type { AppState } from '@/store/types'
const NOW = 10_000_000
const LEAF = '11111111-1111-4111-8111-111111111111'
const mockRuntimeEnvironmentCall = vi.fn()
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: mockRuntimeEnvironmentCall
}
}
})
function tab(): TerminalTab {
return {
id: 'tab-1',
@ -58,7 +71,8 @@ function entry(): AgentStatusEntry {
}
function installEligibleState(
shutdownWorktreeTerminals = vi.fn()
shutdownWorktreeTerminals = vi.fn(),
overrides: Partial<AppState> = {}
): typeof shutdownWorktreeTerminals {
const e = entry()
useAppStore.setState({
@ -73,16 +87,84 @@ function installEligibleState(
agentStatusByPaneKey: { [e.paneKey]: e },
sleepingAgentSessionsByPaneKey: {},
lastTerminalInputAtByPaneKey: {},
shutdownWorktreeTerminals: shutdownWorktreeTerminals as never
shutdownWorktreeTerminals: shutdownWorktreeTerminals as never,
...overrides
})
return shutdownWorktreeTerminals
}
function runtimeListResult(ptyIds: string[], truncated = false) {
return {
terminals: ptyIds.map((ptyId) => ({
handle: `handle-${ptyId}`,
ptyId,
worktreeId: 'wt-bg',
worktreePath: '/tmp/wt-bg',
branch: 'feature',
tabId: `pty:${ptyId}`,
leafId: `pty:${ptyId}`,
title: 'Agent',
connected: true,
writable: true,
lastOutputAt: null,
preview: ''
})),
totalCount: ptyIds.length,
truncated
}
}
function installRuntimeListResponses(
...responses: (ReturnType<typeof runtimeListResult> | Error)[]
): void {
const queue = [...responses]
mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => {
const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args)
if (compatible) {
return Promise.resolve(compatible)
}
if (args.method === 'terminal.list') {
const response = queue.shift() ?? runtimeListResult(['pty-1'])
if (response instanceof Error) {
return Promise.reject(response)
}
return Promise.resolve({
id: 'terminal-list',
ok: true,
result: response,
_meta: { runtimeId: 'runtime-1' }
})
}
return Promise.resolve({
id: 'default',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
})
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: Error) => void
} {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
afterEach(() => {
resetAgentHibernationCoordinatorForTests()
clearRuntimeCompatibilityCacheForTests()
resetForegroundTerminalWorktreeIdsForTests()
resetAgentHibernationOutputActivityForTests()
hydrateDrivers([])
mockRuntimeEnvironmentCall.mockReset()
vi.useRealTimers()
})
@ -213,4 +295,190 @@ describe('agent hibernation coordinator', () => {
expect(shutdown).not.toHaveBeenCalled()
})
it('hibernates a runtime-backed candidate with fresh liveness and exact PTYs', async () => {
vi.useFakeTimers()
installRuntimeListResponses(
runtimeListResult(['pty-1']),
runtimeListResult(['pty-1']),
runtimeListResult(['pty-1'])
)
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
settings: {
experimentalAgentHibernation: true,
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS,
activeRuntimeEnvironmentId: 'runtime-1'
} as never,
ptyIdsByTabId: { 'tab-1': [] }
})
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
keepIdentifiers: true,
sleepingPaneKeys: [`tab-1:${LEAF}`],
expectedRuntimePtyIds: ['pty-1']
})
expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
method: 'terminal.list',
params: expect.objectContaining({ requireFreshPtyLiveness: true })
})
)
})
it('requires fresh runtime liveness for confirmation and pre-shutdown recheck', async () => {
vi.useFakeTimers()
installRuntimeListResponses(
runtimeListResult(['pty-1']),
runtimeListResult(['pty-1']),
runtimeListResult(['pty-1', 'pty-shell'])
)
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
settings: {
experimentalAgentHibernation: true,
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS,
activeRuntimeEnvironmentId: 'runtime-1'
} as never,
ptyIdsByTabId: { 'tab-1': [] }
})
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).not.toHaveBeenCalled()
expect(
mockRuntimeEnvironmentCall.mock.calls.filter(([args]) => args.method === 'terminal.list')
).toHaveLength(3)
})
it('uses fresh store state after awaiting runtime liveness before shutdown', async () => {
vi.useFakeTimers()
const delayed = deferred<ReturnType<typeof runtimeListResult>>()
const responses: (
| ReturnType<typeof runtimeListResult>
| Promise<ReturnType<typeof runtimeListResult>>
)[] = [runtimeListResult(['pty-1']), runtimeListResult(['pty-1']), delayed.promise]
mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => {
const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args)
if (compatible) {
return Promise.resolve(compatible)
}
if (args.method === 'terminal.list') {
return Promise.resolve(responses.shift() ?? runtimeListResult(['pty-1'])).then(
(result) => ({
id: 'terminal-list',
ok: true,
result,
_meta: { runtimeId: 'runtime-1' }
})
)
}
return Promise.resolve({
id: 'default',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
})
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
settings: {
experimentalAgentHibernation: true,
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS,
activeRuntimeEnvironmentId: 'runtime-1'
} as never,
ptyIdsByTabId: { 'tab-1': [] }
})
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
useAppStore.setState({ activeWorktreeId: 'wt-bg' })
delayed.resolve(runtimeListResult(['pty-1']))
await Promise.resolve()
expect(shutdown).not.toHaveBeenCalled()
})
it('skips runtime-backed candidates with multiple live PTYs', async () => {
vi.useFakeTimers()
installRuntimeListResponses(
runtimeListResult(['pty-1', 'pty-2']),
runtimeListResult(['pty-1', 'pty-2'])
)
const secondLeaf = '22222222-2222-4222-8222-222222222222'
const e = {
...entry(),
paneKey: `tab-1:${secondLeaf}`,
providerSession: { key: 'session_id' as const, id: 'session-2' }
}
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
settings: {
experimentalAgentHibernation: true,
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS,
activeRuntimeEnvironmentId: 'runtime-1'
} as never,
ptyIdsByTabId: { 'tab-1': [] },
terminalLayoutsByTabId: {
'tab-1': {
...layout(),
ptyIdsByLeafId: { [LEAF]: 'pty-1', [secondLeaf]: 'pty-2' }
}
},
agentStatusByPaneKey: {
[`tab-1:${LEAF}`]: entry(),
[e.paneKey]: e
}
})
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).not.toHaveBeenCalled()
})
it('fails closed on truncated runtime liveness samples', async () => {
vi.useFakeTimers()
installRuntimeListResponses(runtimeListResult(['pty-1'], true), runtimeListResult(['pty-1']))
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
settings: {
experimentalAgentHibernation: true,
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS,
activeRuntimeEnvironmentId: 'runtime-1'
} as never,
ptyIdsByTabId: { 'tab-1': [] }
})
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).not.toHaveBeenCalled()
})
it('fails closed when fresh runtime liveness rejects after an earlier good sample', async () => {
vi.useFakeTimers()
installRuntimeListResponses(runtimeListResult(['pty-1']), new Error('runtime unavailable'))
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
settings: {
experimentalAgentHibernation: true,
agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS,
activeRuntimeEnvironmentId: 'runtime-1'
} as never,
ptyIdsByTabId: { 'tab-1': [] }
})
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(1000)
expect(shutdown).not.toHaveBeenCalled()
expect(
mockRuntimeEnvironmentCall.mock.calls.filter(([args]) => args.method === 'terminal.list')
).toHaveLength(2)
})
})

View File

@ -10,6 +10,13 @@ import type { AppState } from '@/store/types'
import { getAllDrivers } from './pane-manager/mobile-driver-state'
import { getForegroundTerminalWorktreeIds } from './foreground-terminal-worktrees'
import { getAgentHibernationOutputSignature } from './agent-hibernation-output-activity'
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import type {
RuntimeTerminalListResult,
RuntimeTerminalSummary
} from '../../../shared/runtime-types'
export const AGENT_HIBERNATION_TICK_MS = 60 * 1000
@ -23,6 +30,7 @@ type AgentHibernationCoordinatorOptions = {
type AgentHibernationCoordinatorState = {
interval: IntervalHandle | null
confirmationState: AgentHibernationConfirmationState
tickInFlight: boolean
shuttingDownWorktreeIds: Set<string>
now: () => number
}
@ -30,11 +38,21 @@ type AgentHibernationCoordinatorState = {
const coordinator: AgentHibernationCoordinatorState = {
interval: null,
confirmationState: {},
tickInFlight: false,
shuttingDownWorktreeIds: new Set(),
now: () => Date.now()
}
function snapshotFromState(state: AppState, now: number): AgentHibernationPlannerSnapshot {
type RuntimePtyLivenessSample = {
runtimeLivePtyIdsByWorktreeId: Record<string, string[]>
runtimeLivenessRequiredWorktreeIds: string[]
}
function snapshotFromState(
state: AppState,
now: number,
runtimeLiveness: RuntimePtyLivenessSample
): AgentHibernationPlannerSnapshot {
return {
settings: state.settings,
activeWorktreeId: state.activeWorktreeId,
@ -42,6 +60,8 @@ function snapshotFromState(state: AppState, now: number): AgentHibernationPlanne
tabsByWorktree: state.tabsByWorktree,
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
ptyIdsByTabId: state.ptyIdsByTabId,
runtimeLivePtyIdsByWorktreeId: runtimeLiveness.runtimeLivePtyIdsByWorktreeId,
runtimeLivenessRequiredWorktreeIds: runtimeLiveness.runtimeLivenessRequiredWorktreeIds,
mobileLockedPtyIds: [...getAllDrivers()]
.filter(([, driver]) => driver.kind === 'mobile')
.map(([ptyId]) => ptyId),
@ -52,15 +72,84 @@ function snapshotFromState(state: AppState, now: number): AgentHibernationPlanne
}
}
function currentCandidates(now: number) {
return planAgentHibernationCandidates(snapshotFromState(useAppStore.getState(), now)).map(
(candidate) => ({
function getRuntimeLivenessTargetWorktrees(state: AppState): Map<string, string> {
const targets = new Map<string, string>()
for (const worktreeId of Object.keys(state.tabsByWorktree)) {
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
if (runtimeEnvironmentId) {
targets.set(worktreeId, runtimeEnvironmentId)
}
}
return targets
}
function getTypedRuntimePtyId(terminal: RuntimeTerminalSummary): string | null {
if (terminal.ptyId) {
return terminal.ptyId
}
if (terminal.tabId.startsWith('pty:') && terminal.tabId === terminal.leafId) {
return terminal.tabId.slice('pty:'.length) || null
}
return null
}
async function collectRuntimePtyLiveness(state: AppState): Promise<RuntimePtyLivenessSample> {
const targets = getRuntimeLivenessTargetWorktrees(state)
const runtimeLivePtyIdsByWorktreeId: Record<string, string[]> = {}
const runtimeLivenessRequiredWorktreeIds = [...targets.keys()]
await Promise.all(
[...targets].map(async ([worktreeId, runtimeEnvironmentId]) => {
try {
const result = await callRuntimeRpc<RuntimeTerminalListResult>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'terminal.list',
{
worktree: toRuntimeWorktreeSelector(worktreeId),
limit: 10_000,
requireFreshPtyLiveness: true
},
{ timeoutMs: 10_000 }
)
if (result.truncated) {
return
}
const ptyIds = new Set<string>()
for (const terminal of result.terminals) {
if (!terminal.connected || terminal.worktreeId !== worktreeId) {
continue
}
const ptyId = getTypedRuntimePtyId(terminal)
if (ptyId) {
ptyIds.add(ptyId)
}
}
runtimeLivePtyIdsByWorktreeId[worktreeId] = [...ptyIds].sort()
} catch {
// Why: stale runtime liveness is unsafe for all-or-nothing hibernation;
// omitting the worktree makes the planner fail closed for this pass.
}
})
)
return { runtimeLivePtyIdsByWorktreeId, runtimeLivenessRequiredWorktreeIds }
}
async function currentCandidates(now: number) {
const runtimeLiveness = await collectRuntimePtyLiveness(useAppStore.getState())
const freshState = useAppStore.getState()
return planAgentHibernationCandidates(snapshotFromState(freshState, now, runtimeLiveness))
.filter((candidate) => {
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
freshState,
candidate.worktreeId
)
return !runtimeEnvironmentId || candidate.expectedRuntimePtyIds.length === 1
})
.map((candidate) => ({
...candidate,
// Why: terminal output after the first stable tick can mean the session
// is still alive even when agent status remains done; require it to stay quiet.
signature: `${candidate.signature}|output:${getAgentHibernationOutputSignature(candidate.paneKeys)}`
})
)
}))
}
async function hibernateWorktreeIfStillEligible(
@ -70,7 +159,7 @@ async function hibernateWorktreeIfStillEligible(
if (coordinator.shuttingDownWorktreeIds.has(worktreeId)) {
return
}
const candidates = currentCandidates(coordinator.now())
const candidates = await currentCandidates(coordinator.now())
const stillEligible = candidates.some(
(candidate) =>
candidate.worktreeId === worktreeId && candidate.signature === confirmedCandidate.signature
@ -80,9 +169,14 @@ async function hibernateWorktreeIfStillEligible(
}
coordinator.shuttingDownWorktreeIds.add(worktreeId)
try {
await useAppStore.getState().shutdownWorktreeTerminals(worktreeId, {
const state = useAppStore.getState()
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
await state.shutdownWorktreeTerminals(worktreeId, {
keepIdentifiers: true,
sleepingPaneKeys: confirmedCandidate.paneKeys
sleepingPaneKeys: confirmedCandidate.paneKeys,
...(runtimeEnvironmentId
? { expectedRuntimePtyIds: confirmedCandidate.expectedRuntimePtyIds }
: {})
})
} catch (err) {
console.warn('[agent-hibernation] failed to hibernate worktree:', worktreeId, err)
@ -91,14 +185,22 @@ async function hibernateWorktreeIfStillEligible(
}
}
export function runAgentHibernationTick(): void {
const plan = confirmAgentHibernationCandidates(
coordinator.confirmationState,
currentCandidates(coordinator.now())
)
coordinator.confirmationState = plan.confirmationState
for (const candidate of plan.candidates) {
void hibernateWorktreeIfStillEligible(candidate)
export async function runAgentHibernationTick(): Promise<void> {
if (coordinator.tickInFlight) {
return
}
coordinator.tickInFlight = true
try {
const plan = confirmAgentHibernationCandidates(
coordinator.confirmationState,
await currentCandidates(coordinator.now())
)
coordinator.confirmationState = plan.confirmationState
for (const candidate of plan.candidates) {
void hibernateWorktreeIfStillEligible(candidate)
}
} finally {
coordinator.tickInFlight = false
}
}
@ -110,7 +212,7 @@ export function startAgentHibernationCoordinator(
}
coordinator.now = options.now ?? (() => Date.now())
const intervalMs = options.intervalMs ?? AGENT_HIBERNATION_TICK_MS
coordinator.interval = setInterval(runAgentHibernationTick, intervalMs)
coordinator.interval = setInterval(() => void runAgentHibernationTick(), intervalMs)
return stopAgentHibernationCoordinator
}
@ -129,5 +231,6 @@ export function isAgentHibernationCoordinatorRunning(): boolean {
export function resetAgentHibernationCoordinatorForTests(): void {
stopAgentHibernationCoordinator()
coordinator.shuttingDownWorktreeIds.clear()
coordinator.tickInFlight = false
coordinator.now = () => Date.now()
}

View File

@ -144,6 +144,107 @@ describe('agent hibernation planner', () => {
expect(plannedWorktrees(snapshot({ mobileLockedPtyIds: ['pty-1'] }))).toEqual([])
})
it('selects runtime-backed live PTYs when the renderer live map is empty', () => {
const [candidate] = planAgentHibernationCandidates(
snapshot({
ptyIdsByTabId: { 'tab-1': [] },
runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1'] },
runtimeLivenessRequiredWorktreeIds: ['wt-bg']
})
)
expect(candidate).toMatchObject({
worktreeId: 'wt-bg',
paneKeys: [`tab-1:${LEAF}`],
expectedRuntimePtyIds: ['pty-1']
})
})
it('matches wrapped remote renderer PTY IDs to raw runtime PTY IDs', () => {
const [candidate] = planAgentHibernationCandidates(
snapshot({
terminalLayoutsByTabId: { 'tab-1': layout(LEAF, 'remote:env-1@@terminal-1') },
ptyIdsByTabId: { 'tab-1': ['remote:env-1@@terminal-1'] },
runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['terminal-1'] },
runtimeLivenessRequiredWorktreeIds: ['wt-bg']
})
)
expect(candidate).toMatchObject({
worktreeId: 'wt-bg',
paneKeys: [`tab-1:${LEAF}`],
expectedRuntimePtyIds: ['terminal-1']
})
})
it('does not select layout-only stale PTYs without runtime liveness', () => {
expect(
plannedWorktrees(
snapshot({
ptyIdsByTabId: { 'tab-1': [] },
runtimeLivePtyIdsByWorktreeId: { 'wt-bg': [] },
runtimeLivenessRequiredWorktreeIds: ['wt-bg']
})
)
).toEqual([])
expect(
plannedWorktrees(
snapshot({
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
runtimeLivePtyIdsByWorktreeId: { 'wt-bg': [] },
runtimeLivenessRequiredWorktreeIds: ['wt-bg']
})
)
).toEqual([])
expect(
plannedWorktrees(
snapshot({
ptyIdsByTabId: { 'tab-1': [] },
runtimeLivenessRequiredWorktreeIds: ['wt-bg']
})
)
).toEqual([])
})
it('rejects runtime-backed worktrees with extra unknown live PTYs', () => {
expect(
plannedWorktrees(
snapshot({
ptyIdsByTabId: { 'tab-1': [] },
runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1', 'pty-shell'] },
runtimeLivenessRequiredWorktreeIds: ['wt-bg']
})
)
).toEqual([])
})
it('applies mobile locks to runtime-backed PTYs', () => {
expect(
plannedWorktrees(
snapshot({
ptyIdsByTabId: { 'tab-1': [] },
runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1'] },
runtimeLivenessRequiredWorktreeIds: ['wt-bg'],
mobileLockedPtyIds: ['pty-1']
})
)
).toEqual([])
})
it('applies mobile locks across wrapped remote and raw runtime PTY IDs', () => {
expect(
plannedWorktrees(
snapshot({
terminalLayoutsByTabId: { 'tab-1': layout(LEAF, 'remote:env-1@@terminal-1') },
ptyIdsByTabId: { 'tab-1': ['remote:env-1@@terminal-1'] },
runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['terminal-1'] },
runtimeLivenessRequiredWorktreeIds: ['wt-bg'],
mobileLockedPtyIds: ['remote:env-1@@terminal-1']
})
)
).toEqual([])
})
it('selects a worktree when all live PTYs are eligible done agents', () => {
expect(plannedWorktrees(snapshot())).toEqual(['wt-bg'])
const second = entry({

View File

@ -6,6 +6,7 @@ import {
} from '../../../shared/agent-session-resume'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { GlobalSettings, TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types'
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
export const DEFAULT_AGENT_HIBERNATION_IDLE_MS = 30 * 60 * 1000
export const MIN_AGENT_HIBERNATION_IDLE_MS = 60 * 1000
@ -18,6 +19,8 @@ export type AgentHibernationPlannerSnapshot = {
tabsByWorktree: Record<string, TerminalTab[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
ptyIdsByTabId: Record<string, string[] | undefined>
runtimeLivePtyIdsByWorktreeId?: Record<string, string[] | undefined>
runtimeLivenessRequiredWorktreeIds?: string[]
mobileLockedPtyIds: string[]
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord | undefined>
@ -28,6 +31,7 @@ export type AgentHibernationPlannerSnapshot = {
export type AgentHibernationCandidate = {
worktreeId: string
paneKeys: string[]
expectedRuntimePtyIds: string[]
signature: string
}
@ -41,12 +45,17 @@ export type AgentHibernationPlan = {
type EligiblePane = {
paneKey: string
ptyId: string
runtimePtyId: string
providerSessionId: string
state: AgentStatusEntry['state']
updatedAt: number
inputAt: number
}
function toRuntimePtyId(ptyId: string): string {
return parseRemoteRuntimePtyId(ptyId)?.handle ?? ptyId
}
export function getEffectiveAgentHibernationIdleMs(value: unknown): number {
return typeof value === 'number' &&
Number.isFinite(value) &&
@ -58,10 +67,24 @@ export function getEffectiveAgentHibernationIdleMs(value: unknown): number {
function getLivePtyIdsForTab(
tab: TerminalTab,
ptyIdsByTabId: Record<string, string[] | undefined>
ptyIdsByTabId: Record<string, string[] | undefined>,
runtimeLivePtyIdsByWorktreeId: Record<string, string[] | undefined> | undefined,
runtimeLivenessRequired: boolean
): string[] {
const ids = ptyIdsByTabId[tab.id] ?? []
return ids.filter((id): id is string => typeof id === 'string' && id.length > 0)
const ids = new Set<string>()
for (const id of runtimeLivePtyIdsByWorktreeId?.[tab.worktreeId] ?? []) {
if (typeof id === 'string' && id.length > 0) {
ids.add(toRuntimePtyId(id))
}
}
if (!runtimeLivenessRequired) {
for (const id of ptyIdsByTabId[tab.id] ?? []) {
if (typeof id === 'string' && id.length > 0) {
ids.add(toRuntimePtyId(id))
}
}
}
return [...ids]
}
function getPaneLivePtyId(
@ -123,12 +146,17 @@ function getEligiblePane(args: {
return null
}
const ptyId = getPaneLivePtyId(entry, layout)
if (!ptyId || !livePtyIds.has(ptyId)) {
if (!ptyId) {
return null
}
const runtimePtyId = toRuntimePtyId(ptyId)
if (!livePtyIds.has(runtimePtyId)) {
return null
}
return {
paneKey: entry.paneKey,
ptyId,
runtimePtyId,
providerSessionId: entry.providerSession.id,
state: entry.state,
updatedAt: entry.updatedAt,
@ -142,7 +170,7 @@ function signatureFor(worktreeId: string, panes: EligiblePane[]): string {
.sort((a, b) => a.paneKey.localeCompare(b.paneKey))
.map(
(pane) =>
`${pane.paneKey}:${pane.ptyId}:${pane.providerSessionId}:${pane.state}:${pane.updatedAt}:${pane.inputAt}`
`${pane.paneKey}:${pane.ptyId}:${pane.runtimePtyId}:${pane.providerSessionId}:${pane.state}:${pane.updatedAt}:${pane.inputAt}`
)
return `${worktreeId}|${parts.join('|')}`
}
@ -176,8 +204,11 @@ export function planAgentHibernationCandidates(
return []
}
const idleMs = getEffectiveAgentHibernationIdleMs(snapshot.settings.agentHibernationIdleMs)
const mobileLockedPtyIds = new Set(snapshot.mobileLockedPtyIds)
const mobileLockedPtyIds = new Set(snapshot.mobileLockedPtyIds.map(toRuntimePtyId))
const foregroundWorktreeIds = new Set(snapshot.foregroundWorktreeIds)
const runtimeLivenessRequiredWorktreeIds = new Set(
snapshot.runtimeLivenessRequiredWorktreeIds ?? []
)
const agentEntriesByTabId = getAgentEntriesByTabId(snapshot.agentStatusByPaneKey)
const candidates: AgentHibernationCandidate[] = []
for (const [worktreeId, tabs] of Object.entries(snapshot.tabsByWorktree)) {
@ -189,11 +220,25 @@ export function planAgentHibernationCandidates(
) {
continue
}
if (
runtimeLivenessRequiredWorktreeIds.has(worktreeId) &&
!Object.prototype.hasOwnProperty.call(
snapshot.runtimeLivePtyIdsByWorktreeId ?? {},
worktreeId
)
) {
continue
}
const livePtyIds = new Set<string>()
const eligibleByPtyId = new Map<string, EligiblePane>()
let rejected = false
for (const tab of tabs) {
const tabLivePtyIds = getLivePtyIdsForTab(tab, snapshot.ptyIdsByTabId)
const tabLivePtyIds = getLivePtyIdsForTab(
tab,
snapshot.ptyIdsByTabId,
snapshot.runtimeLivePtyIdsByWorktreeId,
runtimeLivenessRequiredWorktreeIds.has(worktreeId)
)
for (const ptyId of tabLivePtyIds) {
livePtyIds.add(ptyId)
}
@ -216,7 +261,7 @@ export function planAgentHibernationCandidates(
idleMs
})
if (eligible) {
eligibleByPtyId.set(eligible.ptyId, eligible)
eligibleByPtyId.set(eligible.runtimePtyId, eligible)
} else if (entry.state !== 'done' || getPaneLivePtyId(entry, layout)) {
rejected = true
}
@ -229,6 +274,7 @@ export function planAgentHibernationCandidates(
candidates.push({
worktreeId,
paneKeys: panes.map((pane) => pane.paneKey).sort(),
expectedRuntimePtyIds: [...livePtyIds].sort(),
signature: signatureFor(worktreeId, panes)
})
}

View File

@ -222,6 +222,58 @@ function sleepingRecordFromEntry(args: {
}
}
export function collectSleepingAgentSessionRecordsForWorktree(
state: AppState,
worktreeId: string,
paneKeys?: string[]
): Record<string, SleepingAgentSessionRecord> {
const capturedAt = Date.now()
const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null
const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`)
const records: Record<string, SleepingAgentSessionRecord> = {}
for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) {
continue
}
if (retained.worktreeId !== worktreeId) {
continue
}
const record = sleepingRecordFromEntry({
state,
entry: retained.entry,
worktreeId,
tab: retained.tab,
capturedAt
})
if (record) {
records[record.paneKey] = record
}
}
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) {
continue
}
const belongsToWorktree =
entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)
if (!belongsToWorktree) {
continue
}
const record = sleepingRecordFromEntry({
state,
entry,
worktreeId,
capturedAt
})
if (record) {
records[record.paneKey] = record
}
}
return records
}
function pruneMigrationUnsupportedEntries(
entries: Record<string, MigrationUnsupportedPtyEntry>,
predicate: (entry: MigrationUnsupportedPtyEntry) => boolean
@ -1017,50 +1069,14 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
captureSleepingAgentSessionsByWorktree: (worktreeId, paneKeys) => {
set((s) => {
const capturedAt = Date.now()
const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null
const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`)
const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, paneKeys)
const next: Record<string, SleepingAgentSessionRecord> = {
...s.sleepingAgentSessionsByPaneKey
}
let changed = false
for (const retained of Object.values(s.retainedAgentsByPaneKey)) {
if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) {
continue
}
if (retained.worktreeId !== worktreeId) {
continue
}
const record = sleepingRecordFromEntry({
state: s,
entry: retained.entry,
worktreeId,
tab: retained.tab,
capturedAt
})
if (record && next[record.paneKey] !== record) {
next[record.paneKey] = record
changed = true
}
}
for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) {
if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) {
continue
}
const belongsToWorktree =
entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)
if (!belongsToWorktree) {
continue
}
const record = sleepingRecordFromEntry({
state: s,
entry,
worktreeId,
capturedAt
})
if (record && next[record.paneKey] !== record) {
for (const record of Object.values(records)) {
if (next[record.paneKey] !== record) {
next[record.paneKey] = record
changed = true
}

View File

@ -7,11 +7,17 @@ import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/run
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { toast } from 'sonner'
const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn())
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers
}))
// Mock agent-status (imported by terminal-helpers)
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
@ -2655,6 +2661,483 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
)
})
it('commits sleep state after exact runtime stop for runtime-backed PTYs', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc-default',
ok: true,
result:
args.method === 'terminal.stopExact'
? { stoppedPtyIds: ['pty-1'], livePtyIds: ['pty-1'], postStopVerified: true }
: {},
_meta: { runtimeId: 'remote-runtime' }
}
)
)
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
ptyIdsByTabId: { 'tab-1': [] }
})
store.getState().setAgentStatus(
'tab-1:live',
{
state: 'done',
prompt: 'resume live',
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'live-session' } }
)
await store.getState().shutdownWorktreeTerminals(wt, {
keepIdentifiers: true,
sleepingPaneKeys: ['tab-1:live'],
expectedRuntimePtyIds: ['pty-1']
})
expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'runtime-1',
method: 'terminal.stopExact',
params: expect.objectContaining({ expectedPtyIds: ['pty-1'], keepHistory: true })
})
)
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
providerSession: { key: 'session_id', id: 'live-session' }
})
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeUndefined()
expect(mockApi.pty.kill).not.toHaveBeenCalled()
})
it('does not commit sleep state when exact runtime stop post-check is inconclusive', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc-default',
ok: true,
result:
args.method === 'terminal.stopExact'
? {
stoppedPtyIds: ['pty-1'],
livePtyIds: ['pty-1'],
postStopVerified: false,
postStopFailure: 'terminal_liveness_unavailable'
}
: {},
_meta: { runtimeId: 'remote-runtime' }
}
)
)
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
ptyIdsByTabId: { 'tab-1': [] }
})
store.getState().setAgentStatus(
'tab-1:live',
{
state: 'done',
prompt: 'resume live',
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'live-session' } }
)
await expect(
store.getState().shutdownWorktreeTerminals(wt, {
keepIdentifiers: true,
sleepingPaneKeys: ['tab-1:live'],
expectedRuntimePtyIds: ['pty-1']
})
).rejects.toThrow('terminal_liveness_unavailable')
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined()
expect(mockApi.pty.kill).not.toHaveBeenCalled()
})
it('does not commit sleep state when exact runtime stop omits post-check proof', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc-default',
ok: true,
result:
args.method === 'terminal.stopExact'
? { stoppedPtyIds: ['pty-1'], livePtyIds: ['pty-1'] }
: {},
_meta: { runtimeId: 'remote-runtime' }
}
)
)
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
ptyIdsByTabId: { 'tab-1': [] }
})
store.getState().setAgentStatus(
'tab-1:live',
{
state: 'done',
prompt: 'resume live',
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'live-session' } }
)
await expect(
store.getState().shutdownWorktreeTerminals(wt, {
keepIdentifiers: true,
sleepingPaneKeys: ['tab-1:live'],
expectedRuntimePtyIds: ['pty-1']
})
).rejects.toThrow('exact_terminal_stop_unverified')
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined()
expect(mockApi.pty.kill).not.toHaveBeenCalled()
})
it('clears exact-stop exit suppression when a slept PTY ID wakes live again', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc-default',
ok: true,
result:
args.method === 'terminal.stopExact'
? { stoppedPtyIds: ['pty-1'], livePtyIds: ['pty-1'], postStopVerified: true }
: {},
_meta: { runtimeId: 'remote-runtime' }
}
)
)
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
ptyIdsByTabId: { 'tab-1': [] }
})
store.getState().setAgentStatus(
'tab-1:live',
{
state: 'done',
prompt: 'resume live',
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'live-session' } }
)
await store.getState().shutdownWorktreeTerminals(wt, {
keepIdentifiers: true,
sleepingPaneKeys: ['tab-1:live'],
expectedRuntimePtyIds: ['pty-1']
})
expect(store.getState().suppressedPtyExitIds['pty-1']).toBe(true)
store.getState().updateTabPtyId('tab-1', 'pty-1')
expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined()
})
it('suppresses wrapped remote PTY exits before exact runtime stop resolves', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
let sawWrappedSuppressedDuringStop = false
let sawRawSuppressedDuringStop = false
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => {
const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args)
if (compatible) {
return Promise.resolve(compatible)
}
if (args.method === 'terminal.stopExact') {
sawWrappedSuppressedDuringStop = store
.getState()
.consumeSuppressedPtyExit('remote:env-1@@terminal-1')
sawRawSuppressedDuringStop = store.getState().consumeSuppressedPtyExit('terminal-1')
return Promise.resolve({
id: 'rpc-default',
ok: true,
result: {
stoppedPtyIds: ['terminal-1'],
livePtyIds: ['terminal-1'],
postStopVerified: true
},
_meta: { runtimeId: 'remote-runtime' }
})
}
return Promise.resolve({
id: 'rpc-default',
ok: true,
result: {},
_meta: { runtimeId: 'remote-runtime' }
})
})
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
ptyIdsByTabId: { 'tab-1': ['remote:env-1@@terminal-1'] }
})
store.getState().setAgentStatus(
'tab-1:live',
{
state: 'done',
prompt: 'resume live',
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'live-session' } }
)
await store.getState().shutdownWorktreeTerminals(wt, {
keepIdentifiers: true,
sleepingPaneKeys: ['tab-1:live'],
expectedRuntimePtyIds: ['terminal-1']
})
expect(sawWrappedSuppressedDuringStop).toBe(true)
expect(sawRawSuppressedDuringStop).toBe(true)
})
it('clears raw and wrapped remote exit suppression when a remote PTY wakes live again', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
ptyIdsByTabId: { 'tab-1': [] }
})
store.getState().suppressPtyExit('remote:env-1@@terminal-1')
store.getState().suppressPtyExit('terminal-1')
store.getState().updateTabPtyId('tab-1', 'remote:env-1@@terminal-1')
expect(store.getState().suppressedPtyExitIds['remote:env-1@@terminal-1']).toBeUndefined()
expect(store.getState().suppressedPtyExitIds['terminal-1']).toBeUndefined()
})
it('commits the pre-stop sleeping record when exact-stop exit clears live status', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => {
if (args.method === 'terminal.stopExact') {
store.getState().removeAgentStatus('tab-1:live')
return Promise.resolve({
id: 'rpc-default',
ok: true,
result: { stoppedPtyIds: ['pty-1'], livePtyIds: ['pty-1'], postStopVerified: true },
_meta: { runtimeId: 'remote-runtime' }
})
}
return Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc-default',
ok: true,
result: {},
_meta: { runtimeId: 'remote-runtime' }
}
)
})
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
ptyIdsByTabId: { 'tab-1': [] }
})
store.getState().setAgentStatus(
'tab-1:live',
{
state: 'done',
prompt: 'resume live',
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'live-session' } }
)
await store.getState().shutdownWorktreeTerminals(wt, {
keepIdentifiers: true,
sleepingPaneKeys: ['tab-1:live'],
expectedRuntimePtyIds: ['pty-1']
})
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
providerSession: { key: 'session_id', id: 'live-session' }
})
})
it('does not commit sleep state when exact runtime stop fails', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => {
const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args)
if (compatible) {
return Promise.resolve(compatible)
}
if (args.method === 'terminal.stopExact') {
return Promise.reject(new Error('stop failed'))
}
return Promise.resolve({
id: 'rpc-default',
ok: true,
result: {},
_meta: { runtimeId: 'remote-runtime' }
})
})
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
ptyIdsByTabId: { 'tab-1': [] }
})
store.getState().setAgentStatus(
'tab-1:live',
{
state: 'done',
prompt: 'resume live',
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'live-session' } }
)
await expect(
store.getState().shutdownWorktreeTerminals(wt, {
keepIdentifiers: true,
sleepingPaneKeys: ['tab-1:live'],
expectedRuntimePtyIds: ['pty-1']
})
).rejects.toThrow('stop failed')
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
expect(mockUnregisterPtyDataHandlers).not.toHaveBeenCalledWith(['pty-1'])
expect(mockApi.pty.kill).not.toHaveBeenCalled()
})
it('does not commit sleep state when exact runtime stop returns the wrong set', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
id: 'rpc-default',
ok: true,
result:
args.method === 'terminal.stopExact'
? {
stoppedPtyIds: ['pty-1'],
livePtyIds: ['pty-1', 'pty-shell'],
postStopVerified: true
}
: {},
_meta: { runtimeId: 'remote-runtime' }
}
)
)
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' },
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex' })]
},
ptyIdsByTabId: { 'tab-1': [] }
})
store.getState().setAgentStatus('tab-1:live', {
state: 'done',
prompt: 'resume live',
agentType: 'codex'
})
await expect(
store.getState().shutdownWorktreeTerminals(wt, {
keepIdentifiers: true,
sleepingPaneKeys: ['tab-1:live'],
expectedRuntimePtyIds: ['pty-1']
})
).rejects.toThrow('exact_terminal_stop_mismatch')
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
expect(mockApi.pty.kill).not.toHaveBeenCalled()
})
it('drops live agentStatusByPaneKey entries on sleep so the working row disappears', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'

View File

@ -50,6 +50,7 @@ import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status'
function getNextTerminalOrdinal(tabs: TerminalTab[]): number {
const usedOrdinals = new Set<number>()
@ -241,6 +242,18 @@ function resolveTerminalStopRuntimeEnvironmentId(
return getRuntimeEnvironmentIdForWorktree(state, worktreeId)
}
function sortedUniquePtyIds(ptyIds: readonly string[] | undefined): string[] {
return [...new Set((ptyIds ?? []).filter((ptyId) => ptyId.length > 0))].sort()
}
function equalStringSets(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) {
return false
}
const bSet = new Set(b)
return a.every((value) => bSet.has(value))
}
export type TerminalSlice = {
tabsByWorktree: Record<string, TerminalTab[]>
activeTabId: string | null
@ -401,7 +414,11 @@ export type TerminalSlice = {
clearTabPtyId: (tabId: string, ptyId?: string) => void
shutdownWorktreeTerminals: (
worktreeId: string,
opts?: { keepIdentifiers?: boolean; sleepingPaneKeys?: string[] }
opts?: {
keepIdentifiers?: boolean
sleepingPaneKeys?: string[]
expectedRuntimePtyIds?: string[]
}
) => Promise<void>
suppressPtyExit: (ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
@ -1518,6 +1535,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const isFirstPty = existingPtyIds.length === 0
const isActiveWorktree = worktreeId != null && s.activeWorktreeId === worktreeId
const shouldBumpSortEpoch = isFirstPty && isActiveWorktree && !wasActivationSpawn
const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds }
delete nextSuppressedPtyExitIds[ptyId]
const remoteRuntimePtyHandle = parseRemoteRuntimePtyId(ptyId)?.handle
if (remoteRuntimePtyHandle) {
delete nextSuppressedPtyExitIds[remoteRuntimePtyHandle]
}
return {
...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}),
ptyIdsByTabId: {
@ -1528,6 +1551,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
...s.lastKnownRelayPtyIdByTabId,
[tabId]: ptyId
},
suppressedPtyExitIds: nextSuppressedPtyExitIds,
...(shouldBumpSortEpoch ? { sortEpoch: s.sortEpoch + 1 } : {})
}
})
@ -1639,6 +1663,11 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const keepIdentifiers = opts?.keepIdentifiers ?? false
const tabs = get().tabsByWorktree[worktreeId] ?? []
const ptyIds = tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? [])
const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds)
const shutdownPtyIds = sortedUniquePtyIds([...ptyIds, ...expectedRuntimePtyIds])
const sleepingAgentSessionRecords = keepIdentifiers
? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, opts?.sleepingPaneKeys)
: {}
// Why: the main process flushes any remaining batched PTY data before
// sending the exit event (pty.ts onExit handler). Without this, that
@ -1647,7 +1676,9 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// notifications for a worktree that is already being torn down —
// the "phantom alerts" users see after shutting down worktrees.
// Removing the data handlers first ensures the final flush is a no-op.
unregisterPtyDataHandlers(ptyIds)
if (expectedRuntimePtyIds.length === 0) {
unregisterPtyDataHandlers(shutdownPtyIds)
}
// Why (ordering invariant — DESIGN_DOC §3.3.c): on sleep, capture every
// pane's serializer buffer into terminalLayoutsByTabId[tab].buffersByLeafId
@ -1673,6 +1704,76 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
}
const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId)
if (expectedRuntimePtyIds.length > 0) {
if (!runtimeEnvironmentId) {
throw new Error('missing_runtime_for_exact_terminal_stop')
}
set((s) => ({
suppressedPtyExitIds: {
...s.suppressedPtyExitIds,
...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const))
}
}))
let stopResult: {
stoppedPtyIds?: string[]
livePtyIds?: string[]
postStopVerified?: boolean
postStopFailure?: string
remainingLivePtyIds?: string[]
}
try {
stopResult = await callRuntimeRpc<{
stoppedPtyIds?: string[]
livePtyIds?: string[]
}>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'terminal.stopExact',
{
worktree: toRuntimeWorktreeSelector(worktreeId),
expectedPtyIds: expectedRuntimePtyIds,
keepHistory: keepIdentifiers
},
{ timeoutMs: 15_000 }
)
} catch (err) {
set((s) => {
const next = { ...s.suppressedPtyExitIds }
for (const ptyId of shutdownPtyIds) {
delete next[ptyId]
}
return { suppressedPtyExitIds: next }
})
throw err
}
const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds)
const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds)
if (
!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) ||
!equalStringSets(livePtyIds, expectedRuntimePtyIds)
) {
set((s) => {
const next = { ...s.suppressedPtyExitIds }
for (const ptyId of shutdownPtyIds) {
delete next[ptyId]
}
return { suppressedPtyExitIds: next }
})
throw new Error('exact_terminal_stop_mismatch')
}
if (stopResult.postStopVerified !== true) {
set((s) => {
const next = { ...s.suppressedPtyExitIds }
for (const ptyId of shutdownPtyIds) {
delete next[ptyId]
}
return { suppressedPtyExitIds: next }
})
throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified')
}
unregisterPtyDataHandlers(shutdownPtyIds)
}
set((s) => {
const nextTabsByWorktree = keepIdentifiers
? s.tabsByWorktree
@ -1691,7 +1792,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
: { ...s.runtimePaneTitlesByTabId }
const nextSuppressedPtyExitIds = {
...s.suppressedPtyExitIds,
...Object.fromEntries(ptyIds.map((ptyId) => [ptyId, true] as const))
...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const))
}
// Why: pendingCodexPaneRestartIds is keyed by ptyId — under sleep we
// preserve it so a mid-restart marker survives wake against the same
@ -1702,7 +1803,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
? s.pendingCodexPaneRestartIds
: { ...s.pendingCodexPaneRestartIds }
const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId }
for (const ptyId of ptyIds) {
for (const ptyId of shutdownPtyIds) {
if (!keepIdentifiers) {
delete nextPendingCodexPaneRestartIds[ptyId]
}
@ -1827,7 +1928,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
if (keepIdentifiers) {
get().captureSleepingAgentSessionsByWorktree(worktreeId, opts?.sleepingPaneKeys)
set((s) => ({
sleepingAgentSessionsByPaneKey: {
...s.sleepingAgentSessionsByPaneKey,
...sleepingAgentSessionRecords
}
}))
} else {
get().clearSleepingAgentSessionsByWorktree(worktreeId)
}
@ -1839,12 +1945,11 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// their original tab.
get().dropAgentStatusByWorktree(worktreeId)
if (ptyIds.length === 0) {
if (ptyIds.length === 0 && expectedRuntimePtyIds.length === 0) {
return
}
const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId)
if (runtimeEnvironmentId) {
if (runtimeEnvironmentId && expectedRuntimePtyIds.length === 0) {
await callRuntimeRpc(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'terminal.stop',
@ -1855,6 +1960,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
await Promise.allSettled(
ptyIds
.filter((ptyId) => !expectedRuntimePtyIds.includes(ptyId))
.filter((ptyId) => !ptyId.startsWith('remote:'))
.map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers }))
)

View File

@ -318,6 +318,7 @@ export type RuntimeFilePreviewResult = {
export type RuntimeTerminalSummary = {
handle: string
ptyId: string | null
worktreeId: string
worktreePath: string
branch: string