Keep completed-agent hibernation pane-scoped (#5749)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
fc17631af3
commit
8c4e99ed2f
|
|
@ -12949,6 +12949,80 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(stopped).toEqual([])
|
||||
})
|
||||
|
||||
it('allows target-only exact terminal stop when sibling PTYs remain live', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const stopped: string[] = []
|
||||
const processLists = [
|
||||
[
|
||||
{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' },
|
||||
{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' }
|
||||
],
|
||||
[{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' }]
|
||||
]
|
||||
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: TEST_WORKTREE_ID,
|
||||
title: 'Claude',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
},
|
||||
{
|
||||
tabId: 'tab-2',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Shell',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
},
|
||||
{
|
||||
tabId: 'tab-2',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 2,
|
||||
ptyId: 'pty-shell'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.stopExactTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`, ['pty-1'], {
|
||||
keepHistory: true,
|
||||
targetOnly: true
|
||||
})
|
||||
).resolves.toEqual({
|
||||
stopped: 1,
|
||||
stoppedPtyIds: ['pty-1'],
|
||||
livePtyIds: ['pty-1', 'pty-shell'],
|
||||
postStopVerified: true,
|
||||
remainingLivePtyIds: ['pty-shell']
|
||||
})
|
||||
expect(stopped).toEqual(['pty-1'])
|
||||
})
|
||||
|
||||
it('rejects exact terminal stop for multiple expected PTYs before stopping anything', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const stopped: string[] = []
|
||||
|
|
|
|||
|
|
@ -13732,7 +13732,7 @@ export class OrcaRuntimeService {
|
|||
async stopExactTerminalsForWorktree(
|
||||
worktreeSelector: string,
|
||||
expectedPtyIds: readonly string[],
|
||||
opts: { keepHistory?: boolean } = {}
|
||||
opts: { keepHistory?: boolean; targetOnly?: boolean } = {}
|
||||
): Promise<{
|
||||
stopped: number
|
||||
stoppedPtyIds: string[]
|
||||
|
|
@ -13741,8 +13741,8 @@ export class OrcaRuntimeService {
|
|||
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.
|
||||
// Why: worktree sleep needs proof of the complete live set; pane hibernation
|
||||
// only needs proof that its target PTY was live and is now gone.
|
||||
const graphEpoch = this.captureReadyGraphEpoch()
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
|
|
@ -13757,7 +13757,9 @@ export class OrcaRuntimeService {
|
|||
throw new Error('terminal_liveness_unavailable')
|
||||
}
|
||||
const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness)
|
||||
if (!setsEqual(livePtyIds, expected)) {
|
||||
const targetOnly = opts.targetOnly === true
|
||||
const expectedIsLive = [...expected].every((ptyId) => livePtyIds.has(ptyId))
|
||||
if (targetOnly ? !expectedIsLive : !setsEqual(livePtyIds, expected)) {
|
||||
const error = Object.assign(new Error('terminal_stop_pty_set_mismatch'), {
|
||||
livePtyIds: [...livePtyIds].sort(),
|
||||
expectedPtyIds: [...expected].sort()
|
||||
|
|
@ -13787,7 +13789,8 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness)
|
||||
if (remainingLivePtyIds.size > 0) {
|
||||
const stoppedTargetsStillLive = [...expected].filter((ptyId) => remainingLivePtyIds.has(ptyId))
|
||||
if (targetOnly ? stoppedTargetsStillLive.length > 0 : remainingLivePtyIds.size > 0) {
|
||||
return {
|
||||
stopped: stoppedPtyIds.length,
|
||||
stoppedPtyIds,
|
||||
|
|
@ -13801,7 +13804,10 @@ export class OrcaRuntimeService {
|
|||
stopped: stoppedPtyIds.length,
|
||||
stoppedPtyIds,
|
||||
livePtyIds: [...livePtyIds].sort(),
|
||||
postStopVerified: true
|
||||
postStopVerified: true,
|
||||
...(targetOnly && remainingLivePtyIds.size > 0
|
||||
? { remainingLivePtyIds: [...remainingLivePtyIds].sort() }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -539,7 +539,8 @@ const TerminalStop = z.object({
|
|||
|
||||
const TerminalStopExact = TerminalStop.extend({
|
||||
expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1),
|
||||
keepHistory: z.boolean().optional()
|
||||
keepHistory: z.boolean().optional(),
|
||||
targetOnly: z.boolean().optional()
|
||||
})
|
||||
|
||||
const AgentTeamsTmuxCompat = z.object({
|
||||
|
|
@ -813,7 +814,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
params: TerminalStopExact,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, {
|
||||
keepHistory: params.keepHistory
|
||||
keepHistory: params.keepHistory,
|
||||
targetOnly: params.targetOnly
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
|
|
|||
|
|
@ -72,6 +72,13 @@ const ptyExitSidecars = new Map<string, Set<(code: number) => void>>()
|
|||
export const ptyTeardownHandlers = new Map<string, () => void>()
|
||||
let ptyDispatcherAttached = false
|
||||
|
||||
export type PtyDataHandlerShutdownSnapshot = {
|
||||
ptyId: string
|
||||
dataHandler?: (data: string, meta?: PtyDataMeta) => void
|
||||
replayHandler?: (data: string) => void
|
||||
teardownHandler?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove data and status handlers for the given PTY IDs so that any final
|
||||
* data flushed by the main process during PTY teardown cannot trigger
|
||||
|
|
@ -82,13 +89,37 @@ let ptyDispatcherAttached = false
|
|||
* Exit handlers are intentionally kept alive so the normal exit-cleanup
|
||||
* path (unregister, clear stale timers, update store) still runs.
|
||||
*/
|
||||
export function unregisterPtyDataHandlers(ptyIds: string[]): void {
|
||||
export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutdownSnapshot[] {
|
||||
const snapshots: PtyDataHandlerShutdownSnapshot[] = []
|
||||
for (const id of ptyIds) {
|
||||
snapshots.push({
|
||||
ptyId: id,
|
||||
dataHandler: ptyDataHandlers.get(id),
|
||||
replayHandler: ptyReplayHandlers.get(id),
|
||||
teardownHandler: ptyTeardownHandlers.get(id)
|
||||
})
|
||||
ptyDataHandlers.delete(id)
|
||||
ptyReplayHandlers.delete(id)
|
||||
ptyTeardownHandlers.get(id)?.()
|
||||
ptyTeardownHandlers.delete(id)
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
export function restorePtyDataHandlersAfterFailedShutdown(
|
||||
snapshots: readonly PtyDataHandlerShutdownSnapshot[]
|
||||
): void {
|
||||
for (const snapshot of snapshots) {
|
||||
if (snapshot.dataHandler) {
|
||||
ptyDataHandlers.set(snapshot.ptyId, snapshot.dataHandler)
|
||||
}
|
||||
if (snapshot.replayHandler) {
|
||||
ptyReplayHandlers.set(snapshot.ptyId, snapshot.replayHandler)
|
||||
}
|
||||
if (snapshot.teardownHandler) {
|
||||
ptyTeardownHandlers.set(snapshot.ptyId, snapshot.teardownHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensurePtyDispatcher(): void {
|
||||
|
|
|
|||
|
|
@ -746,6 +746,27 @@ describe('createIpcPtyTransport', () => {
|
|||
expect(onPtyExit).toHaveBeenCalledWith('pty-1')
|
||||
})
|
||||
|
||||
it('restores data handlers when an intentional shutdown fails before exit', async () => {
|
||||
const {
|
||||
createIpcPtyTransport,
|
||||
restorePtyDataHandlersAfterFailedShutdown,
|
||||
unregisterPtyDataHandlers
|
||||
} = await import('./pty-transport')
|
||||
const onDataCallback = vi.fn()
|
||||
const transport = createIpcPtyTransport()
|
||||
|
||||
await transport.connect({ url: '', callbacks: { onData: onDataCallback } })
|
||||
|
||||
const snapshots = unregisterPtyDataHandlers(['pty-1'])
|
||||
onData?.({ id: 'pty-1', data: 'final burst while detached' })
|
||||
expect(onDataCallback).not.toHaveBeenCalled()
|
||||
|
||||
restorePtyDataHandlersAfterFailedShutdown(snapshots)
|
||||
onData?.({ id: 'pty-1', data: 'live again' })
|
||||
|
||||
expect(onDataCallback).toHaveBeenCalledWith('live again')
|
||||
})
|
||||
|
||||
it('unregisterPtyDataHandlers cancels staleTitleTimer so it cannot fire stale idle transition', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export {
|
|||
ensurePtyDispatcher,
|
||||
getEagerPtyBufferHandle,
|
||||
registerEagerPtyBuffer,
|
||||
restorePtyDataHandlersAfterFailedShutdown,
|
||||
subscribeToPtyExit,
|
||||
unregisterPtyDataHandlers
|
||||
} from './pty-dispatcher'
|
||||
|
|
|
|||
|
|
@ -71,9 +71,9 @@ function entry(): AgentStatusEntry {
|
|||
}
|
||||
|
||||
function installEligibleState(
|
||||
shutdownWorktreeTerminals = vi.fn(),
|
||||
shutdownCompletedAgentPaneForHibernation = vi.fn(),
|
||||
overrides: Partial<AppState> = {}
|
||||
): typeof shutdownWorktreeTerminals {
|
||||
): typeof shutdownCompletedAgentPaneForHibernation {
|
||||
const e = entry()
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
|
|
@ -87,10 +87,11 @@ function installEligibleState(
|
|||
agentStatusByPaneKey: { [e.paneKey]: e },
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
lastTerminalInputAtByPaneKey: {},
|
||||
shutdownWorktreeTerminals: shutdownWorktreeTerminals as never,
|
||||
shutdownCompletedAgentPaneForHibernation: shutdownCompletedAgentPaneForHibernation as never,
|
||||
shutdownWorktreeTerminals: vi.fn() as never,
|
||||
...overrides
|
||||
})
|
||||
return shutdownWorktreeTerminals
|
||||
return shutdownCompletedAgentPaneForHibernation
|
||||
}
|
||||
|
||||
function runtimeListResult(ptyIds: string[], truncated = false) {
|
||||
|
|
@ -179,9 +180,29 @@ describe('agent hibernation coordinator', () => {
|
|||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
|
||||
keepIdentifiers: true,
|
||||
shutdownReason: 'auto-hibernate-completed-agent',
|
||||
sleepingPaneKeys: [`tab-1:${LEAF}`]
|
||||
paneKey: `tab-1:${LEAF}`,
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF,
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
expect(useAppStore.getState().shutdownWorktreeTerminals).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hibernates an eligible pane when a sibling shell PTY is live', async () => {
|
||||
vi.useFakeTimers()
|
||||
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] }
|
||||
})
|
||||
startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
|
||||
paneKey: `tab-1:${LEAF}`,
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF,
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -318,10 +339,11 @@ describe('agent hibernation coordinator', () => {
|
|||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
|
||||
keepIdentifiers: true,
|
||||
shutdownReason: 'auto-hibernate-completed-agent',
|
||||
sleepingPaneKeys: [`tab-1:${LEAF}`],
|
||||
expectedRuntimePtyIds: ['pty-1']
|
||||
paneKey: `tab-1:${LEAF}`,
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF,
|
||||
ptyId: 'pty-1',
|
||||
expectedRuntimePtyId: 'pty-1'
|
||||
})
|
||||
expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -336,7 +358,7 @@ describe('agent hibernation coordinator', () => {
|
|||
installRuntimeListResponses(
|
||||
runtimeListResult(['pty-1']),
|
||||
runtimeListResult(['pty-1']),
|
||||
runtimeListResult(['pty-1', 'pty-shell'])
|
||||
runtimeListResult(['pty-shell'])
|
||||
)
|
||||
const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), {
|
||||
settings: {
|
||||
|
|
@ -405,9 +427,12 @@ describe('agent hibernation coordinator', () => {
|
|||
expect(shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips runtime-backed candidates with multiple live PTYs', async () => {
|
||||
it('hibernates runtime-backed candidates independently when siblings remain live', async () => {
|
||||
vi.useFakeTimers()
|
||||
installRuntimeListResponses(
|
||||
runtimeListResult(['pty-1', 'pty-2']),
|
||||
runtimeListResult(['pty-1', 'pty-2']),
|
||||
runtimeListResult(['pty-1', 'pty-2']),
|
||||
runtimeListResult(['pty-1', 'pty-2']),
|
||||
runtimeListResult(['pty-1', 'pty-2'])
|
||||
)
|
||||
|
|
@ -440,7 +465,21 @@ describe('agent hibernation coordinator', () => {
|
|||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
expect(shutdown).toHaveBeenCalledTimes(2)
|
||||
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
|
||||
paneKey: `tab-1:${LEAF}`,
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF,
|
||||
ptyId: 'pty-1',
|
||||
expectedRuntimePtyId: 'pty-1'
|
||||
})
|
||||
expect(shutdown).toHaveBeenCalledWith('wt-bg', {
|
||||
paneKey: `tab-1:${secondLeaf}`,
|
||||
tabId: 'tab-1',
|
||||
leafId: secondLeaf,
|
||||
ptyId: 'pty-2',
|
||||
expectedRuntimePtyId: 'pty-2'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed on truncated runtime liveness samples', async () => {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ type AgentHibernationCoordinatorState = {
|
|||
interval: IntervalHandle | null
|
||||
confirmationState: AgentHibernationConfirmationState
|
||||
tickInFlight: boolean
|
||||
shuttingDownWorktreeIds: Set<string>
|
||||
shuttingDownCandidateIds: Set<string>
|
||||
now: () => number
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ const coordinator: AgentHibernationCoordinatorState = {
|
|||
interval: null,
|
||||
confirmationState: {},
|
||||
tickInFlight: false,
|
||||
shuttingDownWorktreeIds: new Set(),
|
||||
shuttingDownCandidateIds: new Set(),
|
||||
now: () => Date.now()
|
||||
}
|
||||
|
||||
|
|
@ -152,37 +152,38 @@ async function currentCandidates(now: number) {
|
|||
}))
|
||||
}
|
||||
|
||||
async function hibernateWorktreeIfStillEligible(
|
||||
async function hibernatePaneIfStillEligible(
|
||||
confirmedCandidate: AgentHibernationCandidate
|
||||
): Promise<void> {
|
||||
const { worktreeId } = confirmedCandidate
|
||||
if (coordinator.shuttingDownWorktreeIds.has(worktreeId)) {
|
||||
const { id, worktreeId } = confirmedCandidate
|
||||
if (coordinator.shuttingDownCandidateIds.has(id)) {
|
||||
return
|
||||
}
|
||||
const candidates = await currentCandidates(coordinator.now())
|
||||
const stillEligible = candidates.some(
|
||||
(candidate) =>
|
||||
candidate.worktreeId === worktreeId && candidate.signature === confirmedCandidate.signature
|
||||
candidate.id === confirmedCandidate.id && candidate.signature === confirmedCandidate.signature
|
||||
)
|
||||
if (!stillEligible) {
|
||||
return
|
||||
}
|
||||
coordinator.shuttingDownWorktreeIds.add(worktreeId)
|
||||
coordinator.shuttingDownCandidateIds.add(id)
|
||||
try {
|
||||
const state = useAppStore.getState()
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
|
||||
await state.shutdownWorktreeTerminals(worktreeId, {
|
||||
keepIdentifiers: true,
|
||||
shutdownReason: 'auto-hibernate-completed-agent',
|
||||
sleepingPaneKeys: confirmedCandidate.paneKeys,
|
||||
await state.shutdownCompletedAgentPaneForHibernation(worktreeId, {
|
||||
paneKey: confirmedCandidate.paneKey,
|
||||
tabId: confirmedCandidate.tabId,
|
||||
leafId: confirmedCandidate.leafId,
|
||||
ptyId: confirmedCandidate.targetPtyIds[0],
|
||||
...(runtimeEnvironmentId
|
||||
? { expectedRuntimePtyIds: confirmedCandidate.expectedRuntimePtyIds }
|
||||
? { expectedRuntimePtyId: confirmedCandidate.expectedRuntimePtyIds[0] }
|
||||
: {})
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[agent-hibernation] failed to hibernate worktree:', worktreeId, err)
|
||||
console.warn('[agent-hibernation] failed to hibernate agent pane:', id, err)
|
||||
} finally {
|
||||
coordinator.shuttingDownWorktreeIds.delete(worktreeId)
|
||||
coordinator.shuttingDownCandidateIds.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,7 +199,7 @@ export async function runAgentHibernationTick(): Promise<void> {
|
|||
)
|
||||
coordinator.confirmationState = plan.confirmationState
|
||||
for (const candidate of plan.candidates) {
|
||||
void hibernateWorktreeIfStillEligible(candidate)
|
||||
void hibernatePaneIfStillEligible(candidate)
|
||||
}
|
||||
} finally {
|
||||
coordinator.tickInFlight = false
|
||||
|
|
@ -231,7 +232,7 @@ export function isAgentHibernationCoordinatorRunning(): boolean {
|
|||
|
||||
export function resetAgentHibernationCoordinatorForTests(): void {
|
||||
stopAgentHibernationCoordinator()
|
||||
coordinator.shuttingDownWorktreeIds.clear()
|
||||
coordinator.shuttingDownCandidateIds.clear()
|
||||
coordinator.tickInFlight = false
|
||||
coordinator.now = () => Date.now()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ function plannedWorktrees(input: AgentHibernationPlannerSnapshot): string[] {
|
|||
return planAgentHibernationCandidates(input).map((candidate) => candidate.worktreeId)
|
||||
}
|
||||
|
||||
function plannedPaneKeys(input: AgentHibernationPlannerSnapshot): string[] {
|
||||
return planAgentHibernationCandidates(input).map((candidate) => candidate.paneKey)
|
||||
}
|
||||
|
||||
describe('agent hibernation planner', () => {
|
||||
it('selects nothing when disabled, active, or foreground', () => {
|
||||
expect(
|
||||
|
|
@ -128,10 +132,26 @@ describe('agent hibernation planner', () => {
|
|||
).toEqual(['wt-bg'])
|
||||
})
|
||||
|
||||
it('rejects untracked live PTYs and already-sleeping panes', () => {
|
||||
it('emits a pane candidate when a sibling shell PTY is live', () => {
|
||||
expect(
|
||||
plannedWorktrees(snapshot({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } }))
|
||||
).toEqual([])
|
||||
planAgentHibernationCandidates(
|
||||
snapshot({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] } })
|
||||
)
|
||||
).toMatchObject([
|
||||
{
|
||||
id: `wt-bg|tab-1:${LEAF}`,
|
||||
worktreeId: 'wt-bg',
|
||||
paneKey: `tab-1:${LEAF}`,
|
||||
tabId: 'tab-1',
|
||||
leafId: LEAF,
|
||||
targetPtyIds: ['pty-1'],
|
||||
expectedRuntimePtyIds: ['pty-1'],
|
||||
paneKeys: [`tab-1:${LEAF}`]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects panes without live PTYs and already-sleeping panes', () => {
|
||||
expect(plannedWorktrees(snapshot({ ptyIdsByTabId: { 'tab-1': [] } }))).toEqual([])
|
||||
expect(
|
||||
plannedWorktrees(
|
||||
|
|
@ -144,6 +164,17 @@ describe('agent hibernation planner', () => {
|
|||
expect(plannedWorktrees(snapshot({ mobileLockedPtyIds: ['pty-1'] }))).toEqual([])
|
||||
})
|
||||
|
||||
it('does not let a mobile-locked sibling PTY block an unlocked target pane', () => {
|
||||
expect(
|
||||
plannedPaneKeys(
|
||||
snapshot({
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-shell'] },
|
||||
mobileLockedPtyIds: ['pty-shell']
|
||||
})
|
||||
)
|
||||
).toEqual([`tab-1:${LEAF}`])
|
||||
})
|
||||
|
||||
it('selects runtime-backed live PTYs when the renderer live map is empty', () => {
|
||||
const [candidate] = planAgentHibernationCandidates(
|
||||
snapshot({
|
||||
|
|
@ -206,16 +237,16 @@ describe('agent hibernation planner', () => {
|
|||
).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects runtime-backed worktrees with extra unknown live PTYs', () => {
|
||||
it('allows runtime-backed worktrees with sibling live PTYs', () => {
|
||||
expect(
|
||||
plannedWorktrees(
|
||||
plannedPaneKeys(
|
||||
snapshot({
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
runtimeLivePtyIdsByWorktreeId: { 'wt-bg': ['pty-1', 'pty-shell'] },
|
||||
runtimeLivenessRequiredWorktreeIds: ['wt-bg']
|
||||
})
|
||||
)
|
||||
).toEqual([])
|
||||
).toEqual([`tab-1:${LEAF}`])
|
||||
})
|
||||
|
||||
it('applies mobile locks to runtime-backed PTYs', () => {
|
||||
|
|
@ -245,14 +276,14 @@ describe('agent hibernation planner', () => {
|
|||
).toEqual([])
|
||||
})
|
||||
|
||||
it('selects a worktree when all live PTYs are eligible done agents', () => {
|
||||
it('selects each eligible done agent pane independently', () => {
|
||||
expect(plannedWorktrees(snapshot())).toEqual(['wt-bg'])
|
||||
const second = entry({
|
||||
paneKey: `tab-1:${OTHER_LEAF}`,
|
||||
providerSession: { key: 'session_id', id: 'session-2' }
|
||||
})
|
||||
expect(
|
||||
plannedWorktrees(
|
||||
plannedPaneKeys(
|
||||
snapshot({
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
|
|
@ -271,7 +302,7 @@ describe('agent hibernation planner', () => {
|
|||
agentStatusByPaneKey: { [`tab-1:${LEAF}`]: entry(), [second.paneKey]: second }
|
||||
})
|
||||
)
|
||||
).toEqual(['wt-bg'])
|
||||
).toEqual([`tab-1:${LEAF}`, `tab-1:${OTHER_LEAF}`])
|
||||
})
|
||||
|
||||
it('requires two stable ticks and resets on signature changes', () => {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,13 @@ export type AgentHibernationPlannerSnapshot = {
|
|||
}
|
||||
|
||||
export type AgentHibernationCandidate = {
|
||||
id: string
|
||||
worktreeId: string
|
||||
paneKey: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
paneKeys: string[]
|
||||
targetPtyIds: string[]
|
||||
expectedRuntimePtyIds: string[]
|
||||
signature: string
|
||||
}
|
||||
|
|
@ -44,6 +49,8 @@ export type AgentHibernationPlan = {
|
|||
|
||||
type EligiblePane = {
|
||||
paneKey: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
runtimePtyId: string
|
||||
providerSessionId: string
|
||||
|
|
@ -90,12 +97,13 @@ function getLivePtyIdsForTab(
|
|||
function getPaneLivePtyId(
|
||||
entry: AgentStatusEntry,
|
||||
layout: TerminalLayoutSnapshot | undefined
|
||||
): string | null {
|
||||
): { leafId: string; ptyId: string } | null {
|
||||
const parsed = parsePaneKey(entry.paneKey)
|
||||
if (!parsed || parsed.tabId !== entry.tabId) {
|
||||
if (!parsed || (entry.tabId && parsed.tabId !== entry.tabId)) {
|
||||
return null
|
||||
}
|
||||
return layout?.ptyIdsByLeafId?.[parsed.leafId] ?? null
|
||||
const ptyId = layout?.ptyIdsByLeafId?.[parsed.leafId]
|
||||
return ptyId ? { leafId: parsed.leafId, ptyId } : null
|
||||
}
|
||||
|
||||
function getEntryTabId(entry: AgentStatusEntry): string | null {
|
||||
|
|
@ -112,6 +120,7 @@ function getEligiblePane(args: {
|
|||
livePtyIds: Set<string>
|
||||
sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey']
|
||||
lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey']
|
||||
mobileLockedPtyIds: Set<string>
|
||||
now: number
|
||||
idleMs: number
|
||||
}): EligiblePane | null {
|
||||
|
|
@ -121,7 +130,8 @@ function getEligiblePane(args: {
|
|||
layout,
|
||||
livePtyIds,
|
||||
sleepingAgentSessionsByPaneKey,
|
||||
lastTerminalInputAtByPaneKey
|
||||
lastTerminalInputAtByPaneKey,
|
||||
mobileLockedPtyIds
|
||||
} = args
|
||||
if (entry.state !== 'done' || sleepingAgentSessionsByPaneKey[entry.paneKey]) {
|
||||
return null
|
||||
|
|
@ -145,16 +155,19 @@ function getEligiblePane(args: {
|
|||
if (typeof inputAt === 'number' && Number.isFinite(inputAt) && inputAt > entry.updatedAt) {
|
||||
return null
|
||||
}
|
||||
const ptyId = getPaneLivePtyId(entry, layout)
|
||||
if (!ptyId) {
|
||||
const livePane = getPaneLivePtyId(entry, layout)
|
||||
if (!livePane) {
|
||||
return null
|
||||
}
|
||||
const { leafId, ptyId } = livePane
|
||||
const runtimePtyId = toRuntimePtyId(ptyId)
|
||||
if (!livePtyIds.has(runtimePtyId)) {
|
||||
if (!livePtyIds.has(runtimePtyId) || mobileLockedPtyIds.has(runtimePtyId)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
paneKey: entry.paneKey,
|
||||
tabId: tab.id,
|
||||
leafId,
|
||||
ptyId,
|
||||
runtimePtyId,
|
||||
providerSessionId: entry.providerSession.id,
|
||||
|
|
@ -175,6 +188,10 @@ function signatureFor(worktreeId: string, panes: EligiblePane[]): string {
|
|||
return `${worktreeId}|${parts.join('|')}`
|
||||
}
|
||||
|
||||
function candidateIdFor(worktreeId: string, paneKey: string): string {
|
||||
return `${worktreeId}|${paneKey}`
|
||||
}
|
||||
|
||||
function getAgentEntriesByTabId(
|
||||
agentStatusByPaneKey: AgentHibernationPlannerSnapshot['agentStatusByPaneKey']
|
||||
): Map<string, AgentStatusEntry[]> {
|
||||
|
|
@ -229,9 +246,6 @@ export function planAgentHibernationCandidates(
|
|||
) {
|
||||
continue
|
||||
}
|
||||
const livePtyIds = new Set<string>()
|
||||
const eligibleByPtyId = new Map<string, EligiblePane>()
|
||||
let rejected = false
|
||||
for (const tab of tabs) {
|
||||
const tabLivePtyIds = getLivePtyIdsForTab(
|
||||
tab,
|
||||
|
|
@ -239,12 +253,6 @@ export function planAgentHibernationCandidates(
|
|||
snapshot.runtimeLivePtyIdsByWorktreeId,
|
||||
runtimeLivenessRequiredWorktreeIds.has(worktreeId)
|
||||
)
|
||||
for (const ptyId of tabLivePtyIds) {
|
||||
livePtyIds.add(ptyId)
|
||||
}
|
||||
if (tabLivePtyIds.some((ptyId) => mobileLockedPtyIds.has(ptyId))) {
|
||||
rejected = true
|
||||
}
|
||||
if (tabLivePtyIds.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -257,28 +265,29 @@ export function planAgentHibernationCandidates(
|
|||
livePtyIds: new Set(tabLivePtyIds),
|
||||
sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey,
|
||||
lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey,
|
||||
mobileLockedPtyIds,
|
||||
now: snapshot.now,
|
||||
idleMs
|
||||
})
|
||||
if (eligible) {
|
||||
eligibleByPtyId.set(eligible.runtimePtyId, eligible)
|
||||
} else if (entry.state !== 'done' || getPaneLivePtyId(entry, layout)) {
|
||||
rejected = true
|
||||
candidates.push({
|
||||
id: candidateIdFor(worktreeId, eligible.paneKey),
|
||||
worktreeId,
|
||||
paneKey: eligible.paneKey,
|
||||
tabId: eligible.tabId,
|
||||
leafId: eligible.leafId,
|
||||
paneKeys: [eligible.paneKey],
|
||||
targetPtyIds: [eligible.ptyId],
|
||||
expectedRuntimePtyIds: [eligible.runtimePtyId],
|
||||
signature: signatureFor(worktreeId, [eligible])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rejected || livePtyIds.size === 0 || eligibleByPtyId.size !== livePtyIds.size) {
|
||||
continue
|
||||
}
|
||||
const panes = [...eligibleByPtyId.values()]
|
||||
candidates.push({
|
||||
worktreeId,
|
||||
paneKeys: panes.map((pane) => pane.paneKey).sort(),
|
||||
expectedRuntimePtyIds: [...livePtyIds].sort(),
|
||||
signature: signatureFor(worktreeId, panes)
|
||||
})
|
||||
}
|
||||
return candidates.sort((a, b) => a.worktreeId.localeCompare(b.worktreeId))
|
||||
return candidates.sort(
|
||||
(a, b) => a.worktreeId.localeCompare(b.worktreeId) || a.paneKey.localeCompare(b.paneKey)
|
||||
)
|
||||
}
|
||||
|
||||
export function confirmAgentHibernationCandidates(
|
||||
|
|
@ -288,8 +297,8 @@ export function confirmAgentHibernationCandidates(
|
|||
const confirmationState: AgentHibernationConfirmationState = {}
|
||||
const confirmed: AgentHibernationCandidate[] = []
|
||||
for (const candidate of candidates) {
|
||||
confirmationState[candidate.worktreeId] = candidate.signature
|
||||
if (previous[candidate.worktreeId] === candidate.signature) {
|
||||
confirmationState[candidate.id] = candidate.signature
|
||||
if (previous[candidate.id] === candidate.signature) {
|
||||
confirmed.push(candidate)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ type DropAgentStatusByWorktreeOptions = {
|
|||
retainedCompletionEvidence?: readonly RetainedAgentEntry[]
|
||||
}
|
||||
|
||||
type DropHibernatedAgentPaneOptions = {
|
||||
retainedCompletionEvidence?: readonly RetainedAgentEntry[]
|
||||
}
|
||||
|
||||
export type AgentStatusSlice = {
|
||||
/** Explicit agent status entries keyed by `${tabId}:${leafId}` composite.
|
||||
* Real-time only — lives in renderer memory, not persisted to disk. */
|
||||
|
|
@ -114,6 +118,14 @@ export type AgentStatusSlice = {
|
|||
* remaining agent rows (live or retained) must not reappear. */
|
||||
dropAgentStatusByTabPrefix: (tabIdPrefix: string) => void
|
||||
|
||||
/** Remove one automatically hibernated completed-agent pane while preserving
|
||||
* sibling live/retained rows in the same worktree. */
|
||||
dropHibernatedAgentStatusPane: (
|
||||
worktreeId: string,
|
||||
paneKey: string,
|
||||
opts?: DropHibernatedAgentPaneOptions
|
||||
) => void
|
||||
|
||||
/** Remove all entries for a worktree AND suppress re-retention for live rows.
|
||||
* Used on worktree sleep/remove — the whole worktree surface is folding, so
|
||||
* retained rows must drop even if their original tab is no longer present.
|
||||
|
|
@ -1111,6 +1123,96 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
|||
}
|
||||
},
|
||||
|
||||
dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts) => {
|
||||
let hadLive = false
|
||||
set((s) => {
|
||||
const liveEntry = s.agentStatusByPaneKey[paneKey]
|
||||
const hasLive = liveEntry !== undefined
|
||||
const hasRetained = paneKey in s.retainedAgentsByPaneKey
|
||||
const migrationUnsupported = pruneMigrationUnsupportedEntries(
|
||||
s.migrationUnsupportedByPtyId,
|
||||
(entry) => entry.paneKey === paneKey
|
||||
)
|
||||
const retainedEvidence = new Map<string, RetainedAgentEntry>()
|
||||
for (const retained of opts?.retainedCompletionEvidence ?? []) {
|
||||
if (
|
||||
retained.entry.paneKey === paneKey &&
|
||||
!liveEntry &&
|
||||
shouldReplaceRetainedWithLive(retainedEvidence.get(paneKey), retained)
|
||||
) {
|
||||
retainedEvidence.set(paneKey, retained)
|
||||
}
|
||||
}
|
||||
if (
|
||||
liveEntry?.state === 'done' &&
|
||||
liveEntry.agentType !== undefined &&
|
||||
liveEntry.interrupted !== true
|
||||
) {
|
||||
retainedEvidence.set(
|
||||
paneKey,
|
||||
retainedAgentEntryFromLive(s, worktreeId, liveEntry, liveEntry.agentType)
|
||||
)
|
||||
}
|
||||
const keepsCompletionEvidence = retainedEvidence.has(paneKey)
|
||||
let nextAck = s.acknowledgedAgentsByPaneKey
|
||||
if (!keepsCompletionEvidence && paneKey in nextAck) {
|
||||
nextAck = { ...nextAck }
|
||||
delete nextAck[paneKey]
|
||||
}
|
||||
if (!hasLive && !hasRetained && !migrationUnsupported.changed && !keepsCompletionEvidence) {
|
||||
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
|
||||
return { acknowledgedAgentsByPaneKey: nextAck }
|
||||
}
|
||||
return s
|
||||
}
|
||||
hadLive = hasLive
|
||||
|
||||
const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey
|
||||
if (hasLive) {
|
||||
delete nextLive[paneKey]
|
||||
}
|
||||
|
||||
const nextRetained =
|
||||
hasRetained || keepsCompletionEvidence
|
||||
? { ...s.retainedAgentsByPaneKey }
|
||||
: s.retainedAgentsByPaneKey
|
||||
if (hasRetained && !keepsCompletionEvidence) {
|
||||
delete nextRetained[paneKey]
|
||||
}
|
||||
for (const [key, retained] of retainedEvidence) {
|
||||
if (shouldReplaceRetainedWithLive(nextRetained[key], retained)) {
|
||||
nextRetained[key] = retained
|
||||
}
|
||||
}
|
||||
|
||||
const needsSuppressor =
|
||||
hasLive && !keepsCompletionEvidence && !(paneKey in s.retentionSuppressedPaneKeys)
|
||||
|
||||
return {
|
||||
agentStatusByPaneKey: nextLive,
|
||||
retainedAgentsByPaneKey: nextRetained,
|
||||
migrationUnsupportedByPtyId: migrationUnsupported.next,
|
||||
...(nextAck !== s.acknowledgedAgentsByPaneKey
|
||||
? { acknowledgedAgentsByPaneKey: nextAck }
|
||||
: {}),
|
||||
...(needsSuppressor
|
||||
? {
|
||||
retentionSuppressedPaneKeys: {
|
||||
...s.retentionSuppressedPaneKeys,
|
||||
[paneKey]: true
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
agentStatusEpoch:
|
||||
hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
|
||||
sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
|
||||
}
|
||||
})
|
||||
if (hadLive) {
|
||||
queueMicrotask(() => freshness.schedule())
|
||||
}
|
||||
},
|
||||
|
||||
dropAgentStatusByWorktree: (worktreeId, opts) => {
|
||||
let hadLive = false
|
||||
set((s) => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp
|
|||
import { toast } from 'sonner'
|
||||
|
||||
const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn())
|
||||
const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Mock sonner (imported by repos.ts)
|
||||
vi.mock('sonner', () => ({
|
||||
|
|
@ -15,6 +16,7 @@ vi.mock('sonner', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
restorePtyDataHandlersAfterFailedShutdown: mockRestorePtyDataHandlersAfterFailedShutdown,
|
||||
unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers
|
||||
}))
|
||||
|
||||
|
|
@ -2627,6 +2629,481 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
|
|||
shutdownBufferCaptures.clear()
|
||||
})
|
||||
|
||||
it('automatically hibernates only the completed agent pane and preserves siblings', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const targetLeaf = '11111111-1111-4111-8111-111111111111'
|
||||
const siblingLeaf = '22222222-2222-4222-8222-222222222222'
|
||||
const targetPaneKey = `tab-1:${targetLeaf}`
|
||||
const siblingPaneKey = `tab-1:${siblingLeaf}`
|
||||
const dropByWorktree = vi.fn()
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: targetLeaf },
|
||||
second: { type: 'leaf', leafId: siblingLeaf }
|
||||
},
|
||||
activeLeafId: siblingLeaf,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [targetLeaf]: 'pty-agent', [siblingLeaf]: 'pty-shell' }
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] },
|
||||
unreadTerminalTabs: { 'tab-1': true },
|
||||
unreadTerminalPanes: { [targetPaneKey]: true, [siblingPaneKey]: true },
|
||||
unreadAgentCompletionPanes: { [targetPaneKey]: true, [siblingPaneKey]: true },
|
||||
lastTerminalInputAtByPaneKey: { [targetPaneKey]: 1000, [siblingPaneKey]: 1100 },
|
||||
pendingSetupSplitByTabId: { 'tab-1': { command: 'setup', direction: 'horizontal' } },
|
||||
pendingIssueCommandSplitByTabId: { 'tab-1': { command: 'issue' } }
|
||||
})
|
||||
store.setState({ dropAgentStatusByWorktree: dropByWorktree as never })
|
||||
store.getState().setAgentStatus(
|
||||
targetPaneKey,
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'resume target',
|
||||
agentType: 'codex',
|
||||
lastAssistantMessage: 'done'
|
||||
},
|
||||
'Codex',
|
||||
{ updatedAt: 2000, stateStartedAt: 1000 },
|
||||
{ tabId: 'tab-1', worktreeId: wt },
|
||||
{ providerSession: { key: 'session_id', id: 'target-session' } }
|
||||
)
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
siblingPaneKey,
|
||||
{ state: 'working', prompt: 'keep running', agentType: 'claude' },
|
||||
'Claude',
|
||||
{ updatedAt: 2100, stateStartedAt: 2100 },
|
||||
{ tabId: 'tab-1', worktreeId: wt },
|
||||
{ providerSession: { key: 'session_id', id: 'sibling-session' } }
|
||||
)
|
||||
const siblingSleepingRecordBefore =
|
||||
store.getState().sleepingAgentSessionsByPaneKey[siblingPaneKey]
|
||||
|
||||
await store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
|
||||
paneKey: targetPaneKey,
|
||||
tabId: 'tab-1',
|
||||
leafId: targetLeaf,
|
||||
ptyId: 'pty-agent'
|
||||
})
|
||||
|
||||
const state = store.getState()
|
||||
expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-agent', { keepHistory: true })
|
||||
expect(mockApi.pty.kill).not.toHaveBeenCalledWith('pty-shell', expect.anything())
|
||||
expect(mockUnregisterPtyDataHandlers).toHaveBeenCalledWith(['pty-agent'])
|
||||
expect(mockUnregisterPtyDataHandlers.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockApi.pty.kill.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-shell'])
|
||||
expect(state.tabsByWorktree[wt]?.[0]?.ptyId).toBe('pty-shell')
|
||||
expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({
|
||||
[targetLeaf]: 'pty-agent',
|
||||
[siblingLeaf]: 'pty-shell'
|
||||
})
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
|
||||
providerSession: { key: 'session_id', id: 'target-session' }
|
||||
})
|
||||
expect(state.sleepingAgentSessionsByPaneKey[siblingPaneKey]).toBe(siblingSleepingRecordBefore)
|
||||
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
expect(state.agentStatusByPaneKey[siblingPaneKey]).toBeDefined()
|
||||
expect(state.retainedAgentsByPaneKey[targetPaneKey]).toMatchObject({
|
||||
entry: { lastAssistantMessage: 'done' }
|
||||
})
|
||||
expect(state.unreadTerminalTabs['tab-1']).toBe(true)
|
||||
expect(state.unreadTerminalPanes[targetPaneKey]).toBeUndefined()
|
||||
expect(state.unreadTerminalPanes[siblingPaneKey]).toBe(true)
|
||||
expect(state.unreadAgentCompletionPanes[targetPaneKey]).toBeUndefined()
|
||||
expect(state.unreadAgentCompletionPanes[siblingPaneKey]).toBe(true)
|
||||
expect(state.lastTerminalInputAtByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
expect(state.lastTerminalInputAtByPaneKey[siblingPaneKey]).toBe(1100)
|
||||
expect(state.pendingSetupSplitByTabId['tab-1']).toBeDefined()
|
||||
expect(state.pendingIssueCommandSplitByTabId['tab-1']).toBeDefined()
|
||||
expect(dropByWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps manual sleep worktree-wide', async () => {
|
||||
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: 'Terminal', ptyId: 'pty-agent' })]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] }
|
||||
})
|
||||
|
||||
await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true })
|
||||
|
||||
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([])
|
||||
expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-agent', { keepHistory: true })
|
||||
expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-shell', { keepHistory: true })
|
||||
})
|
||||
|
||||
it('does not commit pane sleep state when local target kill fails', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const targetLeaf = '11111111-1111-4111-8111-111111111111'
|
||||
const siblingLeaf = '22222222-2222-4222-8222-222222222222'
|
||||
const targetPaneKey = `tab-1:${targetLeaf}`
|
||||
const handlerSnapshots = [{ ptyId: 'pty-agent' }]
|
||||
|
||||
mockApi.pty.kill.mockRejectedValueOnce(new Error('kill failed'))
|
||||
mockUnregisterPtyDataHandlers.mockReturnValueOnce(handlerSnapshots)
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: targetLeaf },
|
||||
second: { type: 'leaf', leafId: siblingLeaf }
|
||||
},
|
||||
activeLeafId: siblingLeaf,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [targetLeaf]: 'pty-agent', [siblingLeaf]: 'pty-shell' }
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] }
|
||||
})
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
targetPaneKey,
|
||||
{ state: 'done', prompt: 'resume target', agentType: 'codex' },
|
||||
'Codex',
|
||||
{ updatedAt: 2000, stateStartedAt: 1000 },
|
||||
{ tabId: 'tab-1', worktreeId: wt },
|
||||
{ providerSession: { key: 'session_id', id: 'target-session' } }
|
||||
)
|
||||
|
||||
await expect(
|
||||
store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
|
||||
paneKey: targetPaneKey,
|
||||
tabId: 'tab-1',
|
||||
leafId: targetLeaf,
|
||||
ptyId: 'pty-agent'
|
||||
})
|
||||
).rejects.toThrow('kill failed')
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-agent', 'pty-shell'])
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
|
||||
expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined()
|
||||
expect(mockRestorePtyDataHandlersAfterFailedShutdown).toHaveBeenCalledWith(handlerSnapshots)
|
||||
})
|
||||
|
||||
it('uses target-only runtime stop for automatic pane hibernation', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const targetLeaf = '11111111-1111-4111-8111-111111111111'
|
||||
const siblingLeaf = '22222222-2222-4222-8222-222222222222'
|
||||
const targetPaneKey = `tab-1:${targetLeaf}`
|
||||
|
||||
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
|
||||
Promise.resolve(
|
||||
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
|
||||
id: 'rpc-default',
|
||||
ok: true,
|
||||
result:
|
||||
args.method === 'terminal.stopExact'
|
||||
? {
|
||||
stoppedPtyIds: ['terminal-1'],
|
||||
livePtyIds: ['terminal-1', 'terminal-2'],
|
||||
postStopVerified: true,
|
||||
remainingLivePtyIds: ['terminal-2']
|
||||
}
|
||||
: {},
|
||||
_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' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: targetLeaf },
|
||||
second: { type: 'leaf', leafId: siblingLeaf }
|
||||
},
|
||||
activeLeafId: siblingLeaf,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
[targetLeaf]: 'remote:env-1@@terminal-1',
|
||||
[siblingLeaf]: 'remote:env-1@@terminal-2'
|
||||
}
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2']
|
||||
}
|
||||
})
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
targetPaneKey,
|
||||
{ state: 'done', prompt: 'resume target', agentType: 'codex' },
|
||||
'Codex',
|
||||
{ updatedAt: 2000, stateStartedAt: 1000 },
|
||||
{ tabId: 'tab-1', worktreeId: wt },
|
||||
{ providerSession: { key: 'session_id', id: 'target-session' } }
|
||||
)
|
||||
|
||||
await store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
|
||||
paneKey: targetPaneKey,
|
||||
tabId: 'tab-1',
|
||||
leafId: targetLeaf,
|
||||
ptyId: 'remote:env-1@@terminal-1',
|
||||
expectedRuntimePtyId: 'terminal-1'
|
||||
})
|
||||
|
||||
expect(mockApi.runtimeEnvironments.call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'runtime-1',
|
||||
method: 'terminal.stopExact',
|
||||
params: expect.objectContaining({
|
||||
expectedPtyIds: ['terminal-1'],
|
||||
keepHistory: true,
|
||||
targetOnly: true
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(mockApi.runtimeEnvironments.call).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.stop' })
|
||||
)
|
||||
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual(['remote:env-1@@terminal-2'])
|
||||
expect(mockApi.pty.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears stale relay wake hints when pane hibernation leaves no live PTYs in the tab', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const targetLeaf = '11111111-1111-4111-8111-111111111111'
|
||||
const targetPaneKey = `tab-1:${targetLeaf}`
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [
|
||||
makeTab({
|
||||
id: 'tab-1',
|
||||
worktreeId: wt,
|
||||
title: 'Codex',
|
||||
ptyId: 'ssh:ssh-1@@pty-agent'
|
||||
})
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: targetLeaf },
|
||||
activeLeafId: targetLeaf,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [targetLeaf]: 'ssh:ssh-1@@pty-agent' }
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['ssh:ssh-1@@pty-agent'] },
|
||||
lastKnownRelayPtyIdByTabId: { 'tab-1': 'ssh:ssh-1@@pty-agent' }
|
||||
})
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
targetPaneKey,
|
||||
{ state: 'done', prompt: 'resume target', agentType: 'codex' },
|
||||
'Codex',
|
||||
{ updatedAt: 2000, stateStartedAt: 1000 },
|
||||
{ tabId: 'tab-1', worktreeId: wt },
|
||||
{ providerSession: { key: 'session_id', id: 'target-session' } }
|
||||
)
|
||||
|
||||
await store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
|
||||
paneKey: targetPaneKey,
|
||||
tabId: 'tab-1',
|
||||
leafId: targetLeaf,
|
||||
ptyId: 'ssh:ssh-1@@pty-agent'
|
||||
})
|
||||
|
||||
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([])
|
||||
expect(store.getState().lastKnownRelayPtyIdByTabId['tab-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not retain stale completion evidence when pane status changes during hibernation', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const targetLeaf = '11111111-1111-4111-8111-111111111111'
|
||||
const targetPaneKey = `tab-1:${targetLeaf}`
|
||||
|
||||
mockApi.pty.kill.mockImplementationOnce(async () => {
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
targetPaneKey,
|
||||
{ state: 'working', prompt: 'still running', agentType: 'codex' },
|
||||
'Codex',
|
||||
{ updatedAt: 3000, stateStartedAt: 3000 },
|
||||
{ tabId: 'tab-1', worktreeId: wt },
|
||||
{ providerSession: { key: 'session_id', id: 'target-session' } }
|
||||
)
|
||||
})
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: targetLeaf },
|
||||
activeLeafId: targetLeaf,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [targetLeaf]: 'pty-agent' }
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-agent'] }
|
||||
})
|
||||
store.getState().setAgentStatus(
|
||||
targetPaneKey,
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'stale done',
|
||||
agentType: 'codex',
|
||||
lastAssistantMessage: 'old done'
|
||||
},
|
||||
'Codex',
|
||||
{ updatedAt: 2000, stateStartedAt: 1000 },
|
||||
{ tabId: 'tab-1', worktreeId: wt },
|
||||
{ providerSession: { key: 'session_id', id: 'target-session' } }
|
||||
)
|
||||
|
||||
await store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
|
||||
paneKey: targetPaneKey,
|
||||
tabId: 'tab-1',
|
||||
leafId: targetLeaf,
|
||||
ptyId: 'pty-agent'
|
||||
})
|
||||
|
||||
expect(store.getState().agentStatusByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
expect(store.getState().retainedAgentsByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
expect(store.getState().retentionSuppressedPaneKeys[targetPaneKey]).toBe(true)
|
||||
})
|
||||
|
||||
it('rolls back target suppressions when target-only runtime stop fails', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const targetLeaf = '11111111-1111-4111-8111-111111111111'
|
||||
const siblingLeaf = '22222222-2222-4222-8222-222222222222'
|
||||
const targetPaneKey = `tab-1:${targetLeaf}`
|
||||
|
||||
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
|
||||
Promise.resolve(
|
||||
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
|
||||
id: 'rpc-default',
|
||||
ok: true,
|
||||
result:
|
||||
args.method === 'terminal.stopExact'
|
||||
? {
|
||||
stoppedPtyIds: ['terminal-1'],
|
||||
livePtyIds: ['terminal-1', 'terminal-2'],
|
||||
postStopVerified: false,
|
||||
postStopFailure: 'target_still_live'
|
||||
}
|
||||
: {},
|
||||
_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' })]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: targetLeaf },
|
||||
second: { type: 'leaf', leafId: siblingLeaf }
|
||||
},
|
||||
activeLeafId: siblingLeaf,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
[targetLeaf]: 'remote:env-1@@terminal-1',
|
||||
[siblingLeaf]: 'remote:env-1@@terminal-2'
|
||||
}
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['remote:env-1@@terminal-1', 'remote:env-1@@terminal-2']
|
||||
}
|
||||
})
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
targetPaneKey,
|
||||
{ state: 'done', prompt: 'resume target', agentType: 'codex' },
|
||||
'Codex',
|
||||
{ updatedAt: 2000, stateStartedAt: 1000 },
|
||||
{ tabId: 'tab-1', worktreeId: wt },
|
||||
{ providerSession: { key: 'session_id', id: 'target-session' } }
|
||||
)
|
||||
|
||||
await expect(
|
||||
store.getState().shutdownCompletedAgentPaneForHibernation(wt, {
|
||||
paneKey: targetPaneKey,
|
||||
tabId: 'tab-1',
|
||||
leafId: targetLeaf,
|
||||
ptyId: 'remote:env-1@@terminal-1',
|
||||
expectedRuntimePtyId: 'terminal-1'
|
||||
})
|
||||
).rejects.toThrow('target_still_live')
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.ptyIdsByTabId['tab-1']).toEqual([
|
||||
'remote:env-1@@terminal-1',
|
||||
'remote:env-1@@terminal-2'
|
||||
])
|
||||
expect(state.suppressedPtyExitIds['remote:env-1@@terminal-1']).toBeUndefined()
|
||||
expect(state.suppressedPtyExitIds['terminal-1']).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
it('records terminal input even before agent hibernation is enabled', () => {
|
||||
const store = createTestStore()
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
} from './tab-group-state'
|
||||
import {
|
||||
ensurePtyDispatcher,
|
||||
restorePtyDataHandlersAfterFailedShutdown,
|
||||
unregisterPtyDataHandlers
|
||||
} from '@/components/terminal-pane/pty-transport'
|
||||
import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/terminal-layout-leaf-ids'
|
||||
|
|
@ -435,6 +436,16 @@ export type TerminalSlice = {
|
|||
expectedRuntimePtyIds?: string[]
|
||||
}
|
||||
) => Promise<void>
|
||||
shutdownCompletedAgentPaneForHibernation: (
|
||||
worktreeId: string,
|
||||
opts: {
|
||||
paneKey: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
expectedRuntimePtyId?: string
|
||||
}
|
||||
) => Promise<void>
|
||||
suppressPtyExit: (ptyId: string) => void
|
||||
consumeSuppressedPtyExit: (ptyId: string) => boolean
|
||||
queueCodexPaneRestarts: (ptyIds: string[]) => void
|
||||
|
|
@ -1685,6 +1696,209 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
}
|
||||
},
|
||||
|
||||
shutdownCompletedAgentPaneForHibernation: async (worktreeId, opts) => {
|
||||
const paneKeys = [opts.paneKey]
|
||||
const expectedRuntimePtyIds = sortedUniquePtyIds(
|
||||
opts.expectedRuntimePtyId ? [opts.expectedRuntimePtyId] : []
|
||||
)
|
||||
const shutdownPtyIds = sortedUniquePtyIds([opts.ptyId, ...expectedRuntimePtyIds])
|
||||
const state = get()
|
||||
const tab = (state.tabsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === opts.tabId
|
||||
)
|
||||
const parsed = parsePaneKey(opts.paneKey)
|
||||
const layout = state.terminalLayoutsByTabId[opts.tabId]
|
||||
const liveTabPtyIds = state.ptyIdsByTabId[opts.tabId] ?? []
|
||||
if (
|
||||
!tab ||
|
||||
!parsed ||
|
||||
parsed.tabId !== opts.tabId ||
|
||||
parsed.leafId !== opts.leafId ||
|
||||
layout?.ptyIdsByLeafId?.[opts.leafId] !== opts.ptyId ||
|
||||
(expectedRuntimePtyIds.length === 0 && !liveTabPtyIds.includes(opts.ptyId))
|
||||
) {
|
||||
throw new Error('agent_hibernation_pane_binding_mismatch')
|
||||
}
|
||||
|
||||
const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree(
|
||||
state,
|
||||
worktreeId,
|
||||
paneKeys
|
||||
)
|
||||
const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree(
|
||||
state,
|
||||
worktreeId,
|
||||
paneKeys
|
||||
)
|
||||
|
||||
const capture = shutdownBufferCaptures.get(opts.tabId)
|
||||
if (capture) {
|
||||
try {
|
||||
capture({ includeLocalBuffers: false })
|
||||
} catch {
|
||||
// Don't let one tab's capture failure block the pane hibernation.
|
||||
}
|
||||
}
|
||||
|
||||
const clearTargetSuppressions = (): void => {
|
||||
set((s) => {
|
||||
const next = { ...s.suppressedPtyExitIds }
|
||||
for (const ptyId of shutdownPtyIds) {
|
||||
delete next[ptyId]
|
||||
}
|
||||
return { suppressedPtyExitIds: next }
|
||||
})
|
||||
}
|
||||
|
||||
set((s) => ({
|
||||
suppressedPtyExitIds: {
|
||||
...s.suppressedPtyExitIds,
|
||||
...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const))
|
||||
}
|
||||
}))
|
||||
|
||||
if (expectedRuntimePtyIds.length > 0) {
|
||||
const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId)
|
||||
if (!runtimeEnvironmentId) {
|
||||
clearTargetSuppressions()
|
||||
throw new Error('missing_runtime_for_exact_terminal_stop')
|
||||
}
|
||||
let stopResult: {
|
||||
stoppedPtyIds?: string[]
|
||||
livePtyIds?: string[]
|
||||
postStopVerified?: boolean
|
||||
postStopFailure?: string
|
||||
}
|
||||
try {
|
||||
stopResult = await callRuntimeRpc<{
|
||||
stoppedPtyIds?: string[]
|
||||
livePtyIds?: string[]
|
||||
postStopVerified?: boolean
|
||||
postStopFailure?: string
|
||||
}>(
|
||||
{ kind: 'environment', environmentId: runtimeEnvironmentId },
|
||||
'terminal.stopExact',
|
||||
{
|
||||
worktree: toRuntimeWorktreeSelector(worktreeId),
|
||||
expectedPtyIds: expectedRuntimePtyIds,
|
||||
keepHistory: true,
|
||||
targetOnly: true
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
} catch (err) {
|
||||
clearTargetSuppressions()
|
||||
throw err
|
||||
}
|
||||
const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds)
|
||||
const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds)
|
||||
const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId))
|
||||
if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) {
|
||||
clearTargetSuppressions()
|
||||
throw new Error('exact_terminal_stop_mismatch')
|
||||
}
|
||||
if (stopResult.postStopVerified !== true) {
|
||||
clearTargetSuppressions()
|
||||
throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified')
|
||||
}
|
||||
unregisterPtyDataHandlers(shutdownPtyIds)
|
||||
} else if (!opts.ptyId.startsWith('remote:')) {
|
||||
// Why: pty.kill can flush final data before exit; unregister first so
|
||||
// pane hibernation cannot fire phantom notifications from stale handlers.
|
||||
const handlerSnapshots = unregisterPtyDataHandlers(shutdownPtyIds)
|
||||
try {
|
||||
await window.api.pty.kill(opts.ptyId, { keepHistory: true })
|
||||
} catch (err) {
|
||||
restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots)
|
||||
clearTargetSuppressions()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
set((s) => {
|
||||
const existingPtyIds = s.ptyIdsByTabId[opts.tabId] ?? []
|
||||
const shutdownPtyIdSet = new Set(shutdownPtyIds)
|
||||
const remainingPtyIds = existingPtyIds.filter((ptyId) => !shutdownPtyIdSet.has(ptyId))
|
||||
const nextTabsByWorktree = { ...s.tabsByWorktree }
|
||||
const tabs = nextTabsByWorktree[worktreeId] ?? []
|
||||
const tabIndex = tabs.findIndex((candidate) => candidate.id === opts.tabId)
|
||||
if (tabIndex !== -1) {
|
||||
const nextTabs = [...tabs]
|
||||
nextTabs[tabIndex] = {
|
||||
...nextTabs[tabIndex],
|
||||
ptyId: remainingPtyIds.at(-1) ?? null
|
||||
}
|
||||
nextTabsByWorktree[worktreeId] = nextTabs
|
||||
}
|
||||
|
||||
const nextCodexRestartNoticeByPtyId = { ...s.codexRestartNoticeByPtyId }
|
||||
for (const ptyId of shutdownPtyIds) {
|
||||
delete nextCodexRestartNoticeByPtyId[ptyId]
|
||||
}
|
||||
const nextLastKnownRelay =
|
||||
remainingPtyIds.length === 0
|
||||
? { ...s.lastKnownRelayPtyIdByTabId }
|
||||
: s.lastKnownRelayPtyIdByTabId
|
||||
if (remainingPtyIds.length === 0) {
|
||||
delete nextLastKnownRelay[opts.tabId]
|
||||
}
|
||||
|
||||
let nextRuntimePaneTitlesByTabId = s.runtimePaneTitlesByTabId
|
||||
const numericPaneId = Number(opts.leafId)
|
||||
if (
|
||||
Number.isInteger(numericPaneId) &&
|
||||
s.runtimePaneTitlesByTabId[opts.tabId]?.[numericPaneId]
|
||||
) {
|
||||
const nextByPane = { ...s.runtimePaneTitlesByTabId[opts.tabId] }
|
||||
delete nextByPane[numericPaneId]
|
||||
nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId }
|
||||
if (Object.keys(nextByPane).length > 0) {
|
||||
nextRuntimePaneTitlesByTabId[opts.tabId] = nextByPane
|
||||
} else {
|
||||
delete nextRuntimePaneTitlesByTabId[opts.tabId]
|
||||
}
|
||||
}
|
||||
|
||||
const nextUnreadTerminalPanes = { ...s.unreadTerminalPanes }
|
||||
const nextUnreadAgentCompletionPanes = { ...s.unreadAgentCompletionPanes }
|
||||
const nextLastTerminalInputAtByPaneKey = { ...s.lastTerminalInputAtByPaneKey }
|
||||
delete nextUnreadTerminalPanes[opts.paneKey]
|
||||
delete nextUnreadAgentCompletionPanes[opts.paneKey]
|
||||
delete nextLastTerminalInputAtByPaneKey[opts.paneKey]
|
||||
|
||||
return {
|
||||
tabsByWorktree: nextTabsByWorktree,
|
||||
ptyIdsByTabId: {
|
||||
...s.ptyIdsByTabId,
|
||||
[opts.tabId]: remainingPtyIds
|
||||
},
|
||||
lastKnownRelayPtyIdByTabId: nextLastKnownRelay,
|
||||
suppressedPtyExitIds: {
|
||||
...s.suppressedPtyExitIds,
|
||||
...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const))
|
||||
},
|
||||
codexRestartNoticeByPtyId: nextCodexRestartNoticeByPtyId,
|
||||
...(nextRuntimePaneTitlesByTabId !== s.runtimePaneTitlesByTabId
|
||||
? { runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId }
|
||||
: {}),
|
||||
unreadTerminalPanes: nextUnreadTerminalPanes,
|
||||
unreadAgentCompletionPanes: nextUnreadAgentCompletionPanes,
|
||||
lastTerminalInputAtByPaneKey: nextLastTerminalInputAtByPaneKey
|
||||
}
|
||||
})
|
||||
|
||||
set((s) => ({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
...s.sleepingAgentSessionsByPaneKey,
|
||||
...sleepingAgentSessionRecords
|
||||
}
|
||||
}))
|
||||
|
||||
get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, {
|
||||
retainedCompletionEvidence
|
||||
})
|
||||
},
|
||||
|
||||
shutdownWorktreeTerminals: async (worktreeId, opts) => {
|
||||
const keepIdentifiers = opts?.keepIdentifiers ?? false
|
||||
const shutdownReason: AgentStatusWorktreeShutdownReason =
|
||||
|
|
|
|||
Loading…
Reference in New Issue