Skip to content

Commit 237a921

Browse files
committed
fix(desktop): a tmux run starts only once its pane is tagged, and sign-out reaches runs of closed tabs
- A run's command now waits for its pane to be tagged as the run's before it starts. Untagged, it never starts and its pane closes by itself, so no pane is ever closed by an id a restarted tmux server might have handed to the user. A short command can no longer finish before the tag exists. - The run script is a file instead of a bash -c string. tmux hands its command to sh -c, which expanded the counter and PIPESTATUS meant for bash first, so tmux runs reported no exit code. - A run whose Sim terminal closed while it kept going in tmux is still stopped at sign-out and when Terminal is switched off. - Retiring an install id either replaces it or removes it from disk, so a retired id is never picked up again. A 409 whose new id cannot be saved backs off instead of retrying at once. - A failing approval notification no longer keeps the inbox read from claiming its calls.
1 parent fb8ae20 commit 237a921

5 files changed

Lines changed: 144 additions & 28 deletions

File tree

‎apps/desktop/src/main/desktop-executor/executor.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,14 @@ export class DesktopExecutor {
248248
for (const item of items) {
249249
if (item.kind === 'cancel') void this.stop(item.toolCallId, 'Stopped by the user.')
250250
}
251-
this.options.onApprovals?.(
252-
items.filter((item): item is DesktopApprovalItem => item.kind === 'approval_needed')
253-
)
251+
try {
252+
this.options.onApprovals?.(
253+
items.filter((item): item is DesktopApprovalItem => item.kind === 'approval_needed')
254+
)
255+
} catch (error) {
256+
// Notifications are a courtesy; the calls in this read still get claimed.
257+
logger.warn('Could not notify about desktop approvals', { error: getErrorMessage(error) })
258+
}
254259
for (const item of items) {
255260
if (item.kind !== 'call' || this.held.has(item.toolCallId)) continue
256261
if (this.paused || this.disposed) return

‎apps/desktop/src/main/desktop-executor/service.ts‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import { backoffWithJitter } from '@sim/utils/retry'
1616
import { truncate } from '@sim/utils/string'
1717
import type { Session } from 'electron'
1818
import { app, net, powerMonitor } from 'electron'
19-
import { readFileWithinLimit, writeJsonFileAtomically } from '@/main/atomic-json-file'
19+
import {
20+
readFileWithinLimit,
21+
removeFileIfPresent,
22+
writeJsonFileAtomically,
23+
} from '@/main/atomic-json-file'
2024
import {
2125
createDesktopExecutorClient,
2226
type DesktopExecutorClient,
@@ -136,11 +140,27 @@ export function createDesktopExecutorService(
136140
return next
137141
}
138142

139-
/** Rotates where a failure must not stop the caller; the next registration tries again. */
140-
async function retireInstallId(): Promise<void> {
141-
await rotateInstallId().catch((error) =>
143+
/**
144+
* Retires the current install id for good: a new one replaces it, or, if that cannot be saved,
145+
* the old one is removed so no later launch or sign-in can pick it up again. False when neither
146+
* worked and the old id is still on disk.
147+
*/
148+
async function retireInstallId(): Promise<boolean> {
149+
try {
150+
await rotateInstallId()
151+
return true
152+
} catch (error) {
142153
logger.warn('Could not save a new desktop install id', { error: getErrorMessage(error) })
143-
)
154+
}
155+
try {
156+
await removeFileIfPresent(identityPath)
157+
return true
158+
} catch (error) {
159+
logger.warn('Could not remove the retired desktop install id', {
160+
error: getErrorMessage(error),
161+
})
162+
return false
163+
}
144164
}
145165

146166
function fetchWithAppSession(url: string, init: RequestInit): Promise<Response> {
@@ -288,8 +308,18 @@ export function createDesktopExecutorService(
288308
// The id belongs to another account (a copied profile); this install takes a new one.
289309
logger.warn('Desktop install id is registered to another account; minting a new one')
290310
await resetExecutor()
291-
await retireInstallId()
292-
scheduleRegistration(0)
311+
if (await retireInstallId()) {
312+
scheduleRegistration(0)
313+
} else {
314+
// The conflicting id is still on disk: retrying at once would only conflict again.
315+
registrationAttempt += 1
316+
scheduleRegistration(
317+
backoffWithJitter(registrationAttempt, null, {
318+
baseMs: 2_000,
319+
maxMs: REGISTRATION_RETRY_MAX_MS,
320+
})
321+
)
322+
}
293323
return
294324
}
295325
if (error instanceof DeviceRequestError && error.unregistered) {

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,11 @@ export class TerminalService {
248248
* Held here so the terminal's own lifecycle can reclaim them.
249249
*/
250250
private readonly pendingRuns = new Map<string, TmuxRunHandle[]>()
251+
/**
252+
* Agent runs still going in tmux after their Sim terminal closed: the tmux session outlives the
253+
* tab, but sign-out must still stop them.
254+
*/
255+
private readonly orphanedRuns = new Map<TmuxRunHandle, NodeJS.ProcessEnv>()
251256
/** Runs a `run` call is still waiting on; their files are read when the wait ends. */
252257
private readonly awaitedRuns = new Set<TmuxRunHandle>()
253258
/** Awaited runs released meanwhile (their terminal closed); their files go once the wait ends. */
@@ -508,10 +513,13 @@ export class TerminalService {
508513
* Releases every tracked run for a terminal, finished or not. The terminal is
509514
* going away, so nothing will ever read these files again.
510515
*/
511-
private releasePendingRuns(terminalId: string): void {
516+
private releasePendingRuns(terminalId: string, env?: NodeJS.ProcessEnv): void {
512517
const pending = this.pendingRuns.get(terminalId)
513518
if (!pending) return
514-
for (const handle of pending) this.releaseRun(handle)
519+
for (const handle of pending) {
520+
if (env && !isRunComplete(handle)) this.orphanedRuns.set(handle, env)
521+
this.releaseRun(handle)
522+
}
515523
this.pendingRuns.delete(terminalId)
516524
}
517525

@@ -525,10 +533,11 @@ export class TerminalService {
525533
const closedCwd = session.currentCwd
526534
const order = [...this.sessions.keys()]
527535
const index = order.indexOf(terminalId)
536+
const env = session.env
528537
session.dispose()
529538
this.sessions.delete(terminalId)
530539
this.tmuxCache.delete(terminalId)
531-
this.releasePendingRuns(terminalId)
540+
this.releasePendingRuns(terminalId, env)
532541

533542
this.rememberClosed(closedCwd)
534543
// Nothing is left for the user to hold on to; the next shell the agent
@@ -890,6 +899,10 @@ export class TerminalService {
890899
if (!isRunComplete(handle)) stops.push(stopRun(handle, session.env, STOP_ESCALATION_MS))
891900
}
892901
}
902+
for (const [handle, env] of this.orphanedRuns) {
903+
stops.push(stopRun(handle, env, STOP_ESCALATION_MS))
904+
}
905+
this.orphanedRuns.clear()
893906
await Promise.allSettled(stops)
894907
}
895908

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

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
1+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
4+
import { sleep } from '@sim/utils/helpers'
45
import { afterEach, describe, expect, it } from 'vitest'
56
import {
67
awaitRun,
@@ -120,6 +121,12 @@ switch (args[0]) {
120121
const pane = '%' + state.nextPane++
121122
state.panes[pane] = { window, options: {} }
122123
save()
124+
// Runs the pane's command for real, the way tmux would, when a test asks for it.
125+
if (process.env.FAKE_TMUX_EXEC) {
126+
require('node:child_process')
127+
.spawn('sh', ['-c', args[args.length - 1]], { detached: true, stdio: 'ignore' })
128+
.unref()
129+
}
123130
process.stdout.write(window + ' ' + pane + '\\n')
124131
break
125132
}
@@ -150,7 +157,7 @@ switch (args[0]) {
150157
}
151158
`
152159

153-
function fakeTmux() {
160+
function fakeTmux(options: { exec?: boolean } = {}) {
154161
const dir = mkdtempSync(join(tmpdir(), 'fake-tmux-'))
155162
const stateFile = join(dir, 'state.json')
156163
const binary = join(dir, 'tmux')
@@ -161,7 +168,12 @@ function fakeTmux() {
161168
const read = (): FakeTmuxState => JSON.parse(readFileSync(stateFile, 'utf8'))
162169
return {
163170
dir,
164-
env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, FAKE_TMUX_STATE: stateFile },
171+
env: {
172+
...process.env,
173+
PATH: `${dir}:${process.env.PATH ?? ''}`,
174+
FAKE_TMUX_STATE: stateFile,
175+
...(options.exec ? { FAKE_TMUX_EXEC: '1' } : {}),
176+
},
165177
read,
166178
write,
167179
/** The user splits a run's window: a new pane of theirs beside the run's. */
@@ -232,16 +244,24 @@ describe('stopping a tmux run touches only its own pane', () => {
232244
expect(tmux.read().log).toEqual([])
233245
})
234246

235-
it('closes a run it could not tag instead of leaving an unstoppable command', async () => {
247+
it('never lets a run it could not tag start, and closes no pane by id to stop it', async () => {
236248
const tmux = fakeTmux()
237249
dirs.push(tmux.dir)
238250
tmux.write({ ...tmux.read(), fail: { 'set-option': 'invalid option: @sim-run-id' } })
239251

240-
const started = await startRun('agent', 'sleep 600', null, tmux.env)
252+
const result = await startRun('agent', 'sleep 600', null, tmux.env)
253+
254+
expect(result).toMatchObject({ error: expect.stringContaining('was not run') })
255+
// No pane is closed by an id that a restarted server might have handed to the user.
256+
expect(tmux.read().log).toEqual([])
257+
})
258+
259+
it('lets a tagged run start only once its pane is tagged', async () => {
260+
const tmux = fakeTmux()
261+
const run = await started(tmux)
241262

242-
expect('error' in started).toBe(true)
243-
expect(tmux.read().log).toEqual(['kill-pane %0'])
244-
expect(tmux.read().panes).toEqual({})
263+
expect(tmux.read().panes[run.pane]?.options['@sim-run-id']).toBe(run.runId)
264+
expect(existsSync(join(run.statusPath, '..', 'go'))).toBe(true)
245265
})
246266

247267
it('neither stops nor gives up on a run while tmux cannot be asked', async () => {
@@ -254,4 +274,26 @@ describe('stopping a tmux run touches only its own pane', () => {
254274

255275
expect(tmux.read().log).toEqual([])
256276
})
277+
278+
it('runs a tagged command for real, and never runs one it could not tag', async () => {
279+
const tagged = fakeTmux({ exec: true })
280+
dirs.push(tagged.dir)
281+
const run = await startRun('agent', 'echo ran', null, tagged.env)
282+
if ('error' in run) throw new Error(run.error)
283+
await expect
284+
.poll(() => pollRun(run), { timeout: 10_000 })
285+
.toMatchObject({
286+
done: true,
287+
exitCode: 0,
288+
})
289+
290+
const untagged = fakeTmux({ exec: true })
291+
dirs.push(untagged.dir)
292+
untagged.write({ ...untagged.read(), fail: { 'set-option': 'invalid option' } })
293+
const marker = join(untagged.dir, 'ran')
294+
await startRun('agent', `touch ${JSON.stringify(marker)}`, null, untagged.env)
295+
await sleep(6_000)
296+
297+
expect(existsSync(marker)).toBe(false)
298+
}, 20_000)
257299
})

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

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@
1717
* of the shell that launched it.
1818
*/
1919
import { spawn } from 'node:child_process'
20-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
20+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2121
import { tmpdir } from 'node:os'
2222
import { join } from 'node:path'
2323
import { createLogger } from '@sim/logger'
2424
import type { TerminalPaneState } from '@sim/terminal-protocol'
25+
import { getErrorMessage } from '@sim/utils/errors'
2526
import { sleep } from '@sim/utils/helpers'
2627
import { generateId } from '@sim/utils/id'
2728

@@ -337,6 +338,7 @@ export async function startRun(
337338
const dir = mkdtempSync(join(tmpdir(), 'sim-tmux-run-'))
338339
const outPath = join(dir, 'out')
339340
const statusPath = join(dir, 'status')
341+
const goPath = join(dir, 'go')
340342
const dispose = () => {
341343
try {
342344
rmSync(dir, { recursive: true, force: true })
@@ -354,8 +356,27 @@ export async function startRun(
354356
// writing to the unlinked inode — but an unredirected `printf` would fail
355357
// into the pipeline and print `No such file or directory` into the user's own
356358
// tmux window, minutes after they closed the tab.
357-
const script = `${command}\nprintf %s "\${PIPESTATUS[0]}" > ${JSON.stringify(statusPath)} 2>/dev/null`
358-
const wrapper = `bash -lc ${JSON.stringify(`{ ${script}; } 2>&1 | tee ${JSON.stringify(outPath)}`)}`
359+
//
360+
// The command waits for its pane to be tagged as this run's (the go file), so nothing runs that
361+
// a later stop could not recognize. Untagged, the script gives up after five seconds and its
362+
// pane closes on its own; no one has to close a pane whose id might no longer be its own.
363+
//
364+
// The script is a file rather than a `bash -c` string: tmux hands its command to `sh -c`, which
365+
// would expand `$` references meant for bash (the gate's counter, PIPESTATUS) before bash ran.
366+
const scriptPath = join(dir, 'run.sh')
367+
writeFileSync(
368+
scriptPath,
369+
[
370+
'i=0',
371+
`while [ ! -e ${JSON.stringify(goPath)} ] && [ "$i" -lt 100 ]; do sleep 0.05; i=$((i + 1)); done`,
372+
`[ -e ${JSON.stringify(goPath)} ] || exit 0`,
373+
`{ ${command}`,
374+
`printf %s "\${PIPESTATUS[0]}" > ${JSON.stringify(statusPath)} 2>/dev/null; } 2>&1 | tee ${JSON.stringify(outPath)}`,
375+
'',
376+
].join('\n'),
377+
{ mode: 0o600 }
378+
)
379+
const wrapper = `bash -l ${JSON.stringify(scriptPath)}`
359380

360381
const args = [
361382
'new-window',
@@ -382,14 +403,19 @@ export async function startRun(
382403
// a pane the user opened later under the same id, so it sends nothing at all.
383404
const tagged = await runTmux(['set-option', '-p', '-t', pane, RUN_ID_OPTION, runId], env)
384405
if (!tagged.ok) {
385-
// Untagged, nothing could ever stop it safely later, so it does not get to run. The pane was
386-
// created a moment ago by this call, so its id is still ours to close.
387-
await runTmux(['kill-pane', '-t', pane], env)
406+
// Untagged, nothing could stop it safely later, so it never starts: without the go file the
407+
// wrapper exits by itself.
388408
dispose()
389409
return {
390-
error: `tmux could not mark the command's pane, so it was closed straight away (${tagged.stderr.trim() || 'no detail'}). It may have started; check before running it again.`,
410+
error: `tmux could not mark the command's pane (${tagged.stderr.trim() || 'no detail'}), so the command was not run.`,
391411
}
392412
}
413+
try {
414+
writeFileSync(goPath, '')
415+
} catch (error) {
416+
dispose()
417+
return { error: `The command could not be started: ${getErrorMessage(error)}` }
418+
}
393419

394420
return { window, pane, runId, outPath, statusPath, dispose }
395421
}

0 commit comments

Comments
 (0)