From 300f7c3b692bd653fcb99f7b9a4cb61b1a54ec46 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sun, 17 May 2026 21:13:13 +0200 Subject: [PATCH] feat(agents): cap CARGO_BUILD_JOBS in agent launch env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepend CARGO_BUILD_JOBS=max(2, floor(cpus/4)) to every agent launch command so concurrent fleet runs don't oversubscribe the host when child cargo builds fan out. Harmless on non-Rust agents — cargo is the only consumer of the env var. Tests previously hardcoded CARGO_BUILD_JOBS=8 (passed only on 32-CPU hosts); they now compute the expected value from os.cpus() so CI on any CPU count stays green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.openspec.yaml | 2 ++ .../notes.md | 27 ++++++++++++++++ src/agents/launch.js | 11 ++++++- test/agents-launch.test.js | 31 +++++++++++++------ test/agents-start-dry-run.test.js | 8 ++--- test/agents-start.test.js | 7 +++-- 6 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 openspec/changes/agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02/.openspec.yaml create mode 100644 openspec/changes/agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02/notes.md diff --git a/openspec/changes/agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02/.openspec.yaml b/openspec/changes/agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02/.openspec.yaml new file mode 100644 index 00000000..66da1ae9 --- /dev/null +++ b/openspec/changes/agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-17 diff --git a/openspec/changes/agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02/notes.md b/openspec/changes/agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02/notes.md new file mode 100644 index 00000000..6a16400f --- /dev/null +++ b/openspec/changes/agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02/notes.md @@ -0,0 +1,27 @@ +# agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02 (minimal / T1) + +Branch: `agent/claude/cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02` + +Cap `CARGO_BUILD_JOBS` in the env prefix of every agent launch command so concurrent agent runs don't oversubscribe the host when child cargo builds fan out. Formula: `max(2, floor(os.cpus().length / 4))`. Harmless on non-Rust agents (env var is only read by cargo). + +## Files + +- `src/agents/launch.js` — new `buildResourceEnv()`, prepended to `buildSessionEnv()` in `buildAgentLaunchCommand`. +- `test/agents-launch.test.js` — expected `CARGO_BUILD_JOBS=` is computed from `os.cpus()` (was hardcoded `=8`, only passed on 32-CPU hosts). Added a coverage test asserting the formula. +- `test/agents-start-dry-run.test.js` — regex relaxed to `CARGO_BUILD_JOBS=\d+`. +- `test/agents-start.test.js` — canonical-session `launchCommand` assertions updated to use the computed `CARGO` prefix. + +## Verification + +- `node --test test/agents-launch.test.js test/agents-start-dry-run.test.js test/agents-start.test.js` — 24/24 pass. +- `npm test` — 570 pass / 23 fail; failure count identical to clean `main` baseline (pre-existing, unrelated). + +## Handoff + +- Handoff: change=`agent-claude-cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02`; branch=`agent/claude/cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02`; scope=`cap CARGO_BUILD_JOBS in agent launch env`; action=`finish cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/claude/cap-cargo-jobs-in-agent-launch-env-2026-05-17-21-02 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/agents/launch.js b/src/agents/launch.js index b2f93c6a..fb48f902 100644 --- a/src/agents/launch.js +++ b/src/agents/launch.js @@ -185,6 +185,14 @@ function buildPromptCommand(parts, agent, prompt) { return commandToShell([...parts, prompt]); } +function buildResourceEnv() { + const cpus = require('node:os').cpus().length || 8; + // Cap each agent's cargo parallelism to avoid overwhelming the system + // when multiple agents build concurrently. + const jobs = Math.max(2, Math.floor(cpus / 4)); + return [`CARGO_BUILD_JOBS=${jobs}`]; +} + function buildAgentLaunchCommand(options) { if (!options || typeof options !== 'object') { throw new TypeError('options are required'); @@ -207,7 +215,8 @@ function buildAgentLaunchCommand(options) { } const launchCommand = buildPromptCommand(baseParts, agent, prompt); - const envPrefix = buildSessionEnv(agent, sessionId).join(' '); + const envParts = [...buildResourceEnv(), ...buildSessionEnv(agent, sessionId)]; + const envPrefix = envParts.join(' '); const launchWithEnv = envPrefix ? `${envPrefix} ${launchCommand}` : launchCommand; if (!worktreePath) return launchWithEnv; return `cd ${shellQuote(worktreePath)} && ${launchWithEnv}`; diff --git a/test/agents-launch.test.js b/test/agents-launch.test.js index 75655ac6..779a6462 100644 --- a/test/agents-launch.test.js +++ b/test/agents-launch.test.js @@ -2,12 +2,16 @@ const assert = require('node:assert/strict'); const test = require('node:test'); +const os = require('node:os'); const { buildAgentLaunchCommand, buildAgentResumeCommand, } = require('../src/agents/launch'); +const JOBS = Math.max(2, Math.floor((os.cpus().length || 8) / 4)); +const CARGO = `CARGO_BUILD_JOBS=${JOBS}`; + test('builds codex launch commands with positional prompts', () => { assert.equal( buildAgentLaunchCommand({ @@ -17,7 +21,7 @@ test('builds codex launch commands with positional prompts', () => { permissionMode: 'workspace-write', sessionId: 'session-1', }), - "cd '/tmp/work tree' && OMX_SESSION_ID='session-1' 'codex' '--permission-mode' 'workspace-write' 'fix tests'", + `cd '/tmp/work tree' && ${CARGO} OMX_SESSION_ID='session-1' 'codex' '--permission-mode' 'workspace-write' 'fix tests'`, ); }); @@ -28,7 +32,7 @@ test('builds claude launch commands with argument prompts', () => { prompt: 'review code', permissionMode: 'acceptEdits', }), - "'claude' '--permission-mode' 'acceptEdits' 'review code'", + `${CARGO} 'claude' '--permission-mode' 'acceptEdits' 'review code'`, ); }); @@ -38,7 +42,7 @@ test('builds opencode launch commands with positional prompts', () => { agentId: 'opencode', prompt: 'implement feature', }), - "'opencode' 'implement feature'", + `${CARGO} 'opencode' 'implement feature'`, ); }); @@ -49,7 +53,7 @@ test('builds cursor launch commands with argument prompts', () => { prompt: 'inspect current branch', worktreePath: '/repo/worktree', }), - "cd '/repo/worktree' && 'cursor-agent' 'inspect current branch'", + `cd '/repo/worktree' && ${CARGO} 'cursor-agent' 'inspect current branch'`, ); }); @@ -60,7 +64,7 @@ test('builds gemini launch commands with argument prompts', () => { prompt: 'summarize repo', sessionId: 'session-2', }), - "OMX_SESSION_ID='session-2' 'gemini' 'summarize repo'", + `${CARGO} OMX_SESSION_ID='session-2' 'gemini' 'summarize repo'`, ); }); @@ -69,27 +73,36 @@ test('quotes prompts with single quotes, newlines, and dollar signs safely', () assert.equal( buildAgentLaunchCommand({ agentId: 'codex', prompt }), - "'codex' 'say '\\''hello'\\''\nthen echo $HOME'", + `${CARGO} 'codex' 'say '\\''hello'\\''\nthen echo $HOME'`, ); assert.equal( buildAgentLaunchCommand({ agentId: 'gemini', prompt }), - "'gemini' 'say '\\''hello'\\''\nthen echo $HOME'", + `${CARGO} 'gemini' 'say '\\''hello'\\''\nthen echo $HOME'`, ); assert.equal( buildAgentLaunchCommand({ agentId: 'cursor', prompt }), - "'cursor-agent' 'say '\\''hello'\\''\nthen echo $HOME'", + `${CARGO} 'cursor-agent' 'say '\\''hello'\\''\nthen echo $HOME'`, ); }); test('omits prompts when none are supplied', () => { assert.equal( buildAgentLaunchCommand({ agentId: 'codex', worktreePath: '/repo' }), - "cd '/repo' && 'codex'", + `cd '/repo' && ${CARGO} 'codex'`, ); }); +test('caps CARGO_BUILD_JOBS at floor(cpus/4), minimum 2', () => { + const cmd = buildAgentLaunchCommand({ agentId: 'codex', prompt: 'x' }); + const match = cmd.match(/CARGO_BUILD_JOBS=(\d+)/); + assert.ok(match, 'CARGO_BUILD_JOBS env var must be present'); + const jobs = Number(match[1]); + assert.ok(jobs >= 2, `expected jobs >= 2, got ${jobs}`); + assert.equal(jobs, JOBS); +}); + test('builds resume commands for supported agents', () => { assert.equal(buildAgentResumeCommand('codex', 'workspace-write'), "'codex' 'resume' '--permission-mode' 'workspace-write'"); assert.equal(buildAgentResumeCommand('claude'), "'claude' '--continue'"); diff --git a/test/agents-start-dry-run.test.js b/test/agents-start-dry-run.test.js index d49982c1..aee6aa15 100644 --- a/test/agents-start-dry-run.test.js +++ b/test/agents-start-dry-run.test.js @@ -31,7 +31,7 @@ test('gx agents start dry-run prints the planned codex branch, worktree, and lau result.stdout, new RegExp(`worktree: ${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.omx/agent-worktrees/[^\\n]*codex__fix-auth-tests-2026-04-29-21-30`), ); - assert.match(result.stdout, /launch: cd '.*' && 'codex' 'fix auth tests'/); + assert.match(result.stdout, /launch: cd '.*' && CARGO_BUILD_JOBS=\d+ 'codex' 'fix auth tests'/); assert.match(result.stdout, /No branch, worktree, session metadata, or agent process was created\./); const branchCheck = runCmd( @@ -64,7 +64,7 @@ test('gx agents start dry-run supports claude worktree planning and rejects unkn assert.equal(claudeResult.status, 0, claudeResult.stderr || claudeResult.stdout); assert.match(claudeResult.stdout, /branch: agent\/claude\/update-docs-2026-04-29-21-31/); assert.match(claudeResult.stdout, /\.omc\/agent-worktrees\/[^ \n]*claude__update-docs-2026-04-29-21-31/); - assert.match(claudeResult.stdout, /launch: cd '.*' && 'claude' 'update docs'/); + assert.match(claudeResult.stdout, /launch: cd '.*' && CARGO_BUILD_JOBS=\d+ 'claude' 'update docs'/); const invalidResult = runNode( ['agents', 'start', 'update docs', '--agent', 'bogus', '--dry-run'], @@ -164,7 +164,7 @@ test('gx agents start --dry-run --json emits Colony-ready launch plan', () => { assert.equal(payload.branch, 'agent/codex/colony-dry-run-2026-04-30-00-05'); assert.match(payload.worktree, /\.omx\/agent-worktrees\/repo__codex__colony-dry-run-2026-04-30-00-05$/); assert.deepEqual(payload.claimedFiles, ['README.md']); - assert.match(payload.launchCommand, /cd '.*' && 'codex' 'colony dry run'/); + assert.match(payload.launchCommand, /cd '.*' && CARGO_BUILD_JOBS=\d+ 'codex' 'colony dry run'/); assert.equal(payload.tmuxSession, null); assert.equal(payload.tmuxTarget, null); assert.deepEqual(payload.metadata, { @@ -300,5 +300,5 @@ test('interactive launcher panel asks for a task when opened empty', () => { const output = stdout.chunks.join(''); assert.match(output, /task: fix auth/); assert.match(output, /branch: agent\/codex\/fix-auth-/); - assert.match(output, /launch: cd '.*' && 'codex' 'fix auth'/); + assert.match(output, /launch: cd '.*' && CARGO_BUILD_JOBS=\d+ 'codex' 'fix auth'/); }); diff --git a/test/agents-start.test.js b/test/agents-start.test.js index 8e5eb87c..91b9c1dc 100644 --- a/test/agents-start.test.js +++ b/test/agents-start.test.js @@ -5,6 +5,9 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const CARGO_JOBS = Math.max(2, Math.floor((os.cpus().length || 8) / 4)); +const CARGO = `CARGO_BUILD_JOBS=${CARGO_JOBS}`; + function loadStartWithMocks({ runPackageAsset, createAgentSession, @@ -95,7 +98,7 @@ test('agents start creates canonical session after successful branch start', () base: 'main', claims: [], metadata: {}, - launchCommand: "cd '/repo/.omx/agent-worktrees/repo__codex__fix-auth' && 'codex' 'fix auth'", + launchCommand: `cd '/repo/.omx/agent-worktrees/repo__codex__fix-auth' && ${CARGO} 'codex' 'fix auth'`, tmux: null, status: 'active', }, @@ -183,7 +186,7 @@ test('agents start claim failure updates canonical session to claim-failed', () base: 'main', claims: ['src/auth.js'], metadata: {}, - launchCommand: "cd '/repo/.omx/agent-worktrees/repo__codex__fix-auth' && 'codex' 'fix auth'", + launchCommand: `cd '/repo/.omx/agent-worktrees/repo__codex__fix-auth' && ${CARGO} 'codex' 'fix auth'`, tmux: null, status: 'claim-failed', claimFailure: {