Skip to content

Commit c8897d1

Browse files
committed
fix(desktop): stop the agent's terminal commands at sign-out, by their own process groups
Commands the agent started could outlive the session that started them. Closing a shell only hangs up its foreground, so a command that ignores SIGHUP kept running, and a tmux run outlives the Sim terminal entirely. - Sign-out and an account change now stop every command the agent started before the shells are torn down. A plain shell's command is stopped by its own process group, the same Ctrl-C, SIGTERM, SIGKILL escalation Stop uses, and each tmux run window still going gets Ctrl-C, then is closed. - A command the user started is never touched: only a command a `run` started counts as the agent's. - The agent's command stays the agent's until it really exits, even after an interactive command detaches from its tool call. Stop and sign-out can still end it then.
1 parent 93a0f75 commit c8897d1

6 files changed

Lines changed: 96 additions & 11 deletions

File tree

‎apps/desktop/src/main/index.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,15 @@ function main(): void {
331331
await desktopExecutor.signOut()
332332
},
333333
},
334-
{ label: 'terminal sessions', clear: () => terminal.dispose() },
334+
{
335+
label: 'terminal sessions',
336+
// Agent commands are stopped by their own process groups first: closing a shell only
337+
// hangs up its foreground, and a tmux run outlives the Sim terminal entirely.
338+
clear: async () => {
339+
await terminal.stopAgentCommands()
340+
terminal.dispose()
341+
},
342+
},
335343
{ label: 'task resource state', clear: clearDesktopChatSessions },
336344
{ label: 'local filesystem grants', clear: () => localFilesystem.forgetAll() },
337345
]
@@ -746,7 +754,13 @@ function main(): void {
746754
? [
747755
{ label: 'sign-in handoff state', clear: () => handoff.clear() },
748756
{ label: 'background executor', clear: () => desktopExecutor.signOut() },
749-
{ label: 'terminal sessions', clear: () => terminal.dispose() },
757+
{
758+
label: 'terminal sessions',
759+
clear: async () => {
760+
await terminal.stopAgentCommands()
761+
terminal.dispose()
762+
},
763+
},
750764
{ label: 'task resource state', clear: clearDesktopChatSessions },
751765
{
752766
label: 'app session storage',

‎apps/desktop/src/main/terminal/index.ts‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -858,15 +858,42 @@ export class TerminalService {
858858
return true
859859
}
860860

861+
/** Ctrl-C in the run's own window, then closing the window hangs up anything that ignored it. */
862+
private async interruptTmuxRun(handle: TmuxRunHandle, env: NodeJS.ProcessEnv): Promise<void> {
863+
await sendKey(handle.window, 'C-c', env)
864+
const deadline = Date.now() + STOP_ESCALATION_MS
865+
while (!isRunComplete(handle) && Date.now() < deadline) await sleep(100)
866+
if (!isRunComplete(handle)) await closeRunWindow(handle, env)
867+
}
868+
869+
/**
870+
* Stops every command the agent started that is still running, for sign-out: a plain shell's
871+
* agent command by its own process group, as Stop does, and every tmux run window still going.
872+
* A command the user started is not the agent's and is left alone.
873+
*/
874+
async stopAgentCommands(): Promise<void> {
875+
const stops: Promise<void>[] = []
876+
for (const session of this.sessions.values()) {
877+
const toolCallId = session.agentCommandToolCallId
878+
if (toolCallId) stops.push(this.stopCommand(session, toolCallId))
879+
for (const handle of this.pendingRuns.get(session.terminalId) ?? []) {
880+
if (!isRunComplete(handle)) stops.push(this.interruptTmuxRun(handle, session.env))
881+
}
882+
}
883+
await Promise.allSettled(stops)
884+
}
885+
861886
/** Waits for the command a run started to end, up to `ms`. */
862887
private async commandEnds(
863888
session: TerminalSession,
864889
toolCallId: string,
865890
ms: number
866891
): Promise<boolean> {
867892
const deadline = Date.now() + ms
868-
while (session.runningToolCallId === toolCallId) {
869-
if (Date.now() >= deadline || !session.alive) return session.runningToolCallId !== toolCallId
893+
while (session.agentCommandToolCallId === toolCallId) {
894+
if (Date.now() >= deadline || !session.alive) {
895+
return session.agentCommandToolCallId !== toolCallId
896+
}
870897
await sleep(50)
871898
}
872899
return true
@@ -878,15 +905,15 @@ export class TerminalService {
878905
* call and the same group still hold it, so a command the user starts meanwhile is never hit.
879906
*/
880907
private async stopCommand(session: TerminalSession, toolCallId: string): Promise<void> {
881-
if (session.runningToolCallId !== toolCallId) return
908+
if (session.agentCommandToolCallId !== toolCallId) return
882909
const groups = this.options.processGroups ?? OS_PROCESS_GROUPS
883910
const pgid = await groups.foreground(session.pid)
884-
if (session.runningToolCallId !== toolCallId) return
911+
if (session.agentCommandToolCallId !== toolCallId) return
885912
session.kill('SIGINT')
886913
for (const escalation of ['SIGTERM', 'SIGKILL'] as const) {
887914
if (await this.commandEnds(session, toolCallId, STOP_ESCALATION_MS)) return
888915
if (pgid === null || (await groups.foreground(session.pid)) !== pgid) return
889-
if (session.runningToolCallId !== toolCallId) return
916+
if (session.agentCommandToolCallId !== toolCallId) return
890917
logger.info('Stopped command ignored the previous signal; escalating', {
891918
toolCallId,
892919
signal: escalation,
@@ -1234,10 +1261,7 @@ export class TerminalService {
12341261
endWait = resolve
12351262
})
12361263
latch.stopRunning = async () => {
1237-
await sendKey(handle.window, 'C-c', terminal.env)
1238-
const deadline = Date.now() + STOP_ESCALATION_MS
1239-
while (!isRunComplete(handle) && Date.now() < deadline) await sleep(100)
1240-
if (!isRunComplete(handle)) await closeRunWindow(handle, terminal.env)
1264+
await this.interruptTmuxRun(handle, terminal.env)
12411265
endWait()
12421266
}
12431267
// A Stop that landed while the run window opened applies now.

‎apps/desktop/src/main/terminal/registry.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,13 @@ export class TerminalRegistry {
369369
return true
370370
}
371371

372+
/** Stops every command the agent started in any chat's terminals; the user's own are untouched. */
373+
async stopAgentCommands(): Promise<void> {
374+
await Promise.allSettled(
375+
[...this.entries.values()].map((entry) => entry.service.stopAgentCommands())
376+
)
377+
}
378+
372379
/** Tears down every shell owned by every chat scope. */
373380
dispose(): void {
374381
const entries = [...this.entries.values()]

‎apps/desktop/src/main/terminal/service.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ vi.mock('@/main/terminal/session', async () => {
7474
get runningToolCallId() {
7575
return state.toolCallId
7676
},
77+
get agentCommandToolCallId() {
78+
return state.toolCallId
79+
},
7780
kill: vi.fn((signal: string) => {
7881
if (signal === 'SIGINT' && state.interruptible) finishRun(130)
7982
}),
@@ -466,6 +469,26 @@ describe('stopping a tool call', () => {
466469
expect(session.kill).not.toHaveBeenCalled()
467470
})
468471

472+
it("stops the agent's running command at sign-out", async () => {
473+
const { terminal, session } = cancellableService()
474+
const { running } = await startRun(terminal, session, 'call-left-running')
475+
476+
await terminal.stopAgentCommands()
477+
478+
expect(session.kill).toHaveBeenCalledWith('SIGINT')
479+
await expect(running).resolves.toMatchObject({ ok: true, result: { exitCode: 130 } })
480+
})
481+
482+
it('leaves a command the user started alone at sign-out', async () => {
483+
const { terminal, session, processGroups } = cancellableService()
484+
session.setBusy(true)
485+
486+
await terminal.stopAgentCommands()
487+
488+
expect(session.kill).not.toHaveBeenCalled()
489+
expect(processGroups.signal).not.toHaveBeenCalled()
490+
})
491+
469492
it('leaves the terminal alone for a call it is not running', async () => {
470493
const { terminal, session, processGroups } = cancellableService()
471494
const { running } = await startRun(terminal, session, 'call-other')

‎apps/desktop/src/main/terminal/session.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ describe('TerminalSession command lifecycle', () => {
137137
const resultPromise = session.runCommand('vim', 'tool-call-1', 10_000)
138138
ptyStub.dataHandler?.('\u001b]633;C;test-nonce\u0007\u001b[?1049h')
139139
expect(await resultPromise).toMatchObject({ status: 'interactive' })
140+
// Detached from the tool call, the command is still the agent's until it exits.
141+
expect(session.runningToolCallId).toBeNull()
142+
expect(session.agentCommandToolCallId).toBe('tool-call-1')
140143

141144
expect(commandEvents.at(-1)).toMatchObject({
142145
terminalId: 'terminal-1',
@@ -153,6 +156,7 @@ describe('TerminalSession command lifecycle', () => {
153156
command: 'vim',
154157
exitCode: 0,
155158
})
159+
expect(session.agentCommandToolCallId).toBeNull()
156160
expect(commandEvents.at(-1)?.toolCallId).toBeUndefined()
157161
} finally {
158162
session.dispose()

‎apps/desktop/src/main/terminal/session.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,12 @@ export class TerminalSession {
316316
private altScreen = false
317317
private foregroundCommand: string | null = null
318318
private foregroundToolCallId: string | null = null
319+
/**
320+
* The agent tool call whose command is still running, until that command really ends. Unlike
321+
* {@link runningToolCallId} it survives an interactive command detaching from the tool call:
322+
* the command is still the agent's, so Stop and sign-out can still end it.
323+
*/
324+
private agentToolCallId: string | null = null
319325
private pendingCommand: PendingCommand | null = null
320326
/** Command line reported by the shell but not yet bracketed by output-start. */
321327
private announcedCommand: string | null = null
@@ -439,6 +445,11 @@ export class TerminalSession {
439445
return this.foregroundToolCallId
440446
}
441447

448+
/** The agent tool call whose command is still running in this shell, if one is. */
449+
get agentCommandToolCallId(): string | null {
450+
return this.agentToolCallId
451+
}
452+
442453
/**
443454
* Tab-strip view of this terminal. The label prefers the running command,
444455
* which is what the user is actually waiting on, and falls back to the
@@ -604,6 +615,7 @@ export class TerminalSession {
604615
}
605616
this.foregroundCommand = command
606617
this.foregroundToolCallId = toolCallId
618+
this.agentToolCallId = toolCallId
607619
this.emitState()
608620
this.callbacks.onCommand({ terminalId: this.terminalId, phase: 'start', command, toolCallId })
609621

@@ -983,6 +995,7 @@ export class TerminalSession {
983995

984996
this.foregroundCommand = null
985997
this.foregroundToolCallId = null
998+
this.agentToolCallId = null
986999
this.announcedCommand = null
9871000
this.altScreen = false
9881001
this.emitState()

0 commit comments

Comments
 (0)