Skip to content

Commit 78cd332

Browse files
authored
improvement(mothership): let a code call run for up to fifteen minutes (#9588)
* improvement(mothership): let a code call run for up to fifteen minutes A render or build that needs more than five minutes was cut off at the ceiling and failed, and the model started over in smaller pieces or escaped the call with a background process the paused workbench then stalled. Mothership code calls now run up to 900 seconds. The worker's schema still decides what a model may request, so this is inert until the worker raises its own maximum. * fix(mothership): renew a running code call's sandbox CLI scope past its expiry * fix(mothership): keep a long code call's upload bindings for its whole lifetime
1 parent c735ded commit 78cd332

5 files changed

Lines changed: 80 additions & 9 deletions

File tree

‎apps/sim/lib/mothership/tools/handlers/function-execute-session.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ describe('executeFunctionExecute session plumbing', () => {
146146
expect(mockExecuteTool.mock.calls[1][1].timeout).toBe(90_000)
147147

148148
await executeFunctionExecute({ code: 'x', language: 'python', timeout: 45_000 }, BASE_CONTEXT)
149-
expect(mockExecuteTool.mock.calls[2][1].timeout).toBe(300_000)
149+
expect(mockExecuteTool.mock.calls[2][1].timeout).toBe(900_000)
150150
})
151151
})
152152

