Hibernate completed runtime-owned agents (#5473)
This commit is contained in:
parent
5599ad28cb
commit
ee8327dde9
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ export type RuntimeFilePreviewResult = {
|
|||
|
||||
export type RuntimeTerminalSummary = {
|
||||
handle: string
|
||||
ptyId: string | null
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
branch: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue