Skip to content

Commit 589f7d8

Browse files
committed
fix(desktop): stop every terminal operation, answer only from granted folders, and bound the outbox in bytes
- Stop reaches `input`, `kill` and pane `close`, not just `run` and `handoff`. A Stop that lands while the session resolves means none of them starts. A batch of keys or lines stops between keystrokes and says some input may already have arrived. - A terminal call stopped while it waited for the chat's previous operation never reaches the terminal. - Sign-out stops running actions before it waits on a reconcile in flight. - The outbox budgets result data in UTF-8 bytes, so a journal of non-ASCII results still reads back after a restart. - A local read, grep, glob, list or stat answers only while its folder is still granted when it finishes. This covers the chat view's path as well. - Grep says when its results are truncated, as glob does. - A doorbell stream that keeps closing as soon as it opens backs off instead of reconnecting every second. - The not-started results follow #8666's wording: what happened, that nothing ran, and what the model should do instead of retrying.
1 parent ef82a99 commit 589f7d8

18 files changed

Lines changed: 327 additions & 47 deletions

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export interface ApprovalNotifierDeps {
2525
}) => ApprovalNotification | null
2626
}
2727

28+
/**
29+
* Creates the notifier the executor feeds each inbox read's approval items to. `update` notifies
30+
* once per newly waiting call and closes notifications for calls decided since; `clear` closes
31+
* them all, for sign-out.
32+
*/
2833
export function createApprovalNotifier(deps: ApprovalNotifierDeps) {
2934
/** Calls already brought to the user's attention, by notification or by being on screen. */
3035
const shown = new Map<string, ApprovalNotification | null>()

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ async function errorMessage(response: Response): Promise<string> {
6767
return typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`
6868
}
6969

70+
/**
71+
* Creates the client for one install id. Each request times out on its own and fails with a
72+
* {@link DeviceRequestError}, so the executor decides what to retry; responses are parsed before
73+
* they are returned, and a malformed one is a 502.
74+
*/
7075
export function createDesktopExecutorClient(
7176
options: DesktopExecutorClientOptions
7277
): DesktopExecutorClient {

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sleep } from '@sim/utils/helpers'
12
import { describe, expect, it, vi } from 'vitest'
23
import { DeviceRequestError } from '@/main/desktop-executor/client'
34
import { InboxDoorbell, parseServerSentEvents } from '@/main/desktop-executor/doorbell'
@@ -90,6 +91,28 @@ describe('InboxDoorbell', () => {
9091
doorbell.stop()
9192
})
9293

94+
it('backs off when every stream ends as soon as it opens', async () => {
95+
const opened: number[] = []
96+
const doorbell = new InboxDoorbell({
97+
client: {
98+
openInboxStream: async () => {
99+
opened.push(Date.now())
100+
return new ReadableStream<Uint8Array>({ start: (controller) => controller.close() })
101+
},
102+
},
103+
onRing: vi.fn(),
104+
onUnregistered: vi.fn(),
105+
retryBaseMs: 5,
106+
})
107+
doorbell.start()
108+
await sleep(400)
109+
doorbell.stop()
110+
111+
// A fixed 5 ms retry would open dozens; doubling from 5 ms opens a handful.
112+
expect(opened.length).toBeGreaterThan(2)
113+
expect(opened.length).toBeLessThan(15)
114+
})
115+
93116
it('replaces a connection that goes silent past the heartbeat', async () => {
94117
const { doorbell, connections } = harness({ staleAfterMs: 30 })
95118
doorbell.start()

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const logger = createLogger('DesktopExecutorDoorbell')
1616
/** Sim heartbeats every 30 s; two missed ones mean the connection is gone. */
1717
const STALE_STREAM_MS = 75_000
1818
const RECONNECT_MAX_MS = 30_000
19+
/** A stream that stayed open this long was healthy; its clean end starts backoff afresh. */
20+
const HEALTHY_STREAM_MS = 60_000
1921
const HANDSHAKE_TIMEOUT_MS = 15_000
2022

2123
interface DoorbellOptions {
@@ -96,10 +98,16 @@ export class InboxDoorbell {
9698
let attempt = 0
9799
const current = () => this.loopGeneration === generation
98100
while (current()) {
101+
const openedAt = Date.now()
99102
const rotated = await this.connectOnce().then(
100103
(result) => {
101-
attempt = 0
102-
return result === 'rotated'
104+
if (result === 'rotated') {
105+
attempt = 0
106+
return true
107+
}
108+
// A stream something keeps closing right away is a failing connection, not a healthy one.
109+
attempt = Date.now() - openedAt >= HEALTHY_STREAM_MS ? 0 : attempt + 1
110+
return false
103111
},
104112
(error: unknown) => {
105113
if (this.reconnectNow) {

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,31 @@ describe('registration', () => {
455455
expect(executor.heldCallCount()).toBe(0)
456456
})
457457

458+
it('stops running actions at sign-out without waiting on a claim still in flight', async () => {
459+
const { sim, journal, runner, executor } = setup()
460+
sim.inbox = [callItem('call-1', 'chat-a')]
461+
await executor.reconcile()
462+
await vi.waitFor(() => expect(runner.started).toEqual(['call-1']))
463+
const answer = deferred<void>()
464+
const claim = sim.client.claim
465+
sim.client.claim = async (toolCallId) => {
466+
await answer.promise
467+
return claim(toolCallId)
468+
}
469+
sim.inbox = [callItem('call-2', 'chat-b')]
470+
const reading = executor.reconcile()
471+
await vi.waitFor(() => expect(journal.entries.get('call-2')?.state).toBe('claiming'))
472+
473+
const signingOut = executor.dispose()
474+
await vi.waitFor(() => expect(runner.cancelled).toEqual(['call-1']))
475+
answer.resolve()
476+
await Promise.all([reading, signingOut])
477+
478+
expect(runner.started).toEqual(['call-1'])
479+
expect(executor.heldCallCount()).toBe(0)
480+
expect(journal.entries.size).toBe(0)
481+
})
482+
458483
it('does not keep a claim that Sim answers after sign-out', async () => {
459484
const { sim, journal, runner, executor } = setup({ leaseRenewMs: 10 })
460485
const answer = deferred<void>()

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ const DEFAULT_MAX_HELD_CALLS = 32
2626
const DELIVERY_RETRY_MAX_MS = 30_000
2727

2828
const NOT_STARTED_AFTER_RESTART =
29-
'Not run: the Sim desktop app restarted before this action started, so nothing happened on the user’s computer.'
29+
'Not run: this action never started, because the Sim desktop app restarted before it began. Nothing happened on the user’s computer. Do not retry it in this turn; tell the user, who can ask again.'
3030
const OUTCOME_UNKNOWN_AFTER_RESTART =
3131
'The Sim desktop app restarted while this action was running, so its result was lost. It may already have taken effect: inspect the current state before repeating it, and do not retry it automatically.'
3232
const STOPPED_BEFORE_START = 'Stopped before the Sim desktop app started this action.'
3333
const STOPPED_WHILE_RUNNING = 'Stopped while the Sim desktop app was running this action.'
3434
const NOT_RECORDED =
35-
'Not run: the Sim desktop app could not record this action on the user’s computer before starting it, so it did not start it.'
35+
'Not run: this action never started, because the Sim desktop app could not record it on the user’s computer first. Nothing happened on the user’s computer. Do not retry it in this turn; tell the user, who can ask again later.'
3636
const RESULT_TOO_LARGE =
3737
'The action finished, but its result was too large to send back. Do not repeat a side-effecting action; inspect the current state instead.'
3838

@@ -161,8 +161,16 @@ export class DesktopExecutor {
161161
/** Sign-out: stops every action and forgets every call; the session that owned them is gone. */
162162
async dispose(): Promise<void> {
163163
this.disposed = true
164-
// An inbox read in flight may still be claiming; let it see `disposed` before clearing.
164+
const stopping = this.dropHeld()
165+
// A claim still in flight sees `disposed` once Sim answers and is never held; the journal is
166+
// cleared only after it, so its `claiming` record does not outlive the session.
165167
await this.reconciling?.catch(() => {})
168+
await Promise.all([stopping, this.dropHeld()])
169+
await this.options.journal.clear()
170+
}
171+
172+
/** Releases every held call and stops the actions already running. */
173+
private async dropHeld(): Promise<void> {
166174
const held = [...this.held.values()]
167175
for (const entry of held) this.release(entry)
168176
await Promise.allSettled(
@@ -173,7 +181,6 @@ export class DesktopExecutor {
173181
return this.options.runner.cancel(entry.call)
174182
})
175183
)
176-
await this.options.journal.clear()
177184
}
178185

179186
private async reconcileOnce(): Promise<void> {
@@ -234,6 +241,7 @@ export class DesktopExecutor {
234241
this.noteRequestFailure('Desktop call was not claimed', error, { toolCallId })
235242
return
236243
}
244+
if (this.disposed) return
237245
// Best effort: an unrecorded token leaves `claiming`, which recovery treats conservatively.
238246
await this.record({ toolCallId, state: 'claimed', executionToken: call.executionToken })
239247
const entry: HeldCall = {

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,29 @@ describe('executor journal', () => {
102102
expect(JSON.stringify(restored)).toContain('resultOmitted')
103103
})
104104

105+
it('budgets results by their encoded size, not their length in characters', async () => {
106+
const filePath = await journalPath()
107+
const encryption = testEncryption()
108+
const journal = createExecutorJournal(filePath, encryption)
109+
// Three bytes per character: within the budget by length, three times over it in bytes.
110+
const pageText = '€'.repeat(6 * 1024 * 1024)
111+
for (const toolCallId of ['call-1', 'call-2', 'call-3']) {
112+
await journal.put({
113+
toolCallId,
114+
state: 'result',
115+
executionToken: `token-${toolCallId}`,
116+
completion: { status: 'success', message: 'done', data: { pageText } },
117+
})
118+
}
119+
120+
const restored = await createExecutorJournal(filePath, encryption).load()
121+
expect(restored).toHaveLength(3)
122+
const kept = restored.filter(
123+
(entry) => entry.state === 'result' && entry.completion.data?.pageText === pageText
124+
)
125+
expect(kept).toHaveLength(1)
126+
})
127+
105128
it('starts empty from a corrupt or foreign file instead of failing', async () => {
106129
const filePath = await journalPath()
107130
await writeFile(filePath, '{"version":1,"ciphertext":"not-base64-json"}')

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ const JOURNAL_VERSION = 1
2929
/** Results can carry a screenshot, so the bound is the size of a few of them. */
3030
const MAX_JOURNAL_BYTES = 64 * 1024 * 1024
3131
/**
32-
* Result data kept on disk across all entries. Encryption and base64 grow it by about a third,
33-
* so a journal within this budget always reads back under {@link MAX_JOURNAL_BYTES}.
32+
* Result data kept on disk across all entries, in UTF-8 bytes. Encryption and base64 grow it by
33+
* about a third, so a journal within this budget always reads back under {@link MAX_JOURNAL_BYTES}.
3434
*/
35-
const MAX_PERSISTED_RESULT_CHARS = 32 * 1024 * 1024
35+
const MAX_PERSISTED_RESULT_BYTES = 32 * 1024 * 1024
3636
const RESULT_NOT_KEPT =
3737
'The action finished, but its result was too large to keep on this computer. Do not repeat a side-effecting action; inspect the current state instead.'
3838

@@ -41,10 +41,10 @@ const RESULT_NOT_KEPT =
4141
* kept as finished without its data, so the file never grows past what a restart can read.
4242
*/
4343
function boundedEntries(entries: Map<string, JournalEntry>): JournalEntry[] {
44-
let budget = MAX_PERSISTED_RESULT_CHARS
44+
let budget = MAX_PERSISTED_RESULT_BYTES
4545
return [...entries.values()].map((entry) => {
4646
if (entry.state !== 'result' || entry.completion.data === undefined) return entry
47-
const size = JSON.stringify(entry.completion.data).length
47+
const size = Buffer.byteLength(JSON.stringify(entry.completion.data), 'utf8')
4848
if (size <= budget) {
4949
budget -= size
5050
return entry

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,32 @@ describe('background terminal calls', () => {
6363
expect((await second).status).toBe('success')
6464
expect(started).toEqual(['wedged', 'next'])
6565
})
66+
67+
it('never starts a terminal operation stopped while it waited for the chat terminal', async () => {
68+
vi.useFakeTimers()
69+
let releaseWedged: (response: TerminalToolResponse) => void = () => {}
70+
const started: string[] = []
71+
const runner = runnerWithTerminal((toolCallId) => {
72+
started.push(toolCallId)
73+
if (toolCallId === 'wedged')
74+
return new Promise((resolve) => {
75+
releaseWedged = resolve
76+
})
77+
return Promise.resolve({ ok: true, result: { output: '' } })
78+
})
79+
const first = runner.run(terminalCall('wedged', 'input'), new AbortController().signal)
80+
await vi.advanceTimersByTimeAsync(15_000)
81+
await first
82+
83+
const stop = new AbortController()
84+
const second = runner.run(terminalCall('stopped', 'run'), stop.signal)
85+
await vi.advanceTimersByTimeAsync(0)
86+
stop.abort()
87+
const completion = await second
88+
releaseWedged({ ok: true, result: {} })
89+
await vi.advanceTimersByTimeAsync(0)
90+
91+
expect(completion.status).toBe('error')
92+
expect(started).toEqual(['wedged'])
93+
})
6694
})

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

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ const USER_LOCAL_TOOLS: ReadonlySet<string> = new Set(['read', 'grep', 'glob'])
4646

4747
/** The model learns a call never ran because a surface is switched off on this machine. */
4848
function surfaceOff(surface: string): DesktopToolCompletion {
49-
const message = `Not run: ${surface} is switched off in the Sim desktop app's settings, so nothing happened on the user's computer. Continue without it, or ask the user to switch it on.`
49+
const message = `Not run: this action never started, because ${surface} is switched off in the Sim desktop app’s settings. Nothing happened on the user’s computer. Do not retry it in this turn; continue without it, or ask the user to switch it on.`
5050
return { status: 'error', message, data: { error: message, notStarted: true } }
5151
}
5252

5353
function unsupported(toolName: string): DesktopToolCompletion {
54-
const message = `Not run: this version of the Sim desktop app cannot run ${toolName} in the background, so nothing happened on the user's computer.`
54+
const message = `Not run: this action never started, because this version of the Sim desktop app cannot run ${toolName} in the background. Nothing happened on the user’s computer. Do not retry it in this turn; tell the user to update the Sim desktop app.`
5555
return { status: 'error', message, data: { error: message, notStarted: true } }
5656
}
5757

@@ -88,6 +88,12 @@ export interface DesktopToolRunnerDeps {
8888
}
8989
}
9090