@@ -187,8 +187,9 @@ describe.each([
187187
{ timeout: 7, expected: 7_000 },
188188
{ timeout: '90', expected: 90_000 },
189189
{ timeout: 300, expected: 300_000 },
190-
{ timeout: 601, expected: 300_000 },
191-
{ timeout: 45_000, expected: 300_000 },
190+
{ timeout: 900, expected: 900_000 },
191+
{ timeout: 901, expected: 900_000 },
192+
{ timeout: 45_000, expected: 900_000 },
192193
{ timeout: 0, expected: 60_000 },
193194
{ timeout: 'invalid', expected: 60_000 },
194195
])('maps $timeout seconds to $expected milliseconds once', async ({ timeout, expected }) => {

‎apps/sim/lib/mothership/tools/handlers/function-execute.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ const logger = createLogger('CopilotFunctionExecute')
7676
const MAX_MOUNTED_FILES = 500
7777
/** The call budget includes cold allocation and mounting, before the user's code starts. */
7878
const DEFAULT_TIMEOUT_SECONDS = 60
79-
const MAX_TIMEOUT_SECONDS = 300
79+
/**
80+
* The longest call the worker may request. Long renders and builds need it whole: cut off at
81+
* five minutes, they fail and start over. The worker's schema decides what a model may ask for.
82+
*/
83+
const MAX_TIMEOUT_SECONDS = 900
8084

8185
/** An input the run asked for that was not mounted, and why; paths are the model's own. */
8286
export interface ExcludedInput {

‎apps/sim/lib/mothership/tools/sandbox-project-files.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '@/lib/mothership/tools/sandbox-callback'
1818
import {
1919
recordSandboxResourceEffects,
20+
SANDBOX_SCOPE_STATE_TTL_SECONDS,
2021
type SandboxResourceScope,
2122
} from '@/lib/mothership/tools/sandbox-resources'
2223
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
@@ -66,10 +67,11 @@ export async function proxySandboxProjectFileRequest(
6667
uploadBinding: {
6768
async record(uploadId) {
6869
await redis().eval(
69-
"redis.call('SADD', KEYS[1], ARGV[1]); redis.call('EXPIRE', KEYS[1], 600); return 1",
70+
"redis.call('SADD', KEYS[1], ARGV[1]); redis.call('EXPIRE', KEYS[1], ARGV[2]); return 1",
7071
1,
7172
bindingKey,
72-
uploadId
73+
uploadId,
74+
SANDBOX_SCOPE_STATE_TTL_SECONDS
7375
)
7476
},
7577
async contains(uploadId) {

‎apps/sim/lib/mothership/tools/sandbox-resources.test.ts‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,33 @@ vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
1111

1212
const { values, lists, seen, persist, redis } = vi.hoisted(() => {
1313
const values = new Map<string, string>()
14+
const expiresAt = new Map<string, number>()
1415
const lists = new Map<string, string[]>()
1516
const seen = new Map<string, Set<string>>()
17+
const live = (key: string) => {
18+
if ((expiresAt.get(key) ?? Number.POSITIVE_INFINITY) <= Date.now()) {
19+
values.delete(key)
20+
expiresAt.delete(key)
21+
}
22+
return values.has(key)
23+
}
1624
return {
1725
values,
1826
lists,
1927
seen,
2028
persist: vi.fn(async () => {}),
2129
redis: {
22-
set: vi.fn(async (key: string, value: string) => {
30+
set: vi.fn(async (key: string, value: string, _mode?: 'EX', ttlSeconds?: number) => {
2331
values.set(key, value)
32+
if (ttlSeconds) expiresAt.set(key, Date.now() + ttlSeconds * 1000)
33+
}),
34+
expire: vi.fn(async (key: string, ttlSeconds: number) => {
35+
if (!live(key)) return 0
36+
expiresAt.set(key, Date.now() + ttlSeconds * 1000)
37+
return 1
2438
}),
25-
get: vi.fn(async (key: string) => values.get(key) ?? null),
26-
exists: vi.fn(async (key: string) => (values.has(key) ? 1 : 0)),
39+
get: vi.fn(async (key: string) => (live(key) ? (values.get(key) ?? null) : null)),
40+
exists: vi.fn(async (key: string) => (live(key) ? 1 : 0)),
2741
del: vi.fn(async (key: string) => {
2842
values.delete(key)
2943
lists.delete(key)
@@ -183,6 +197,36 @@ describe('sandbox resource ownership and publication', () => {
183197
})
184198
})
185199

200+
it('keeps the callback scope live for a call that outlasts its expiry, and revokes it after', async () => {
201+
vi.useFakeTimers()
202+
try {
203+
let release = () => {}
204+
const finished = new Promise<void>((resolve) => {
205+
release = resolve
206+
})
207+
let token = ''
208+
const call = withSandboxResourceScope(
209+
identity,
210+
new AbortController().signal,
211+
undefined,
212+
async () => {
213+
token =
214+
(await sandboxResourceEndpoint('https://sim.test', args, 'secret')).split('/').at(-1) ??
215+
''
216+
await finished
217+
}
218+
)
219+
await vi.advanceTimersByTimeAsync(15 * 60_000)
220+
expect(await readSandboxResourceScope(token, 'secret')).not.toBeNull()
221+
release()
222+
await vi.advanceTimersByTimeAsync(1_000)
223+
await call
224+
expect(await readSandboxResourceScope(token, 'secret')).toBeNull()
225+
} finally {
226+
vi.useRealTimers()
227+
}
228+
})
229+
186230
it('revokes on cancellation and never emits an abandoned callback', async () => {
187231
const controller = new AbortController()
188232
const emit = vi.fn()

‎apps/sim/lib/mothership/tools/sandbox-resources.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ import { isProjectsEnabled } from '@/lib/projects/rollout.server'
1616

1717
const logger = createLogger('MothershipSandboxResources')
1818
const CONTEXT_TTL_SECONDS = 600
19+
/** A running call renews its scope well inside the TTL; the TTL only bounds a crashed owner. */
20+
const CONTEXT_RENEW_MS = (CONTEXT_TTL_SECONDS * 1000) / 3
21+
/**
22+
* Outlives the longest code call (15 minutes plus its settle margin). State keyed by a scope token
23+
* is reachable only through that token's live context, so outliving the context widens nothing.
24+
*/
25+
export const SANDBOX_SCOPE_STATE_TTL_SECONDS = 20 * 60
1926
const POLL_MS = 200
2027
const scopeSchema = z
2128
.object({
@@ -233,10 +240,23 @@ export async function withSandboxResourceScope<T>(
233240
() => {},
234241
() => {}
235242
)
243+
const renew = async () => {
244+
if (!active.token || signal.aborted) return
245+
try {
246+
await redisClient().expire(contextKey(active.token), CONTEXT_TTL_SECONDS)
247+
} catch {
248+
logger.warn('Sandbox resource scope renewal deferred', { toolCallId: identity.toolCallId })
249+
}
250+
}
251+
let renewedAt = Date.now()
236252
try {
237253
while (running) {
238254
await Promise.race([settled, sleep(POLL_MS)])
239255
await drain()
256+
if (Date.now() - renewedAt >= CONTEXT_RENEW_MS) {
257+
renewedAt = Date.now()
258+
await renew()
259+
}
240260
}
241261
return await execution
242262
} finally {

0 commit comments

Comments
 (0)