Skip to content

Commit 126e360

Browse files
authored
Merge pull request #43 from superagent-ai/fix-sandbox-memory
Add resources config to Daytona sandbox creation, fix OOM on npm install (fixes #40)
2 parents b3a18a4 + f778919 commit 126e360

7 files changed

Lines changed: 195 additions & 4 deletions

File tree

examples/node22-bookworm-computer-use-image.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ const CUA_DRIVER_INSTALL_COMMAND = [
5757
'"$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh)"',
5858
].join(' ');
5959

60+
/**
61+
* Sandbox memory default (GiB). Daytona's platform default (no `resources` override) is a 1GiB
62+
* cgroup limit -- confirmed by inspecting `/sys/fs/cgroup/memory.max` inside a real sandbox -- which
63+
* reliably gets a full `npm install` of AutoBrin-flue's dependency tree OOM-killed a few seconds in
64+
* (see superagent-ai/benchpress#40). Confirmed empirically: 2/2 sandboxes at the 1GiB default were
65+
* OOM-killed (`bash`'s own "Killed" job-control message, the exact signature from #40) within ~35s;
66+
* 3/3 sandboxes at 4GiB completed the same `npm install` successfully in ~35-37s.
67+
*/
68+
const DEFAULT_SANDBOX_MEMORY_GIB = 4;
69+
6070
export function buildNode22BookwormComputerUseImage(): Image {
6171
return Image.base('node:22-bookworm')
6272
.runCommands(
@@ -95,6 +105,7 @@ async function main(): Promise<void> {
95105
kind: 'image',
96106
image: buildNode22BookwormComputerUseImage(),
97107
autoStopInterval: AUTO_STOP_SAFETY_NET_MINUTES,
108+
resources: { memory: DEFAULT_SANDBOX_MEMORY_GIB },
98109
});
99110

100111
try {

src/contenders/autobrin.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
33
import path from 'node:path';
44
import { pathToFileURL } from 'node:url';
55
import { spawn } from 'node:child_process';
6-
import type { Image, Sandbox } from '@daytona/sdk';
6+
import type { Image, Resources, Sandbox } from '@daytona/sdk';
77
import type { AgentRunner, BenchmarkTask, ContenderClaim, ConfirmedFinding, NormalizedResult, ProposedPatch, RunContext, RunControls, TargetHandle } from './types.js';
88
import { webappTargetMetadata } from './types.js';
99
import { ensureAutobrinCheckout } from '../lib/checkout.js';
@@ -38,6 +38,15 @@ export type AutobrinContenderConfig = {
3838
visionModel?: string;
3939
/** Keep the sandbox running after the engagement instead of deleting it (debugging only). `transport: 'daytona'` only. */
4040
keepSandbox?: boolean;
41+
/**
42+
* Sandbox CPU/memory/disk allocation. `transport: 'daytona'` only, and only takes effect when
43+
* creating from `image` -- Daytona snapshots fix their resources at snapshot-build time
44+
* (`CreateSnapshotParams.resources`), so there is nothing to override at sandbox-creation time
45+
* for `snapshot`. Omitting this uses the Daytona platform default, which is not large enough for
46+
* a full `npm install` of AutoBrin-flue's dependency tree under load and can get the process
47+
* OOM-killed -- see superagent-ai/benchpress#40.
48+
*/
49+
resources?: Pick<Resources, 'cpu' | 'memory' | 'disk'>;
4150
};
4251

4352
export type AutobrinRunOptions = {
@@ -370,6 +379,11 @@ export function createAutobrinRunner(options: AutobrinRunOptions): AgentRunner {
370379
if (!options.config.image && !options.config.snapshot) {
371380
throw new Error(`autobrin contender "${contenderId}": transport "daytona" requires "image" or "snapshot"`);
372381
}
382+
if (options.config.resources && options.config.snapshot) {
383+
throw new Error(
384+
`autobrin contender "${contenderId}": "resources" is only supported when creating a sandbox from "image" (Daytona snapshots fix resources at build time)`,
385+
);
386+
}
373387
}
374388

375389
return {
@@ -499,6 +513,7 @@ async function runViaDaytona(input: RunInput): Promise<NormalizedResult> {
499513
ref: config.ref,
500514
image: config.image,
501515
snapshot: config.snapshot,
516+
resources: config.resources,
502517
visionModel: config.visionModel,
503518
payload,
504519
keepSandbox: config.keepSandbox,

src/daytona/launcher.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Image, Sandbox } from '@daytona/sdk';
1+
import type { Image, Resources, Sandbox } from '@daytona/sdk';
22
import { ensureComputerUseAssets, ensureComputerUseStarted } from './assets.js';
33
import { bootstrapAutobrinFlue, prepareRepoTarget, prepareWebappTarget } from './bootstrap.js';
44
import {
@@ -18,6 +18,13 @@ export type DaytonaRunOptions = {
1818
/** String image ref (registry tag) or a declarative `Image` built with `Image.base(...)`. */
1919
image?: string | Image;
2020
snapshot?: string;
21+
/**
22+
* CPU/memory/disk allocation for the sandbox. Only takes effect when creating from `image`:
23+
* `createSandbox()`'s snapshot params (`CreateSandboxFromSnapshotParams`) have no `resources`
24+
* field, since Daytona snapshots fix resources at snapshot-build time. See
25+
* `AutobrinContenderConfig.resources` in `src/contenders/autobrin.ts` (superagent-ai/benchpress#40).
26+
*/
27+
resources?: Resources;
2128
visionModel?: string;
2229
payload: EngagementPayload | unknown;
2330
keepSandbox?: boolean;
@@ -73,6 +80,7 @@ export async function runDaytonaEngagement(options: DaytonaRunOptions): Promise<
7380
image: options.image!,
7481
envVars: sandboxEnv,
7582
autoStopInterval: AUTO_STOP_SAFETY_NET_MINUTES,
83+
resources: options.resources,
7684
},
7785
);
7886

tests/autobrin-contender.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,26 @@ describe('createAutobrinRunner transport validation', () => {
350350
const runner = createAutobrinRunner({ config: { id: 'x', type: 'autobrin', transport: 'daytona', snapshot: 'daytona-large' } });
351351
expect(runner.id).toBe('x');
352352
});
353+
354+
// Regression (superagent-ai/benchpress#40): Daytona sandboxes had no way to request more than
355+
// the platform default resources, so a full `npm install` of AutoBrin-flue's dependency tree
356+
// could get OOM-killed. `resources` only has an effect when creating from `image` -- Daytona
357+
// snapshots fix their resources at snapshot-build time, so `createSandbox()`'s snapshot params
358+
// have no `resources` field to set (see `CreateSandboxFromSnapshotParams` in `@daytona/sdk`).
359+
it('accepts transport "daytona" with "resources" combined with "image"', () => {
360+
const runner = createAutobrinRunner({
361+
config: { id: 'x', type: 'autobrin', transport: 'daytona', image: 'some-image', resources: { memory: 4 } },
362+
});
363+
expect(runner.id).toBe('x');
364+
});
365+
366+
it('rejects "resources" combined with "snapshot" (snapshots fix resources at build time)', () => {
367+
expect(() =>
368+
createAutobrinRunner({
369+
config: { id: 'x', type: 'autobrin', transport: 'daytona', snapshot: 'daytona-large', resources: { memory: 4 } },
370+
}),
371+
).toThrow(/"resources" is only supported when creating a sandbox from "image"/);
372+
});
353373
});
354374

355375
describe('materializeTarget', () => {

tests/autobrin-daytona-sequencing.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,65 @@ describe('autobrin daytona transport: webapp modality (superagent-ai/benchpress#
162162
expect(launcherMocks.runDaytonaEngagement).not.toHaveBeenCalled();
163163
});
164164
});
165+
166+
// Regression (superagent-ai/benchpress#40): AutobrinContenderConfig had no way to request more
167+
// than the Daytona platform default sandbox resources, so a full `npm install` of AutoBrin-flue's
168+
// dependency tree could get OOM-killed. runDaytonaEngagement's own resources handling (only applied
169+
// for the "image" creation branch) is covered in tests/daytona-launcher.test.ts; these tests only
170+
// cover that runViaDaytona actually forwards config.resources into the options it builds.
171+
describe('autobrin daytona transport: resources config threading (superagent-ai/benchpress#40)', () => {
172+
const baseTask = { id: 't1', benchmarkId: 'repo-cve-smoke' };
173+
const baseTarget = {
174+
benchmarkId: 'repo-cve-smoke',
175+
taskId: 't1',
176+
modality: 'repo' as const,
177+
repo: 'owner/repo',
178+
sha: 'abc123',
179+
};
180+
const baseControls = { model: 'kimi-azure/kimi-k2.6' };
181+
182+
const tmpDirs: string[] = [];
183+
184+
afterEach(() => {
185+
vi.restoreAllMocks();
186+
for (const dir of tmpDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
187+
});
188+
189+
function makeContext() {
190+
const root = mkdtempSync(path.join(tmpdir(), 'benchpress-daytona-resources-'));
191+
tmpDirs.push(root);
192+
return { runId: 'run1', resultsDir: path.join(root, 'results'), engagementsDir: path.join(root, 'engagements') };
193+
}
194+
195+
function mockSuccessfulEngagement() {
196+
checkoutMocks.ensureAutobrinCheckout.mockReset().mockResolvedValue({ root: '/cache/x', ref: 'staging', commitSha: 'deadbeef' });
197+
launcherMocks.runDaytonaEngagement.mockReset().mockResolvedValue({
198+
sandboxId: 'sandbox-1',
199+
engagement: { exitCode: 0, streamLogPath: 'x', resultPath: 'y', resultJson: {} },
200+
computerUse: {},
201+
keptSandbox: false,
202+
});
203+
}
204+
205+
it('forwards config.resources into the options passed to runDaytonaEngagement', async () => {
206+
mockSuccessfulEngagement();
207+
const runner = createAutobrinRunner({
208+
config: { id: 'x', type: 'autobrin', transport: 'daytona', image: 'test-image', resources: { cpu: 2, memory: 4, disk: 20 } },
209+
});
210+
211+
await runner.run({ task: baseTask, target: baseTarget, controls: baseControls, context: makeContext() });
212+
213+
const options = launcherMocks.runDaytonaEngagement.mock.calls[0]?.[0] as { resources?: unknown };
214+
expect(options.resources).toEqual({ cpu: 2, memory: 4, disk: 20 });
215+
});
216+
217+
it('omits resources entirely when not configured (unchanged default behavior)', async () => {
218+
mockSuccessfulEngagement();
219+
const runner = createAutobrinRunner({ config: { id: 'x', type: 'autobrin', transport: 'daytona', image: 'test-image' } });
220+
221+
await runner.run({ task: baseTask, target: baseTarget, controls: baseControls, context: makeContext() });
222+
223+
const options = launcherMocks.runDaytonaEngagement.mock.calls[0]?.[0] as { resources?: unknown };
224+
expect(options.resources).toBeUndefined();
225+
});
226+
});

tests/daytona-launcher.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,54 @@ describe('runDaytonaEngagement computer-use start gating (superagent-ai/benchpre
221221
expect(clientMocks.deleteDaytonaSandbox).toHaveBeenCalledWith('sandbox-1', baseOptions.env);
222222
});
223223
});
224+
225+
// Regression (superagent-ai/benchpress#40): sandboxes had no way to request more than the Daytona
226+
// platform default resources, so a full `npm install` of AutoBrin-flue's dependency tree could get
227+
// OOM-killed (confirmed live: the platform default is a 1GiB cgroup memory limit that reliably
228+
// OOM-kills that install; see the PR description for the empirical repro).
229+
describe('runDaytonaEngagement resources threading (superagent-ai/benchpress#40)', () => {
230+
beforeEach(() => {
231+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
232+
clientMocks.createSandbox.mockReset().mockResolvedValue({ id: 'sandbox-1' });
233+
clientMocks.deleteDaytonaSandbox.mockReset().mockResolvedValue(undefined);
234+
assetsMocks.ensureComputerUseStarted.mockReset().mockResolvedValue(true);
235+
engagementMocks.runEngagementViaHttp.mockReset().mockResolvedValue({
236+
exitCode: 0,
237+
streamLogPath: '/logs/stream.jsonl',
238+
resultPath: '/result.json',
239+
resultJson: { status: 'ok' },
240+
});
241+
});
242+
243+
afterEach(() => {
244+
vi.restoreAllMocks();
245+
});
246+
247+
const payload = { modality: 'repo' as const, repo: 'owner/repo' };
248+
const env = { DAYTONA_API_KEY: 'test' };
249+
250+
it('passes resources through to createSandbox when creating from "image"', async () => {
251+
await runDaytonaEngagement({ image: 'test-image', resources: { cpu: 2, memory: 4, disk: 20 }, payload, env });
252+
253+
expect(clientMocks.createSandbox).toHaveBeenCalledTimes(1);
254+
const params = clientMocks.createSandbox.mock.calls[0]?.[1] as Record<string, unknown>;
255+
expect(params.kind).toBe('image');
256+
expect(params.resources).toEqual({ cpu: 2, memory: 4, disk: 20 });
257+
});
258+
259+
it('omits resources when not provided (unchanged default behavior)', async () => {
260+
await runDaytonaEngagement({ image: 'test-image', payload, env });
261+
262+
const params = clientMocks.createSandbox.mock.calls[0]?.[1] as Record<string, unknown>;
263+
expect(params.resources).toBeUndefined();
264+
});
265+
266+
it('never applies resources on the "snapshot" branch (Daytona snapshots fix resources at build time)', async () => {
267+
await runDaytonaEngagement({ snapshot: 'daytona-large', resources: { memory: 4 }, payload, env });
268+
269+
expect(clientMocks.createSandbox).toHaveBeenCalledTimes(1);
270+
const params = clientMocks.createSandbox.mock.calls[0]?.[1] as Record<string, unknown>;
271+
expect(params.kind).toBe('snapshot');
272+
expect(params).not.toHaveProperty('resources');
273+
});
274+
});

tests/daytona.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
33
import { connect, createServer } from 'node:net';
44
import { tmpdir } from 'node:os';
55
import path from 'node:path';
6-
import { afterEach, describe, expect, it } from 'vitest';
7-
import { getDaytonaClientConfig } from '../src/daytona/client.js';
6+
import type { Daytona } from '@daytona/sdk';
7+
import { afterEach, describe, expect, it, vi } from 'vitest';
8+
import { createSandbox, getDaytonaClientConfig } from '../src/daytona/client.js';
89
import { buildEngagementRunScript } from '../src/daytona/engagement.js';
910
import { assertAllowedFlueRef, buildSandboxEnv } from '../src/daytona/env.js';
1011
import {
@@ -32,6 +33,29 @@ describe('daytona client config', () => {
3233
});
3334
});
3435

36+
// Regression (superagent-ai/benchpress#40): confirms createSandbox() generically forwards a
37+
// "resources" field (cpu/memory/disk) through to daytona.create() -- it never had a special case
38+
// blocking it, but nothing upstream ever set it either. See src/daytona/launcher.ts for where
39+
// "resources" actually gets set on the SandboxCreateInput passed here.
40+
describe('createSandbox (superagent-ai/benchpress#40: resources threading)', () => {
41+
it('forwards "resources" (and other fields) to daytona.create(), stripping only "kind"', async () => {
42+
const create = vi.fn(async (_params: Record<string, unknown>, _options: Record<string, unknown>) => ({ id: 'fake-sandbox' }));
43+
const fakeDaytona = { create } as unknown as Daytona;
44+
45+
await createSandbox(fakeDaytona, {
46+
kind: 'image',
47+
image: 'test-image',
48+
resources: { cpu: 2, memory: 4, disk: 20 },
49+
envVars: { FOO: 'bar' },
50+
});
51+
52+
expect(create).toHaveBeenCalledTimes(1);
53+
const [params, options] = create.mock.calls[0]!;
54+
expect(params).toEqual({ image: 'test-image', resources: { cpu: 2, memory: 4, disk: 20 }, envVars: { FOO: 'bar' } });
55+
expect(options).toEqual({ timeout: 120 });
56+
});
57+
});
58+
3559
describe('daytona env', () => {
3660
it('enforces branch pins for AUTOBRIN_FLUE_REF', () => {
3761
expect(() => assertAllowedFlueRef('feature/foo')).toThrow('branch pin');

0 commit comments

Comments
 (0)