91+
/** Resolves once the signal aborts, which may be never. */
92+
function untilAborted(signal: AbortSignal): Promise<void> {
93+
if (signal.aborted) return Promise.resolve()
94+
return new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
95+
}
96+
9197
/** Resolves with the work's result, or with `onTimeout`'s once `timeoutMs` passes first. */
9298
async function withDeadline<T>(
9399
work: Promise<T>,
@@ -146,7 +152,10 @@ export function createDesktopToolRunner(deps: DesktopToolRunnerDeps): DesktopToo
146152
)
147153
}
148154

149-
async function runTerminal(call: ClaimedDesktopCall): Promise<DesktopToolCompletion> {
155+
async function runTerminal(
156+
call: ClaimedDesktopCall,
157+
signal: AbortSignal
158+
): Promise<DesktopToolCompletion> {
150159
if (!deps.preferences().terminalEnabled) return surfaceOff('the terminal')
151160
const { operation } = call.args
152161
if (!isTerminalOperation(operation)) {
@@ -159,7 +168,12 @@ export function createDesktopToolRunner(deps: DesktopToolRunnerDeps): DesktopToo
159168
const timeoutMs = terminalOperationTimeoutMs(operation)
160169
// An operation reported as unresponsive may still land; the chat's next one waits for it, so
161170
// two never act on the same terminals at once.
162-
await unsettledTerminalWork.get(call.chatId)
171+
const previous = unsettledTerminalWork.get(call.chatId)
172+
if (previous) await Promise.race([previous, untilAborted(signal)])
173+
// Stopped while it waited: the terminal never heard of it, so its own cancel cannot reach it.
174+
if (signal.aborted) {
175+
return terminalToolFailure('The terminal action was stopped before it started.', 'CANCELLED')
176+
}
163177
const operationDone = deps.terminal.executeTool(call.chatId, call.toolCallId, operation, args)
164178
const settled = operationDone.then(
165179
() => undefined,
@@ -195,7 +209,7 @@ export function createDesktopToolRunner(deps: DesktopToolRunnerDeps): DesktopToo
195209
async run(call, signal) {
196210
try {
197211
if (isCurrentBrowserToolName(call.toolName)) return await runBrowser(call, call.toolName)
198-
if (call.toolName === 'terminal') return await runTerminal(call)
212+
if (call.toolName === 'terminal') return await runTerminal(call, signal)
199213
if (!deps.accountDataAvailable()) return surfaceOff('local file access')
200214
if (call.toolName === 'read_local_file') {
201215
return localFileReadCompletion(await deps.localFiles.read(call))

0 commit comments

Comments
 (0)