diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a92a5ae0..354d733a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,11 @@ jobs: run: npm run test:coverage timeout-minutes: 3 - - name: Integration tests (slow, Docker) + - name: E2E tests (fake provider) + run: npm run test:e2e + timeout-minutes: 5 + + - name: Integration tests (slow) run: npm run test:slow timeout-minutes: 15 @@ -96,6 +100,29 @@ jobs: fail_ci_if_error: false continue-on-error: true + # Tier 2: worktree/container isolation e2e (real Docker). Non-blocking and + # opt-in via workflow_dispatch - the Docker sub-tests in `check`'s + # "Integration tests (slow)" step self-skip under CI (this is what that + # step's Docker coverage was previously silently missing). + e2e-docker: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Docker isolation e2e + run: npm run test:e2e:docker + timeout-minutes: 15 + # Cross-platform/version compatibility (main only) # NOTE: macOS runners cost 10x Linux minutes - keep this scoped to main. install-matrix: diff --git a/FIX_TESTS.md b/FIX_TESTS.md deleted file mode 100644 index d220f7d3..00000000 --- a/FIX_TESTS.md +++ /dev/null @@ -1,125 +0,0 @@ -# Test Failures Snapshot - -## Overall - -- 853 passing (24m) -- 16 pending -- 15 failing - -## Failing Tests (15) - -1. MessageBus Integration - - Suite: Batch Publishing (Atomic) - - Test: should prevent interleaving with concurrent agents - - Error: AssertionError [ERR_ASSERTION]: Agent A's messages should be contiguous - - Expected vs actual: 1 expected, 2 actual - - Location: tests/integration/message-bus.test.js:441:14 - - Status: Fixed (verified locally) - -2. npm install retry logic - - Suite: Retry mechanism - - Test: retries npm install on failure with exponential backoff - - Error: AssertionError [ERR_ASSERTION]: Should retry twice after initial failure - - Expected vs actual: 3 expected, 2 actual - - Location: tests/integration/npm-install-retry.test.js:110:14 - - Status: Fixed (pending verification) - -3. npm install retry logic - - Suite: Retry mechanism - - Test: fails after max retries exceeded - - Error: AssertionError [ERR_ASSERTION]: Should attempt 3 times total - - Expected vs actual: 3 expected, 2 actual - - Location: tests/integration/npm-install-retry.test.js:148:14 - - Status: Fixed (pending verification) - -4. npm install retry logic - - Suite: Retry mechanism - - Test: does not retry if first attempt succeeds - - Error: AssertionError [ERR_ASSERTION]: Should only attempt once on success - - Expected vs actual: 1 expected, 4 actual - - Location: tests/integration/npm-install-retry.test.js:183:14 - - Status: Fixed (pending verification) - -5. npm install retry logic - - Suite: Retry mechanism - - Test: handles execution errors during retry - - Error: AssertionError [ERR_ASSERTION]: Should retry after execution errors - - Expected vs actual: 3 expected, 1 actual - - Location: tests/integration/npm-install-retry.test.js:249:14 - - Status: Fixed (pending verification) - -6. npm install retry logic - - Suite: Exponential backoff timing - - Test: uses correct delay calculation: 2s, 4s, 8s - - Error: AssertionError [ERR_ASSERTION]: Should have 3 attempts - - Expected vs actual: 3 expected, 2 actual - - Location: tests/integration/npm-install-retry.test.js:291:14 - - Status: Fixed (pending verification) - -7. npm install retry logic - - Suite: Non-fatal failure behavior - - Test: logs warning but continues when all retries fail - - Error: AssertionError [ERR_ASSERTION]: Should log warnings about npm install failures - - Expected vs actual: true expected, false actual - - Location: tests/integration/npm-install-retry.test.js:343:9 - - Status: Fixed (pending verification) - -8. Orchestrator Isolation Mode Integration - - Suite: Container Lifecycle - - Test: should preserve workspace on stop for resume capability - - Error: AssertionError [ERR_ASSERTION]: Isolated workspace should be PRESERVED for resume: /tmp/zeroshot-isolated/flaming-surge-53 - - Expected vs actual: true expected, false actual - - Location: tests/integration/orchestrator-isolation.test.js:250:7 - - Status: Fixed (pending verification) - -9. Orchestrator Isolation Mode Integration - - Suite: Resume Capability - - Test: should recreate container on resume using preserved workspace - - Error: AssertionError [ERR_ASSERTION]: Workspace should be preserved for resume - - Expected vs actual: true expected, false actual - - Location: tests/integration/orchestrator-isolation.test.js:451:7 - - Status: Fixed (pending verification) - -10. Orchestrator Isolation Mode Integration - - Suite: Resume Capability - - Test: should not be resumable after kill (cluster removed) - - Error: AssertionError [ERR_ASSERTION]: Workspace should exist before kill - - Expected vs actual: true expected, false actual - - Location: tests/integration/orchestrator-isolation.test.js:547:7 - - Status: Fixed (pending verification) - -11. Orchestrator Isolation Mode Integration - - Suite: Container Lifecycle - - Test: should preserve workspace on stop for resume capability - - Error: AssertionError [ERR_ASSERTION]: Isolated workspace should be PRESERVED for resume: /tmp/zeroshot-isolated/sonic-glyph-68 - - Expected vs actual: true expected, false actual - - Location: tests/integration/slow/orchestrator-isolation.test.js:253:7 - - Status: Fixed (pending verification) - -12. Orchestrator Isolation Mode Integration - - Suite: Resume Capability - - Test: should recreate container on resume using preserved workspace - - Error: AssertionError [ERR_ASSERTION]: Workspace should be preserved for resume - - Expected vs actual: true expected, false actual - - Location: tests/integration/slow/orchestrator-isolation.test.js:454:7 - - Status: Fixed (pending verification) - -13. Orchestrator Isolation Mode Integration - - Suite: Resume Capability - - Test: should not be resumable after kill (cluster removed) - - Error: AssertionError [ERR_ASSERTION]: Workspace should exist before kill - - Expected vs actual: true expected, false actual - - Location: tests/integration/slow/orchestrator-isolation.test.js:550:7 - - Status: Fixed (pending verification) - -14. Isolated Mode Output Capture - - Test: should read agent output from log file, not spawn stdout - - Error: Timeout of 150000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. - - Location: tests/unit/isolated-mode-output-capture.test.js - - Status: Fixed (pending verification) - -15. Isolated Mode Output Capture - - Hook: "after each" hook for "should read agent output from log file, not spawn stdout" - - Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. - - Location: tests/unit/isolated-mode-output-capture.test.js - - Status: Fixed (pending verification) diff --git a/cli/index.js b/cli/index.js index 45949724..e5cfcbe8 100755 --- a/cli/index.js +++ b/cli/index.js @@ -189,7 +189,9 @@ function normalizeRunOptions(options) { if (options.docker) { options.worktree = false; } - options.autoMerge = Boolean(options.ship); + // autoMerge is NOT stored here — it is derived from the run plan (delivery === + // 'ship') at every consumer. Writing it here unconditionally would clobber an + // explicit autoMerge intent (e.g. a future `--auto-merge` flag) back to false. } async function runClusterPreflight({ input, options, providerOverride, settings, forceProvider }) { @@ -2610,6 +2612,7 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps), -N (Line if (!process.env.ZEROSHOT_DAEMON) { await streamClusterInForeground(cluster, orchestrator, clusterId, options); + orchestrator.close(); } setupDaemonCleanup(orchestrator, clusterId); diff --git a/lib/detached-startup.js b/lib/detached-startup.js index 98bb6e05..bc4eb48c 100644 --- a/lib/detached-startup.js +++ b/lib/detached-startup.js @@ -2,6 +2,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const lockfile = require('proper-lockfile'); +const { resolveRunPlan } = require('./run-plan'); const DEFAULT_WAIT_TIMEOUT_SECONDS = 180; const DEFAULT_WAIT_POLL_MS = 1000; @@ -116,6 +117,7 @@ async function registerDetachedSetupCluster({ runOptions = {}, cwd, }) { + const plan = resolveRunPlan(runOptions); await updateClustersFile(storageDir, (clusters) => { clusters[clusterId] = { id: clusterId, @@ -125,13 +127,13 @@ async function registerDetachedSetupCluster({ setupLogPath: logPath || null, setupStartedAt: Date.now(), setupStage: 'starting', - autoPr: Boolean(runOptions.pr || runOptions.ship), + autoPr: plan.delivery !== 'none', prOptions: runOptions.prBase ? { prBase: runOptions.prBase, mergeQueue: runOptions.mergeQueue || false, closeIssue: runOptions.closeIssue || null, - autoMerge: Boolean(runOptions.ship), + autoMerge: plan.autoMerge, cwd: cwd || null, } : null, diff --git a/lib/run-mode.js b/lib/run-mode.js index dc9fb9ea..32034bb7 100644 --- a/lib/run-mode.js +++ b/lib/run-mode.js @@ -1,11 +1,23 @@ -function resolveRunMode(options) { - if (options.ship) return options.docker ? 'ship+docker' : 'ship'; - if (options.pr) return options.docker ? 'pr+docker' : 'pr'; - if (options.docker) return 'docker'; - if (options.worktree) return 'worktree'; +const { resolveRunPlan } = require('./run-plan'); + +// The run-mode label is a VIEW of the canonical plan, never an independent +// cascade. Deriving it from the plan is what keeps the user-facing label and the +// actual isolation/delivery/autoMerge behavior from drifting apart. Callers that +// already hold a plan (e.g. the effective plan with env/settings folded in) use +// runModeFromPlan directly so the label reflects the SAME plan as behavior. +function runModeFromPlan({ isolation, delivery }) { + const dockerSuffix = isolation === 'docker' ? '+docker' : ''; + if (delivery === 'ship') return `ship${dockerSuffix}`; + if (delivery === 'pr') return `pr${dockerSuffix}`; + if (isolation === 'docker') return 'docker'; + if (isolation === 'worktree') return 'worktree'; return null; } +function resolveRunMode(options) { + return runModeFromPlan(resolveRunPlan(options)); +} + const RUN_MODE_LABELS = { ship: 'ship (worktree + PR + auto-merge)', 'ship+docker': 'ship (docker + PR + auto-merge)', @@ -19,4 +31,4 @@ function describeRunMode(mode) { return RUN_MODE_LABELS[mode] || 'local (no isolation)'; } -module.exports = { resolveRunMode, describeRunMode }; +module.exports = { resolveRunMode, runModeFromPlan, describeRunMode }; diff --git a/lib/run-plan.js b/lib/run-plan.js new file mode 100644 index 00000000..dde30f02 --- /dev/null +++ b/lib/run-plan.js @@ -0,0 +1,32 @@ +/** + * The single canonical run-mode resolver. + * + * Every consumer (orchestrator, daemon startup, CLI label) derives isolation, + * delivery, and autoMerge from THIS function and nowhere else. The raw + * worktree/docker/pr/ship/autoMerge booleans are inputs; the frozen plan is the + * one truth. Deriving autoMerge separately (the old `Boolean(ship)` scatter) is + * what let `--pr` merge and let the run-mode label drift from behavior. + */ + +function resolveIsolation(options) { + if (options.docker) return 'docker'; + if (options.worktree || options.pr || options.ship) return 'worktree'; + return 'none'; +} + +function resolveDelivery(options) { + // Explicit autoMerge (e.g. a future `--auto-merge` flag) is ship-equivalent: + // "merge it" implies the ship delivery. This is the ONLY place that intent is + // interpreted, so it can never be silently overwritten downstream. + if (options.ship || options.autoMerge === true) return 'ship'; + if (options.pr) return 'pr'; + return 'none'; +} + +function resolveRunPlan(options = {}) { + const isolation = resolveIsolation(options); + const delivery = resolveDelivery(options); + return Object.freeze({ isolation, delivery, autoMerge: delivery === 'ship' }); +} + +module.exports = { resolveRunPlan }; diff --git a/lib/start-cluster.js b/lib/start-cluster.js index d13a5874..c34c2e4b 100644 --- a/lib/start-cluster.js +++ b/lib/start-cluster.js @@ -5,7 +5,8 @@ const { normalizeProviderName } = require('./provider-names'); const { getProvider } = require('../src/providers'); const { detectProvider } = require('../src/issue-providers'); const TemplateResolver = require('../src/template-resolver'); -const { resolveRunMode } = require('./run-mode'); +const { runModeFromPlan } = require('./run-mode'); +const { resolveRunPlan } = require('./run-plan'); const PACKAGE_ROOT = path.resolve(__dirname, '..'); @@ -198,8 +199,20 @@ function mergeRunOptions(options) { return envRunOptions ? { ...envRunOptions, ...options } : options; } -function resolveIsolation(mergedOptions, settings) { - return mergedOptions.docker || process.env.ZEROSHOT_DOCKER === '1' || settings.defaultDocker; +// The single producer of the run plan for a cluster start: fold env + settings +// into the flags, then resolve the canonical isolation/delivery/autoMerge plan. +// buildStartOptions reads EVERY mode field off this one plan — no field +// (isolation, worktree, autoPr, autoMerge, runMode) is derived independently. +function resolveEffectiveRunPlan(mergedOptions, settings) { + return resolveRunPlan({ + ...mergedOptions, + docker: anyTruthy( + mergedOptions.docker, + process.env.ZEROSHOT_DOCKER === '1', + settings.defaultDocker + ), + worktree: anyTruthy(mergedOptions.worktree, process.env.ZEROSHOT_WORKTREE === '1'), + }); } function resolveMergeQueue(mergedOptions) { @@ -238,14 +251,15 @@ function buildStartOptions({ forceProvider, }) { const mergedOptions = mergeRunOptions(options); + const plan = resolveEffectiveRunPlan(mergedOptions, settings); return { clusterId, cwd: resolveTargetCwd(), - isolation: resolveIsolation(mergedOptions, settings), + isolation: plan.isolation === 'docker', isolationImage: firstTruthy(mergedOptions.dockerImage, process.env.ZEROSHOT_DOCKER_IMAGE), - worktree: anyTruthy(mergedOptions.worktree, process.env.ZEROSHOT_WORKTREE === '1'), - autoPr: anyTruthy(mergedOptions.pr, process.env.ZEROSHOT_PR === '1'), - autoMerge: Boolean(mergedOptions.ship), + worktree: plan.isolation === 'worktree', + autoPr: plan.delivery !== 'none', + autoMerge: plan.autoMerge, autoPush: process.env.ZEROSHOT_PUSH === '1', modelOverride: optionalValue(modelOverride), providerOverride: optionalValue(providerOverride), @@ -257,7 +271,7 @@ function buildStartOptions({ mergeQueue: resolveMergeQueue(mergedOptions), closeIssue: resolveCloseIssue(mergedOptions), ship: mergedOptions.ship, - runMode: resolveRunMode(mergedOptions) || null, + runMode: runModeFromPlan(plan), requiredQualityGates: mergedOptions.requiredQualityGates, settings, }; diff --git a/package.json b/package.json index 853b4028..090bfb73 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "pretest": "npm run build:agent-cli-provider", "test": "node tests/run-tests.js", "test:unit": "node tests/run-tests.js", - "test:slow": "mocha 'tests/integration/**/*.test.js' --timeout 180000", - "test:all": "npm run test && npm run test:slow", + "test:e2e": "mocha 'tests/e2e/**/*.test.js' --timeout 120000", + "test:e2e:docker": "bash tests/integration/e2e-isolation-and-auto.test.sh", + "test:slow": "mocha 'tests/integration/**/*.test.js' 'tests/providers/**/*.test.js' --timeout 180000", + "test:all": "npm run test && npm run test:e2e && npm run test:slow", "test:coverage": "c8 npm run test:unit", "test:coverage:report": "c8 --reporter=html npm run test:unit && echo 'Coverage report generated at coverage/index.html'", "postinstall": "node scripts/fix-node-pty-permissions.js && node scripts/check-path.js", diff --git a/src/agent/agent-lifecycle.js b/src/agent/agent-lifecycle.js index d7df9422..7a213a3b 100644 --- a/src/agent/agent-lifecycle.js +++ b/src/agent/agent-lifecycle.js @@ -200,6 +200,8 @@ function start(agent) { * @returns {Promise} */ async function stop(agent) { + stopLivenessCheck(agent); + if (!agent.running) { return; } diff --git a/src/ledger.js b/src/ledger.js index 5afa6b62..32fe9b30 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -715,6 +715,9 @@ class Ledger extends EventEmitter { * Close the database connection */ close() { + if (this._closed) { + return; + } this._closed = true; // Set flag BEFORE closing to prevent race conditions this.db.close(); } diff --git a/src/message-bus.js b/src/message-bus.js index 15bb2663..5331efda 100644 --- a/src/message-bus.js +++ b/src/message-bus.js @@ -24,12 +24,14 @@ class MessageBus extends EventEmitter { this.setMaxListeners(MAX_LISTENERS); this.ledger = ledger || new Ledger(); this.wsClients = new Set(); + this._closed = false; // Forward ledger events - this.ledger.on('message', (message) => { + this._forwardLedgerMessage = (message) => { this.emit('message', message); this._broadcastToWebSocket(message); - }); + }; + this.ledger.on('message', this._forwardLedgerMessage); } /** @@ -241,6 +243,14 @@ class MessageBus extends EventEmitter { * Close the message bus */ close() { + if (this._closed) { + return; + } + this._closed = true; + + this.ledger.off('message', this._forwardLedgerMessage); + this.removeAllListeners(); + // Close all WebSocket connections for (const ws of this.wsClients) { try { diff --git a/src/orchestrator.js b/src/orchestrator.js index 74951bd1..7445a167 100644 --- a/src/orchestrator.js +++ b/src/orchestrator.js @@ -47,6 +47,7 @@ const configValidator = require('./config-validator'); const TemplateResolver = require('./template-resolver'); const { loadSettings } = require('../lib/settings'); const { normalizeProviderName } = require('../lib/provider-names'); +const { resolveRunPlan } = require('../lib/run-plan'); const { getProvider } = require('./providers'); const StateSnapshotter = require('./state-snapshotter'); const { resolveClusterRequiredQualityGates } = require('./quality-gates'); @@ -212,14 +213,11 @@ function applyPushBlockedRepairTriggers(config) { } } -function resolveAutoMerge(options) { - return Boolean(options.ship) || Boolean(options.autoMerge); -} - function buildPrOptions(options, requiredQualityGates) { // autoMerge must always be persisted (even when no other PR fields are set) so that // `zeroshot run --pr` (autoMerge=false) vs `--ship` (autoMerge=true) survives resume. - const autoMerge = resolveAutoMerge(options); + // Derived from the canonical run plan — never recomputed from ship/pr here. + const autoMerge = resolveRunPlan(options).autoMerge; return { prBase: options.prBase || null, @@ -577,6 +575,12 @@ class Orchestrator { // missing - a ledger write. Read-only orchestrators must never write, so they // skip it; they only need to read existing snapshots, not produce new ones. if (!this.readonly) { + this._registerClusterSubscriptions({ + messageBus, + clusterId, + isolationManager, + containerId: isolation?.containerId || null, + }); this._startSnapshotter(clusterContext); } this._log(`[Orchestrator] Loaded cluster: ${clusterId} with ${agents.length} agents`); @@ -1825,7 +1829,7 @@ class Orchestrator { mergeQueue: options.mergeQueue, closeIssue: options.closeIssue, requiredQualityGates: options.requiredQualityGates, - autoMerge: resolveAutoMerge(options), + autoMerge: resolveRunPlan(options).autoMerge, cwd: options.cwd, }); @@ -2316,6 +2320,9 @@ class Orchestrator { * Call before deleting storageDir to prevent ENOENT race conditions during cleanup */ close() { + if (this.closed) { + return; + } this.closed = true; } @@ -4103,7 +4110,7 @@ Continue from where you left off. Review your previous output to understand what // Exported for testing (PR options persistence, e.g. autoMerge for --pr vs --ship). Orchestrator.buildPrOptions = buildPrOptions; -Orchestrator.resolveAutoMerge = resolveAutoMerge; +Orchestrator.resolveRunPlan = resolveRunPlan; module.exports = Orchestrator; module.exports.DuplicateClusterError = DuplicateClusterError; diff --git a/tests/e2e/cluster-lifecycle.test.js b/tests/e2e/cluster-lifecycle.test.js new file mode 100644 index 00000000..dd4ba099 --- /dev/null +++ b/tests/e2e/cluster-lifecycle.test.js @@ -0,0 +1,116 @@ +/** + * Tier 1 e2e: daemon-mode run plus the observability commands (status/list/ + * logs) an operator would actually use, ported from the now-deleted + * tests/integration/e2e-framework.test.ts (which could never run - it + * imported @playwright/test, an uninstalled dependency, and asserted a + * hardcoded stale version string). + */ + +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + setupE2ERepo, + cleanupE2ERepo, + runZeroshot, + readCluster, + worktreePath, + scenarioPath, +} = require('./helpers/e2e-harness'); + +const CONFIG_PATH = path.join(__dirname, 'fixtures', 'single-worker-config.json'); + +function pollUntil(predicate, timeoutMs, intervalMs = 300) { + const start = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + const value = predicate(); + if (value) return resolve(value); + if (Date.now() - start > timeoutMs) { + return reject(new Error(`Condition not met within ${timeoutMs}ms`)); + } + setTimeout(tick, intervalMs); + }; + tick(); + }); +} + +describe('e2e: cluster lifecycle (daemon mode + observability commands)', function () { + this.timeout(60000); + + let env; + + beforeEach(() => { + env = setupE2ERepo(); + }); + + afterEach(() => { + cleanupE2ERepo(env); + }); + + it('runs detached and is observable via status/list/logs until completion', async function () { + const issueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-issue-')); + const issuePath = path.join(issueDir, 'feature.md'); + fs.writeFileSync(issuePath, '# Add feature\n\nDo X.\n'); + + // NOTE: unlike foreground `run`, the `-d` parent branch always calls + // generateName('cluster') itself (cli/index.js:shouldRunDetached path) - + // it does not honor a pre-set ZEROSHOT_CLUSTER_ID the way foreground/resume + // do, so the id must be parsed from stdout ("Started "). + const startResult = runZeroshot( + env, + ['run', issuePath, '-d', '--worktree', '--config', CONFIG_PATH], + { + FAKE_AGENT_SCENARIO: scenarioPath('single-worker-success'), + timeout: 15000, + } + ); + assert.strictEqual( + startResult.status, + 0, + `zeroshot run -d exited ${startResult.status}\nSTDOUT:\n${startResult.stdout}\nSTDERR:\n${startResult.stderr}` + ); + const clusterIdMatch = /Started (\S+)/.exec(startResult.stdout); + assert.ok(clusterIdMatch, `expected "Started " in stdout, got:\n${startResult.stdout}`); + const clusterId = clusterIdMatch[1]; + + await pollUntil(() => { + const cluster = readCluster(env, clusterId); + return cluster && cluster.state !== 'initializing' ? cluster : null; + }, 15000); + + const listResult = runZeroshot(env, ['list', '--json']); + assert.strictEqual(listResult.status, 0, listResult.stderr); + const listData = JSON.parse(listResult.stdout); + const clusters = Array.isArray(listData) ? listData : listData.clusters; + assert.ok( + clusters.some((c) => c.id === clusterId), + `expected ${clusterId} in list --json output: ${listResult.stdout}` + ); + + await pollUntil(() => { + const cluster = readCluster(env, clusterId); + return cluster?.state === 'stopped' || cluster?.state === 'killed' ? cluster : null; + }, 30000); + + const statusResult = runZeroshot(env, ['status', clusterId, '--json']); + assert.strictEqual(statusResult.status, 0, statusResult.stderr); + const statusData = JSON.parse(statusResult.stdout); + assert.strictEqual(statusData.id, clusterId); + assert.ok(['stopped', 'killed'].includes(statusData.state), JSON.stringify(statusData)); + + const logsResult = runZeroshot(env, ['logs', clusterId, '-n', '200']); + assert.strictEqual(logsResult.status, 0, logsResult.stderr); + assert.ok( + logsResult.stdout.includes('Implementing the requested feature'), + `expected fake-agent message text in logs, got:\n${logsResult.stdout}` + ); + + const writtenFile = path.join(worktreePath(env, clusterId), 'output.txt'); + assert.ok(fs.existsSync(writtenFile)); + + fs.rmSync(issueDir, { recursive: true, force: true }); + }); +}); diff --git a/tests/e2e/failing-agent-resume.test.js b/tests/e2e/failing-agent-resume.test.js new file mode 100644 index 00000000..5c4cb509 --- /dev/null +++ b/tests/e2e/failing-agent-resume.test.js @@ -0,0 +1,101 @@ +/** + * Tier 1 e2e: resume path. A scenario that always fails exhausts the + * orchestrator's built-in retry budget (src/agent/agent-lifecycle.js), which + * publishes AGENT_ERROR and persists cluster.failureInfo + * (src/orchestrator.js:_registerAgentErrorHandler). For an 'implementation' + * role agent with 3+ attempts, the orchestrator auto-stops the cluster - no + * custom onError/topic wiring needed (config-validator's reachability check + * only tracks hooks.onComplete as a topic producer, so a hand-rolled + * onError-publishes-a-topic path would be flagged as an unproduced topic). + * `zeroshot resume` then re-runs the failed agent; pointing it at a + * succeeding scenario should drive the cluster to completion. + */ + +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + setupE2ERepo, + cleanupE2ERepo, + runZeroshot, + readCluster, + worktreePath, + scenarioPath, +} = require('./helpers/e2e-harness'); + +const CONFIG_PATH = path.join(__dirname, 'fixtures', 'single-worker-config.json'); + +function pollUntil(predicate, timeoutMs, intervalMs = 200) { + const start = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + const value = predicate(); + if (value) return resolve(value); + if (Date.now() - start > timeoutMs) { + return reject(new Error(`Condition not met within ${timeoutMs}ms`)); + } + setTimeout(tick, intervalMs); + }; + tick(); + }); +} + +describe('e2e: failing agent + resume', function () { + this.timeout(90000); + + let env; + + beforeEach(() => { + env = setupE2ERepo(); + }); + + afterEach(() => { + cleanupE2ERepo(env); + }); + + it('records failureInfo on exhaustion and completes after resume with a working scenario', async function () { + const issueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-issue-')); + const issuePath = path.join(issueDir, 'feature.md'); + fs.writeFileSync(issuePath, '# Add feature\n\nDo X.\n'); + + const clusterId = 'e2e-failing-agent'; + const firstRun = runZeroshot(env, ['run', issuePath, '--worktree', '--config', CONFIG_PATH], { + ZEROSHOT_CLUSTER_ID: clusterId, + FAKE_AGENT_SCENARIO: scenarioPath('failing-agent'), + }); + + assert.strictEqual( + firstRun.status, + 0, + `zeroshot run exited ${firstRun.status}\nSTDOUT:\n${firstRun.stdout}\nSTDERR:\n${firstRun.stderr}` + ); + + const failedCluster = await pollUntil(() => { + const cluster = readCluster(env, clusterId); + return cluster?.failureInfo ? cluster : null; + }, 30000); + assert.strictEqual(failedCluster.failureInfo.agentId, 'worker'); + assert.ok(!fs.existsSync(path.join(worktreePath(env, clusterId), 'output.txt'))); + + // -d returns once the resume has been scheduled; the process still stays + // alive until the reconstructed cluster's terminal subscriptions observe + // completion and stop the resumed agents. + const resumeResult = runZeroshot(env, ['resume', clusterId, '-d'], { + FAKE_AGENT_SCENARIO: scenarioPath('single-worker-success'), + timeout: 15000, + }); + assert.strictEqual( + resumeResult.status, + 0, + `zeroshot resume exited ${resumeResult.status}\nSTDOUT:\n${resumeResult.stdout}\nSTDERR:\n${resumeResult.stderr}` + ); + + const writtenFile = path.join(worktreePath(env, clusterId), 'output.txt'); + await pollUntil(() => fs.existsSync(writtenFile), 30000); + assert.strictEqual(fs.readFileSync(writtenFile, 'utf8'), 'feature implemented\n'); + + fs.rmSync(issueDir, { recursive: true, force: true }); + }); +}); diff --git a/tests/e2e/fixtures/single-worker-config.json b/tests/e2e/fixtures/single-worker-config.json new file mode 100644 index 00000000..aea12d1c --- /dev/null +++ b/tests/e2e/fixtures/single-worker-config.json @@ -0,0 +1,23 @@ +{ + "agents": [ + { + "id": "worker", + "role": "implementation", + "timeout": 0, + "triggers": [{ "topic": "ISSUE_OPENED", "action": "execute_task" }], + "prompt": "FAKE_AGENT_ID=worker Implement the requested feature.", + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { "topic": "TASK_COMPLETE", "content": { "text": "Done" } } + } + } + }, + { + "id": "completion-detector", + "role": "orchestrator", + "timeout": 0, + "triggers": [{ "topic": "TASK_COMPLETE", "action": "stop_cluster" }] + } + ] +} diff --git a/tests/e2e/fixtures/worker-validator-config.json b/tests/e2e/fixtures/worker-validator-config.json new file mode 100644 index 00000000..fa748ff2 --- /dev/null +++ b/tests/e2e/fixtures/worker-validator-config.json @@ -0,0 +1,52 @@ +{ + "agents": [ + { + "id": "worker", + "role": "implementation", + "timeout": 0, + "triggers": [ + { "topic": "ISSUE_OPENED", "action": "execute_task" }, + { + "topic": "VALIDATION_RESULT", + "logic": { + "engine": "javascript", + "script": "const lastPush = ledger.findLast({ topic: 'IMPLEMENTATION_READY' });\nif (!lastPush) return false;\nconst response = ledger.findLast({ topic: 'VALIDATION_RESULT', since: lastPush.timestamp });\nreturn response?.content?.data?.approved === false || response?.content?.data?.approved === 'false';" + }, + "action": "execute_task" + } + ], + "prompt": "FAKE_AGENT_ID=worker Implement the requested feature.", + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "IMPLEMENTATION_READY", + "content": { "text": "Ready for validation" } + } + } + } + }, + { + "id": "validator", + "role": "validator", + "timeout": 0, + "triggers": [{ "topic": "IMPLEMENTATION_READY", "action": "execute_task" }], + "prompt": "FAKE_AGENT_ID=validator Validate the implementation.", + "hooks": { + "onComplete": { + "action": "publish_message", + "config": { + "topic": "VALIDATION_RESULT", + "content": { "text": "Approved", "data": { "approved": true } } + } + } + } + }, + { + "id": "completion-detector", + "role": "orchestrator", + "timeout": 0, + "triggers": [{ "topic": "VALIDATION_RESULT", "action": "stop_cluster" }] + } + ] +} diff --git a/tests/e2e/helpers/e2e-harness.js b/tests/e2e/helpers/e2e-harness.js new file mode 100644 index 00000000..8c4b0399 --- /dev/null +++ b/tests/e2e/helpers/e2e-harness.js @@ -0,0 +1,245 @@ +/** + * Harness for deterministic end-to-end tests. + * + * Drives the real `zeroshot` binary (cli/index.js) as a subprocess, with + * ZEROSHOT_CLAUDE_COMMAND pointed at tests/fixtures/fake-agent so every layer + * above the model (CLI parsing, orchestrator, message bus, ledger, trigger + * engine, agent spawning, stream-json parsing, hooks, worktree isolation) + * runs for real, offline, with no API calls. + * + * Isolation note: most storage paths in this codebase are derived from + * `os.homedir()` directly (orchestrator storageDir, worktree paths, clusters.json), + * NOT from a ZEROSHOT_HOME env var. Node's os.homedir() reads the HOME env var on + * POSIX, so isolation here is achieved by overriding HOME for the child process, + * not by inventing a ZEROSHOT_HOME-only scheme. + * + * PATH shim note: agent execution self-spawns via `which zeroshot` + * (src/agent/agent-task-executor.js:_resolveZeroshotPath), i.e. it does NOT + * reuse the exact script path used to launch the top-level process. A temp + * directory containing a `zeroshot` shim script is prepended to PATH so the + * self-spawned subprocess resolves back to this checkout's cli/index.js, + * without requiring `npm link` as a precondition. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync, spawnSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI_ENTRY = path.join(REPO_ROOT, 'cli', 'index.js'); +const FAKE_AGENT_PATH = path.join(REPO_ROOT, 'tests', 'fixtures', 'fake-agent', 'index.js'); +const FIXTURES_DIR = path.join(REPO_ROOT, 'tests', 'fixtures', 'fake-agent-scenarios'); + +function scenarioPath(name) { + return path.join(FIXTURES_DIR, name.endsWith('.json') ? name : `${name}.json`); +} + +function runGit(args, cwd) { + const result = spawnSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }); + if (result.status !== 0) { + throw new Error( + `git ${args.join(' ')} failed in ${cwd}: ${result.stderr || result.error?.message}` + ); + } + return result.stdout; +} + +function writeZeroshotShim(binDir) { + const shimPath = path.join(binDir, 'zeroshot'); + fs.writeFileSync(shimPath, `#!/bin/sh\nexec node "${CLI_ENTRY}" "$@"\n`, { mode: 0o755 }); + fs.chmodSync(shimPath, 0o755); + return shimPath; +} + +/** + * Sets up an isolated temp git repo (with a local bare "origin") plus an + * isolated fake HOME so `zeroshot` state (clusters.json, worktrees, settings) + * never touches the real user environment. + */ +function setupE2ERepo() { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-repo-')); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-home-')); + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-bin-')); + const originDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-origin-')); + + runGit(['init', '--bare'], originDir); + + runGit(['init'], repoDir); + runGit(['config', 'user.email', 'e2e@example.com'], repoDir); + runGit(['config', 'user.name', 'E2E Test'], repoDir); + runGit(['remote', 'add', 'origin', originDir], repoDir); + fs.writeFileSync(path.join(repoDir, 'README.md'), '# e2e test repo\n'); + runGit(['add', 'README.md'], repoDir); + runGit(['commit', '-m', 'Initial commit'], repoDir); + runGit(['push', 'origin', 'HEAD'], repoDir); + + writeZeroshotShim(binDir); + fs.mkdirSync(path.join(homeDir, '.zeroshot'), { recursive: true }); + // Prevent the CLI's npm-registry update check (network call + interactive + // prompt) from firing during offline, deterministic test runs. + fs.writeFileSync( + path.join(homeDir, '.zeroshot', 'settings.json'), + JSON.stringify({ autoCheckUpdates: false }, null, 2) + ); + + return { repoDir, homeDir, binDir, originDir }; +} + +function cleanupE2ERepo(env) { + if (!env) return; + for (const dir of [env.repoDir, env.homeDir, env.binDir, env.originDir]) { + if (dir && fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +} + +/** + * Strips ambient ZEROSHOT_ and CMDPROOF_ prefixed env vars from the base env + * before building a child env. Without this, running the e2e suite from inside a + * zeroshot-managed session (e.g. a zeroshot worker agent) leaks that + * session's own ZEROSHOT_RUN_OPTIONS/ZEROSHOT_PR/ZEROSHOT_CLUSTER_ID etc. + * into the spawned test subprocess, silently changing its behavior + * (observed: an inherited ZEROSHOT_RUN_OPTIONS with pr:true injected a + * git-pusher agent and strict schema into an unrelated custom --config). + */ +function baseEnv() { + const result = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('ZEROSHOT_') || key.startsWith('CMDPROOF_')) continue; + result[key] = value; + } + return result; +} + +function buildEnv(env, envOverrides = {}) { + return { + ...baseEnv(), + HOME: env.homeDir, + ZEROSHOT_HOME: env.homeDir, + ZEROSHOT_SETTINGS_FILE: path.join(env.homeDir, '.zeroshot', 'settings.json'), + ZEROSHOT_CLAUDE_COMMAND: `node ${FAKE_AGENT_PATH}`, + PATH: `${env.binDir}${path.delimiter}${process.env.PATH}`, + ...envOverrides, + }; +} + +/** + * Runs `zeroshot ` as a real subprocess against the isolated repo/home. + * Returns { status, stdout, stderr } instead of throwing, so tests can assert + * on non-zero exit codes (e.g. the failing-agent scenario). + */ +function runZeroshot(env, args, envOverrides = {}) { + const result = spawnSync('node', [CLI_ENTRY, ...args], { + cwd: env.repoDir, + env: buildEnv(env, envOverrides), + encoding: 'utf8', + timeout: envOverrides.timeout || 90000, + }); + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + error: result.error || null, + }; +} + +function clustersFilePath(env) { + return path.join(env.homeDir, '.zeroshot', 'clusters.json'); +} + +function readClusters(env) { + const file = clustersFilePath(env); + if (!fs.existsSync(file)) return {}; + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function readCluster(env, clusterId) { + return readClusters(env)[clusterId] || null; +} + +/** + * clusters.json does not persist the worktree path (src/orchestrator.js + * _saveClusters only persists `isolation`, the Docker-mode counterpart, not + * `worktree`). The path is deterministic (src/isolation-manager.js + * createWorktreeIsolation): /.zeroshot/worktrees/. + */ +function worktreePath(env, clusterId) { + return path.join(env.homeDir, '.zeroshot', 'worktrees', clusterId); +} + +async function waitForClusterState(env, clusterId, targetStates, timeoutMs = 30000) { + const states = Array.isArray(targetStates) ? targetStates : [targetStates]; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const cluster = readCluster(env, clusterId); + if (cluster && states.includes(cluster.state)) { + return cluster; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error( + `Cluster ${clusterId} did not reach state [${states.join(', ')}] within ${timeoutMs}ms. ` + + `Last known: ${JSON.stringify(readCluster(env, clusterId))}` + ); +} + +function clusterDbPath(env, clusterId) { + return path.join(env.homeDir, '.zeroshot', `${clusterId}.db`); +} + +/** + * Mirrors src/ledger.js's row deserialization: content_text/content_data/metadata + * are stored as raw TEXT/JSON columns, not reconstructed by a bare SELECT *. + */ +function rowToMessage(row) { + const message = { ...row, content: {} }; + if (row.content_text) message.content.text = row.content_text; + if (row.content_data) message.content.data = JSON.parse(row.content_data); + if (row.metadata) message.metadata = JSON.parse(row.metadata); + return message; +} + +function readLedgerMessages(env, clusterId, topic) { + const Database = require('better-sqlite3'); + const dbPath = clusterDbPath(env, clusterId); + if (!fs.existsSync(dbPath)) return []; + const db = new Database(dbPath, { readonly: true, timeout: 5000 }); + try { + const rows = db + .prepare('SELECT * FROM messages WHERE cluster_id = ? AND topic = ? ORDER BY id') + .all(clusterId, topic); + return rows.map(rowToMessage); + } finally { + db.close(); + } +} + +function gitStatusPorcelain(repoDir) { + return runGit(['status', '--porcelain'], repoDir).trim(); +} + +function gitWorktreeList(repoDir) { + return runGit(['worktree', 'list'], repoDir); +} + +module.exports = { + REPO_ROOT, + CLI_ENTRY, + FAKE_AGENT_PATH, + scenarioPath, + setupE2ERepo, + cleanupE2ERepo, + runZeroshot, + clustersFilePath, + readClusters, + readCluster, + worktreePath, + waitForClusterState, + clusterDbPath, + readLedgerMessages, + gitStatusPorcelain, + gitWorktreeList, + execFileSync, +}; diff --git a/tests/e2e/single-worker.test.js b/tests/e2e/single-worker.test.js new file mode 100644 index 00000000..2aacaa28 --- /dev/null +++ b/tests/e2e/single-worker.test.js @@ -0,0 +1,78 @@ +/** + * Tier 1 e2e: full pipeline through the real `zeroshot` binary with the + * fake provider CLI standing in for the model. Proves: CLI parsing -> config + * load -> orchestrator start -> real subprocess spawn -> stream-json parsing + * -> hook-driven completion -> file write happens in the worktree, not the + * main checkout. + */ + +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + setupE2ERepo, + cleanupE2ERepo, + runZeroshot, + readCluster, + worktreePath, + waitForClusterState, + scenarioPath, + gitStatusPorcelain, +} = require('./helpers/e2e-harness'); + +const CONFIG_PATH = path.join(__dirname, 'fixtures', 'single-worker-config.json'); + +describe('e2e: single worker', function () { + this.timeout(60000); + + let env; + + beforeEach(() => { + env = setupE2ERepo(); + }); + + afterEach(() => { + cleanupE2ERepo(env); + }); + + it('runs the full pipeline and writes the file into the worktree, not the main checkout', async function () { + // The issue file lives outside the repo so the main checkout's git status + // stays meaningful as a "did anything leak in" signal. + const issueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-issue-')); + const issuePath = path.join(issueDir, 'feature.md'); + fs.writeFileSync(issuePath, '# Add feature\n\nDo X.\n'); + + const clusterId = 'e2e-single-worker'; + const result = runZeroshot(env, ['run', issuePath, '--worktree', '--config', CONFIG_PATH], { + ZEROSHOT_CLUSTER_ID: clusterId, + FAKE_AGENT_SCENARIO: scenarioPath('single-worker-success'), + }); + + assert.strictEqual( + result.status, + 0, + `zeroshot run exited ${result.status}\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}` + ); + + const cluster = await waitForClusterState(env, clusterId, ['stopped', 'killed']); + const worktreeDir = worktreePath(env, clusterId); + assert.ok(fs.existsSync(worktreeDir), 'worktree directory should exist'); + + const writtenFile = path.join(worktreeDir, 'output.txt'); + assert.ok(fs.existsSync(writtenFile), `expected ${writtenFile} to exist`); + assert.strictEqual(fs.readFileSync(writtenFile, 'utf8'), 'feature implemented\n'); + + assert.ok( + !fs.existsSync(path.join(env.repoDir, 'output.txt')), + 'output.txt must not leak into the main checkout' + ); + assert.strictEqual(gitStatusPorcelain(env.repoDir), '', 'main checkout should remain clean'); + + const clusterAfter = readCluster(env, clusterId); + assert.strictEqual(clusterAfter.state, cluster.state); + + fs.rmSync(issueDir, { recursive: true, force: true }); + }); +}); diff --git a/tests/e2e/worker-validator.test.js b/tests/e2e/worker-validator.test.js new file mode 100644 index 00000000..acf520ba --- /dev/null +++ b/tests/e2e/worker-validator.test.js @@ -0,0 +1,76 @@ +/** + * Tier 1 e2e: worker -> validator flow with distinct fake-agent scenarios per + * agent. Both agents share one process-wide ZEROSHOT_CLAUDE_COMMAND, so + * per-agent scenario dispatch relies on the FAKE_AGENT_ID= marker + * embedded in each agent's `prompt` field (see tests/fixtures/fake-agent). + */ + +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + setupE2ERepo, + cleanupE2ERepo, + runZeroshot, + worktreePath, + readLedgerMessages, + scenarioPath, +} = require('./helpers/e2e-harness'); + +const CONFIG_PATH = path.join(__dirname, 'fixtures', 'worker-validator-config.json'); + +describe('e2e: worker -> validator', function () { + this.timeout(60000); + + let env; + + beforeEach(() => { + env = setupE2ERepo(); + }); + + afterEach(() => { + cleanupE2ERepo(env); + }); + + it('runs both agents with distinct scenarios and reaches validation approval', function () { + const issueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-issue-')); + const issuePath = path.join(issueDir, 'feature.md'); + fs.writeFileSync(issuePath, '# Add feature\n\nDo X.\n'); + + const clusterId = 'e2e-worker-validator'; + const result = runZeroshot(env, ['run', issuePath, '--worktree', '--config', CONFIG_PATH], { + ZEROSHOT_CLUSTER_ID: clusterId, + FAKE_AGENT_SCENARIO_WORKER: scenarioPath('worker-success'), + FAKE_AGENT_SCENARIO_VALIDATOR: scenarioPath('validator-approve'), + }); + + assert.strictEqual( + result.status, + 0, + `zeroshot run exited ${result.status}\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}` + ); + + const worktreeDir = worktreePath(env, clusterId); + assert.ok( + fs.existsSync(path.join(worktreeDir, 'implementation.txt')), + 'worker should have written implementation.txt' + ); + assert.ok( + fs.existsSync(path.join(worktreeDir, 'validation-report.txt')), + 'validator should have written validation-report.txt' + ); + + const implementationReady = readLedgerMessages(env, clusterId, 'IMPLEMENTATION_READY'); + assert.strictEqual(implementationReady.length, 1); + assert.strictEqual(implementationReady[0].sender, 'worker'); + + const validationResults = readLedgerMessages(env, clusterId, 'VALIDATION_RESULT'); + assert.strictEqual(validationResults.length, 1); + assert.strictEqual(validationResults[0].sender, 'validator'); + assert.strictEqual(validationResults[0].content.data.approved, true); + + fs.rmSync(issueDir, { recursive: true, force: true }); + }); +}); diff --git a/tests/e2e/worktree-isolation-no-leak.test.js b/tests/e2e/worktree-isolation-no-leak.test.js new file mode 100644 index 00000000..ffca7120 --- /dev/null +++ b/tests/e2e/worktree-isolation-no-leak.test.js @@ -0,0 +1,79 @@ +/** + * Tier 1 e2e: --worktree mode leaves the main checkout untouched and + * registers a real git worktree/branch for the cluster, with no leaked + * process for the completed cluster. + */ + +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + setupE2ERepo, + cleanupE2ERepo, + runZeroshot, + worktreePath, + gitStatusPorcelain, + gitWorktreeList, + scenarioPath, +} = require('./helpers/e2e-harness'); + +const CONFIG_PATH = path.join(__dirname, 'fixtures', 'single-worker-config.json'); + +describe('e2e: worktree isolation, no leak', function () { + this.timeout(60000); + + let env; + + beforeEach(() => { + env = setupE2ERepo(); + }); + + afterEach(() => { + cleanupE2ERepo(env); + }); + + it('leaves the main checkout clean and registers a real worktree/branch', function () { + const issueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-issue-')); + const issuePath = path.join(issueDir, 'feature.md'); + fs.writeFileSync(issuePath, '# Add feature\n\nDo X.\n'); + + const clusterId = 'e2e-worktree-isolation'; + const result = runZeroshot(env, ['run', issuePath, '--worktree', '--config', CONFIG_PATH], { + ZEROSHOT_CLUSTER_ID: clusterId, + FAKE_AGENT_SCENARIO: scenarioPath('single-worker-success'), + }); + + assert.strictEqual( + result.status, + 0, + `zeroshot run exited ${result.status}\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}` + ); + + assert.strictEqual( + gitStatusPorcelain(env.repoDir), + '', + 'main checkout should have no uncommitted residue' + ); + + const worktreeList = gitWorktreeList(env.repoDir); + assert.ok( + worktreeList.includes(`zeroshot/${clusterId}`), + `expected git worktree list to include zeroshot/${clusterId}, got:\n${worktreeList}` + ); + assert.ok(fs.existsSync(worktreePath(env, clusterId)), 'worktree directory should exist'); + + const psOutput = require('child_process').execSync('ps -A -o command=').toString(); + const leakedLines = psOutput + .split('\n') + .filter((line) => line.includes(clusterId) && !line.includes('grep')); + assert.strictEqual( + leakedLines.length, + 0, + `expected no leaked processes referencing ${clusterId}, found:\n${leakedLines.join('\n')}` + ); + + fs.rmSync(issueDir, { recursive: true, force: true }); + }); +}); diff --git a/tests/fixtures/fake-agent-scenarios/failing-agent.json b/tests/fixtures/fake-agent-scenarios/failing-agent.json new file mode 100644 index 00000000..52890990 --- /dev/null +++ b/tests/fixtures/fake-agent-scenarios/failing-agent.json @@ -0,0 +1,5 @@ +{ + "files": [], + "messages": ["Task failed: simulated fatal error."], + "exitCode": 1 +} diff --git a/tests/fixtures/fake-agent-scenarios/single-worker-success.json b/tests/fixtures/fake-agent-scenarios/single-worker-success.json new file mode 100644 index 00000000..15072d41 --- /dev/null +++ b/tests/fixtures/fake-agent-scenarios/single-worker-success.json @@ -0,0 +1,8 @@ +{ + "files": [{ "path": "output.txt", "content": "feature implemented\n" }], + "messages": [ + "Implementing the requested feature.", + "{\"summary\": \"Implemented the requested feature\", \"result\": \"Wrote output.txt\"}" + ], + "exitCode": 0 +} diff --git a/tests/fixtures/fake-agent-scenarios/validator-approve.json b/tests/fixtures/fake-agent-scenarios/validator-approve.json new file mode 100644 index 00000000..4131fc03 --- /dev/null +++ b/tests/fixtures/fake-agent-scenarios/validator-approve.json @@ -0,0 +1,8 @@ +{ + "files": [{ "path": "validation-report.txt", "content": "APPROVED\n" }], + "messages": [ + "Validating the implementation.", + "{\"summary\": \"Validation passed\", \"result\": \"All checks green\"}" + ], + "exitCode": 0 +} diff --git a/tests/fixtures/fake-agent-scenarios/worker-success.json b/tests/fixtures/fake-agent-scenarios/worker-success.json new file mode 100644 index 00000000..3b0d0839 --- /dev/null +++ b/tests/fixtures/fake-agent-scenarios/worker-success.json @@ -0,0 +1,8 @@ +{ + "files": [{ "path": "implementation.txt", "content": "implementation output\n" }], + "messages": [ + "Implementing the requested feature.", + "{\"summary\": \"Implementation complete\", \"result\": \"Wrote implementation.txt\"}" + ], + "exitCode": 0 +} diff --git a/tests/fixtures/fake-agent/index.js b/tests/fixtures/fake-agent/index.js new file mode 100755 index 00000000..1a40a04d --- /dev/null +++ b/tests/fixtures/fake-agent/index.js @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/** + * Fake provider CLI for deterministic end-to-end tests. + * + * Stands in for the real `claude` binary via ZEROSHOT_CLAUDE_COMMAND (see + * lib/settings.js:getClaudeCommand and src/agent-cli-provider/adapters/claude.ts:buildCommand). + * Everything above this process (CLI parsing, orchestrator, message bus, ledger, + * trigger/logic engine, agent spawning, stream-json parsing, hooks, worktree + * isolation) runs for real; only the model's cognition is faked. + * + * Argv contract (buildCommand): [...prefixArgs, '--print', '--input-format', 'text', + * ...outputArgs, ...schemaArgs, ...modelArgs, ...autoApproveArgs, ...sessionArgs, context] + * The prompt/context is always the LAST argv element. + * + * Scenario selection: + * FAKE_AGENT_SCENARIO= Used for every agent by default. + * FAKE_AGENT_SCENARIO_ Overrides the default for one agent id. + * + * Per-agent override convention (not in the original issue spec, needed for multi-agent + * scenarios): ZEROSHOT_CLAUDE_COMMAND is a single process-wide env var, so every agent in + * a cluster invokes the exact same fake-agent command. The only per-invocation signal + * available is the prompt text itself (the last argv element), which embeds the agent's + * configured `prompt` string verbatim (see src/agent/agent-context-sections.js + * buildHeaderContext). Fixture configs that need distinct per-agent scenarios must include + * the literal marker `FAKE_AGENT_ID=` somewhere in that agent's `prompt` field; this + * script extracts it from the context and uses it to look up + * FAKE_AGENT_SCENARIO_, falling back to FAKE_AGENT_SCENARIO. + * + * Scenario JSON shape: + * { + * "files": [{ "path": "relative/path.txt", "content": "..." }], + * "edits": [{ "path": "relative/path.txt", "find": "...", "replace": "..." }], + * "messages": ["assistant message 1", "assistant message 2"], + * "exitCode": 0, + * "delayMs": 0 + * } + * files/edits are applied relative to process.cwd() (proves worktree cwd injection). + */ + +const fs = require('fs'); +const path = require('path'); + +function extractAgentId(context) { + const match = /FAKE_AGENT_ID=([A-Za-z0-9_-]+)/.exec(context); + return match ? match[1] : null; +} + +function resolveScenarioPath(context) { + const agentId = extractAgentId(context); + if (agentId) { + const perAgentKey = `FAKE_AGENT_SCENARIO_${agentId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`; + if (process.env[perAgentKey]) { + return process.env[perAgentKey]; + } + } + return process.env.FAKE_AGENT_SCENARIO; +} + +function loadScenario(context) { + const scenarioPath = resolveScenarioPath(context); + if (!scenarioPath) { + throw new Error('fake-agent: FAKE_AGENT_SCENARIO (or a per-agent override) must be set'); + } + const raw = fs.readFileSync(scenarioPath, 'utf8'); + return JSON.parse(raw); +} + +function applyFiles(scenario) { + for (const file of scenario.files || []) { + const target = path.resolve(process.cwd(), file.path); + process.stderr.write(`fake-agent: writing ${target}\n`); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, file.content ?? ''); + } +} + +function applyEdits(scenario) { + for (const edit of scenario.edits || []) { + const target = path.resolve(process.cwd(), edit.path); + const current = fs.readFileSync(target, 'utf8'); + fs.writeFileSync(target, current.split(edit.find).join(edit.replace)); + } +} + +function parseArgv(argv) { + const outputFormatIndex = argv.indexOf('--output-format'); + const modelIndex = argv.indexOf('--model'); + return { + outputFormat: outputFormatIndex >= 0 ? argv[outputFormatIndex + 1] : null, + model: modelIndex >= 0 ? argv[modelIndex + 1] : null, + context: argv[argv.length - 1] || '', + }; +} + +function emitEvent(event) { + process.stdout.write(`${JSON.stringify(event)}\n`); +} + +function emitMessages(messages) { + for (const text of messages) { + emitEvent({ + type: 'assistant', + message: { content: [{ type: 'text', text }] }, + }); + } +} + +function emitResult(scenario, messages) { + const exitCode = scenario.exitCode ?? 0; + const lastMessage = messages[messages.length - 1] ?? ''; + emitEvent({ + type: 'result', + subtype: exitCode === 0 ? 'success' : 'error', + is_error: exitCode !== 0, + result: lastMessage, + total_cost_usd: 0, + duration_ms: 1, + usage: { input_tokens: 1, output_tokens: 1 }, + }); +} + +function isVersionProbe(argv) { + // src/preflight.js:getClaudeVersion runs ` --version` (and only that) + // to detect CLI presence, with no scenario/task context. Must not touch + // FAKE_AGENT_SCENARIO or write files for this bare capability probe. + return argv.includes('--version') && !argv.includes('--print'); +} + +function main() { + const argv = process.argv.slice(2); + if (isVersionProbe(argv)) { + process.stdout.write('1.0.0 (fake-agent)\n'); + process.exit(0); + } + + const { outputFormat, model, context } = parseArgv(argv); + const scenario = loadScenario(context); + process.stderr.write( + `fake-agent: output-format=${outputFormat} model=${model} cwd=${process.cwd()}\n` + ); + + const exitCode = scenario.exitCode ?? 0; + const messages = scenario.messages || []; + + if (exitCode === 0) { + applyFiles(scenario); + applyEdits(scenario); + } + + const run = () => { + emitMessages(messages); + emitResult(scenario, messages); + process.exit(exitCode); + }; + + if (scenario.delayMs) { + setTimeout(run, scenario.delayMs); + } else { + run(); + } +} + +main(); diff --git a/tests/git-pusher-pr-mode.test.js b/tests/git-pusher-pr-mode.test.js index d4a4fcbf..9783f2e5 100644 --- a/tests/git-pusher-pr-mode.test.js +++ b/tests/git-pusher-pr-mode.test.js @@ -122,23 +122,25 @@ describe('git-pusher --pr vs --ship (autoMerge)', function () { assert.notStrictEqual(prOptionsForPr, null); }); - it('resolveAutoMerge is the single source for the autoMerge decision', function () { - assert.strictEqual(Orchestrator.resolveAutoMerge({ ship: true }), true); - assert.strictEqual(Orchestrator.resolveAutoMerge({ pr: true }), false); - assert.strictEqual(Orchestrator.resolveAutoMerge({ pr: true, autoMerge: true }), true); + it('resolveRunPlan is the single source for the autoMerge decision', function () { + assert.strictEqual(Orchestrator.resolveRunPlan({ ship: true }).autoMerge, true); + assert.strictEqual(Orchestrator.resolveRunPlan({ pr: true }).autoMerge, false); + // Explicit autoMerge is ship-equivalent and is NOT clobbered (the old + // normalizeRunOptions overwrite bug reset this to false). + assert.strictEqual(Orchestrator.resolveRunPlan({ pr: true, autoMerge: true }).autoMerge, true); }); - it('buildPrOptions and git-pusher config cannot diverge (both derive from resolveAutoMerge)', function () { + it('buildPrOptions and git-pusher config cannot diverge (both derive from resolveRunPlan)', function () { const shipOptions = { ship: true }; const prOptions = { pr: true }; assert.strictEqual( Orchestrator.buildPrOptions(shipOptions, []).autoMerge, - Orchestrator.resolveAutoMerge(shipOptions) + Orchestrator.resolveRunPlan(shipOptions).autoMerge ); assert.strictEqual( Orchestrator.buildPrOptions(prOptions, []).autoMerge, - Orchestrator.resolveAutoMerge(prOptions) + Orchestrator.resolveRunPlan(prOptions).autoMerge ); }); }); diff --git a/tests/integration/e2e-framework.test.ts b/tests/integration/e2e-framework.test.ts deleted file mode 100644 index 885d3d00..00000000 --- a/tests/integration/e2e-framework.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * E2E Test for Vibe Framework - * - * Tests that the zeroshot framework can successfully: - * 1. Run a simple single-agent task - * 2. Track the task lifecycle - * 3. Retrieve task logs - * 4. Clean up properly - */ - -import { test, expect } from '@playwright/test'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import * as fs from 'fs/promises'; -import * as path from 'path'; - -const execAsync = promisify(exec); - -// Test configuration -const VIBE_CLI = 'zeroshot'; -const TEST_TIMEOUT = 120000; // 2 minutes for task execution - -test.describe('Vibe Framework E2E', () => { - let taskId: string | null = null; - - test.afterEach(async () => { - // Clean up: kill task if still running - if (taskId) { - try { - await execAsync(`${VIBE_CLI} kill ${taskId}`); - } catch (error) { - // Task might already be completed, ignore errors - } - taskId = null; - } - }); - - test('should run a simple single-agent task successfully', async () => { - test.setTimeout(TEST_TIMEOUT); - // Step 1: Verify zeroshot CLI is installed and available - const { stdout: versionOutput } = await execAsync(`${VIBE_CLI} --version`); - expect(versionOutput).toContain('1.0.0'); - console.log('[OK] Vibe CLI is available:', versionOutput.trim()); - - // Step 2: Run a simple task that should complete quickly - const taskPrompt = 'Echo hello from zeroshot and exit successfully'; - let runOutput = ''; - try { - const result = await execAsync( - `${VIBE_CLI} task run '${taskPrompt}' 2>&1`, - { timeout: 10000 } // 10 seconds to spawn - ); - runOutput = result.stdout; - } catch (error: any) { - // Command might exit with non-zero but still spawn task successfully - runOutput = error.stdout || error.stderr || ''; - if (!runOutput.includes('spawned:')) { - throw error; - } - } - - // Extract task ID from output (format: "Task spawned: --") - // Note: Output contains ANSI color codes, so we need to account for those - const taskIdMatch = runOutput.match(/spawned:\s+(?:\x1b\[\d+m)*([a-z]+-[a-z]+-\d+)/); - if (!taskIdMatch) { - console.error('Failed to extract task ID from output'); - console.error('Output:', runOutput); - throw new Error(`Could not extract task ID from output`); - } - taskId = taskIdMatch[1]; - console.log('[OK] Task spawned with ID:', taskId); - - // Step 3: Verify task appears in list - const { stdout: listOutput } = await execAsync(`${VIBE_CLI} list`); - expect(listOutput).toContain(taskId); - console.log('[OK] Task appears in list'); - - // Step 4: Wait for task to complete (poll status) - let attempts = 0; - const maxAttempts = 24; // 2 minutes with 5-second intervals - let taskCompleted = false; - - while (attempts < maxAttempts && !taskCompleted) { - await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds - - try { - const { stdout: statusOutput } = await execAsync(`${VIBE_CLI} status ${taskId}`); - - if ( - statusOutput.toLowerCase().includes('completed') || - statusOutput.toLowerCase().includes('stopped') - ) { - taskCompleted = true; - console.log('[OK] Task completed'); - } else if ( - statusOutput.toLowerCase().includes('failed') || - statusOutput.toLowerCase().includes('error') - ) { - throw new Error(`Task failed: ${statusOutput}`); - } else { - console.log(`[INFO] Task still running (attempt ${attempts + 1}/${maxAttempts})`); - } - } catch (error) { - // Status command might fail if task just completed, try logs instead - try { - const { stdout: logsOutput } = await execAsync(`${VIBE_CLI} logs ${taskId}`); - if (logsOutput.includes('Task completed') || logsOutput.includes('exited')) { - taskCompleted = true; - console.log('[OK] Task completed (detected via logs)'); - } - } catch (logError) { - console.log(`[INFO] Could not get logs (attempt ${attempts + 1}/${maxAttempts})`); - } - } - - attempts++; - } - - expect(taskCompleted).toBe(true); - - // Step 5: Retrieve and verify logs - const { stdout: logsOutput } = await execAsync(`${VIBE_CLI} logs ${taskId}`); - expect(logsOutput.length).toBeGreaterThan(0); - console.log('[OK] Task logs retrieved'); - console.log('Task logs snippet:', logsOutput.substring(0, 500)); - }); - - test('should list tasks correctly', async () => { - // Verify list command works without errors - const { stdout: listOutput } = await execAsync(`${VIBE_CLI} list`); - expect(listOutput).toBeTruthy(); - console.log('[OK] List command works'); - }); - - test('should show settings', async () => { - // Verify settings command works - const { stdout: settingsOutput } = await execAsync(`${VIBE_CLI} settings`); - expect(settingsOutput).toBeTruthy(); - console.log('[OK] Settings command works'); - }); - - test('should verify zeroshot database is accessible', async () => { - // Check that zeroshot directory exists - const homeDir = process.env.HOME || process.env.USERPROFILE || '/home/eivind'; - const zeroshotDir = path.join(homeDir, '.zeroshot'); - - try { - await fs.access(zeroshotDir); - console.log('[OK] Vibe directory exists:', zeroshotDir); - - // Check for clusters.json - const clustersFile = path.join(zeroshotDir, 'clusters.json'); - try { - const clustersData = await fs.readFile(clustersFile, 'utf-8'); - const clusters = JSON.parse(clustersData); - console.log('[OK] Clusters metadata accessible, count:', Object.keys(clusters).length); - } catch { - console.log('[INFO] No clusters.json found (normal for fresh install)'); - } - } catch (error) { - // Vibe directory might not exist if no tasks have been run yet - console.log('[INFO] Vibe directory not found or empty (normal for fresh install)'); - } - }); -}); diff --git a/tests/integration/hello-e2e.test.ts b/tests/integration/hello-e2e.test.ts deleted file mode 100644 index 3d17c315..00000000 --- a/tests/integration/hello-e2e.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Hello Vibe Test - * - * Simple test that echoes "hello from zeroshot" and exits successfully. - */ - -import { test, expect } from '@playwright/test'; - -test('should echo hello from zeroshot', async () => { - // Echo hello from zeroshot - console.log('hello from zeroshot'); - - // Exit successfully - expect(true).toBe(true); -}); diff --git a/tests/integration/message-bus.test.js b/tests/integration/message-bus.test.js index 13007802..94d93ae4 100644 --- a/tests/integration/message-bus.test.js +++ b/tests/integration/message-bus.test.js @@ -647,6 +647,25 @@ function registerTaskIdTokenTotalsTest() { }); } +function registerCloseTests() { + describe('Close lifecycle', () => { + it('should be idempotent and ignore publishes after close', () => { + messageBus.close(); + messageBus.close(); + ledger.close(); + + const published = messageBus.publish({ + cluster_id: 'closed-cluster', + topic: 'AFTER_CLOSE', + sender: 'test', + content: { text: 'ignored' }, + }); + + assert.strictEqual(published, null); + }); + }); +} + describe('MessageBus Integration', function () { this.timeout(10000); @@ -672,4 +691,5 @@ describe('MessageBus Integration', function () { registerEventEmissionTests(); registerBatchPublishingTests(); registerTaskIdCausalLinkingTests(); + registerCloseTests(); }); diff --git a/tests/integration/orchestrator-worktree.test.js b/tests/integration/orchestrator-worktree.test.js index 99a17db8..c537a9ce 100644 --- a/tests/integration/orchestrator-worktree.test.js +++ b/tests/integration/orchestrator-worktree.test.js @@ -81,7 +81,7 @@ function registerWorktreeHooks() { execSync('git config user.email "test@test.com"', { cwd: testRepoDir, stdio: 'pipe' }); execSync('git config user.name "Test User"', { cwd: testRepoDir, stdio: 'pipe' }); fs.writeFileSync(path.join(testRepoDir, 'README.md'), '# Test Repo'); - execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' }); + execSync('git add README.md', { cwd: testRepoDir, stdio: 'pipe' }); execSync('git commit -m "Initial commit"', { cwd: testRepoDir, stdio: 'pipe' }); mockRunner = new MockTaskRunner(); diff --git a/tests/unit/agent-lifecycle-stop.test.js b/tests/unit/agent-lifecycle-stop.test.js new file mode 100644 index 00000000..f55a5fe2 --- /dev/null +++ b/tests/unit/agent-lifecycle-stop.test.js @@ -0,0 +1,17 @@ +const assert = require('node:assert'); + +const { stop } = require('../../src/agent/agent-lifecycle'); + +describe('Agent lifecycle stop', function () { + it('clears liveness monitoring even when the agent is already not running', async function () { + const interval = setInterval(() => {}, 60_000); + const agent = { + running: false, + livenessCheckInterval: interval, + }; + + await stop(agent); + + assert.strictEqual(agent.livenessCheckInterval, null); + }); +}); diff --git a/tests/unit/run-plan.test.js b/tests/unit/run-plan.test.js new file mode 100644 index 00000000..d77bcb69 --- /dev/null +++ b/tests/unit/run-plan.test.js @@ -0,0 +1,120 @@ +/** + * Test: resolveRunPlan (the single canonical run-mode resolver) + * + * Verifies isolation/delivery/autoMerge for every flag combination, and — the + * point of #582 — that the run-mode LABEL (resolveRunMode) is a pure view of the + * same plan, so the label and the behavior cannot drift. + */ + +const assert = require('assert'); +const { resolveRunPlan } = require('../../lib/run-plan'); +const { resolveRunMode } = require('../../lib/run-mode'); + +describe('resolveRunPlan', function () { + it('resolves {} to no isolation, no delivery', function () { + assert.deepStrictEqual(resolveRunPlan({}), { + isolation: 'none', + delivery: 'none', + autoMerge: false, + }); + }); + + it('resolves {worktree: true}', function () { + assert.deepStrictEqual(resolveRunPlan({ worktree: true }), { + isolation: 'worktree', + delivery: 'none', + autoMerge: false, + }); + }); + + it('resolves {docker: true}', function () { + assert.deepStrictEqual(resolveRunPlan({ docker: true }), { + isolation: 'docker', + delivery: 'none', + autoMerge: false, + }); + }); + + it('resolves {pr: true} to pr delivery, no auto-merge', function () { + assert.deepStrictEqual(resolveRunPlan({ pr: true }), { + isolation: 'worktree', + delivery: 'pr', + autoMerge: false, + }); + }); + + it('resolves {ship: true} to ship delivery with auto-merge', function () { + assert.deepStrictEqual(resolveRunPlan({ ship: true }), { + isolation: 'worktree', + delivery: 'ship', + autoMerge: true, + }); + }); + + it('resolves {pr: true, docker: true}', function () { + assert.deepStrictEqual(resolveRunPlan({ pr: true, docker: true }), { + isolation: 'docker', + delivery: 'pr', + autoMerge: false, + }); + }); + + it('resolves {ship: true, docker: true}', function () { + assert.deepStrictEqual(resolveRunPlan({ ship: true, docker: true }), { + isolation: 'docker', + delivery: 'ship', + autoMerge: true, + }); + }); + + it('treats an explicit autoMerge as ship-equivalent (future --auto-merge flag)', function () { + // Regression for the normalizeRunOptions overwrite bug: an explicit + // autoMerge intent must survive, not be reset to false. + assert.deepStrictEqual(resolveRunPlan({ pr: true, autoMerge: true }), { + isolation: 'worktree', + delivery: 'ship', + autoMerge: true, + }); + }); + + it('returns a frozen object', function () { + assert.strictEqual(Object.isFrozen(resolveRunPlan({})), true); + }); + + it('defaults options to {} when called with no arguments', function () { + assert.deepStrictEqual(resolveRunPlan(), { + isolation: 'none', + delivery: 'none', + autoMerge: false, + }); + }); +}); + +describe('resolveRunMode is a view of resolveRunPlan (cannot drift)', function () { + // Expected label derived ONLY from the plan. If resolveRunMode ever grows an + // independent cascade again, this binding breaks. + function labelFromPlan(options) { + const { isolation, delivery } = resolveRunPlan(options); + const suffix = isolation === 'docker' ? '+docker' : ''; + if (delivery === 'ship') return `ship${suffix}`; + if (delivery === 'pr') return `pr${suffix}`; + if (isolation === 'docker') return 'docker'; + if (isolation === 'worktree') return 'worktree'; + return null; + } + + it('agrees with the plan across all 16 flag combinations', function () { + const flags = ['worktree', 'docker', 'pr', 'ship']; + for (let mask = 0; mask < 1 << flags.length; mask++) { + const options = {}; + flags.forEach((f, i) => { + if (mask & (1 << i)) options[f] = true; + }); + assert.strictEqual( + resolveRunMode(options), + labelFromPlan(options), + `run-mode label drifted from plan for ${JSON.stringify(options)}` + ); + } + }); +}); diff --git a/tests/unit/start-cluster-config.test.js b/tests/unit/start-cluster-config.test.js index c9074a21..0ac201e3 100644 --- a/tests/unit/start-cluster-config.test.js +++ b/tests/unit/start-cluster-config.test.js @@ -67,6 +67,47 @@ describe('buildStartOptions() runMode', function () { }); }); +describe('buildStartOptions() isolation (single producer via run plan)', function () { + it('derives isolation=true / worktree=false for --docker', function () { + const result = buildStartOptions({ clusterId: 'c1', options: { docker: true } }); + assert.strictEqual(result.isolation, true); + assert.strictEqual(result.worktree, false); + }); + + it('derives worktree=true / isolation=false for --pr (worktree delivery)', function () { + const result = buildStartOptions({ clusterId: 'c1', options: { pr: true } }); + assert.strictEqual(result.isolation, false); + assert.strictEqual(result.worktree, true); + }); + + it('folds settings.defaultDocker into the plan (isolation without a CLI flag)', function () { + const result = buildStartOptions({ + clusterId: 'c1', + options: {}, + settings: { defaultDocker: true }, + }); + assert.strictEqual(result.isolation, true); + assert.strictEqual(result.worktree, false); + // Label reflects the SAME effective plan, not just the raw flags. + assert.strictEqual(result.runMode, 'docker'); + }); + + it('isolation and worktree are mutually exclusive (docker wins over worktree)', function () { + const result = buildStartOptions({ + clusterId: 'c1', + options: { worktree: true, docker: true }, + }); + assert.strictEqual(result.isolation, true); + assert.strictEqual(result.worktree, false); + }); + + it('no isolation when no flags and no settings', function () { + const result = buildStartOptions({ clusterId: 'c1', options: {}, settings: {} }); + assert.strictEqual(result.isolation, false); + assert.strictEqual(result.worktree, false); + }); +}); + describe('buildStartOptions() autoMerge', function () { it('resolves autoMerge=true for --ship', function () { const result = buildStartOptions({ clusterId: 'c1', options: { ship: true } });