From d2b789a6b6ba58728078854530114aebbfcdba99 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 22:14:29 +0200 Subject: [PATCH] Prove agent lane session lifecycle end to end The start path created branch/worktree state without writing the canonical session store, while finish already resolved sessions from .guardex/agents/sessions. The lifecycle test now locks start, status, inspect, and finish resolution to one session identity without launching real agents. Constraint: Must not call real Codex, Claude, gh, or network from the test Rejected: Exercise real branch start and finish scripts | they would launch broader Guardex side effects and make the test depend on gh/network availability Confidence: high Scope-risk: narrow Directive: Keep gx agents start/status/finish on the same .guardex/agents/sessions store unless a migration updates all three together Tested: node --test test/agents-lifecycle.test.js test/agents-start-claims.test.js test/agents-finish.test.js test/agents-inspect.test.js Not-tested: npm test remains red on pre-existing agents-launch and cli-args-dispatch expectation drift --- src/agents/start.js | 7 +- test/agents-lifecycle.test.js | 153 ++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 test/agents-lifecycle.test.js diff --git a/src/agents/start.js b/src/agents/start.js index 6fc0beb5..8e070e37 100644 --- a/src/agents/start.js +++ b/src/agents/start.js @@ -198,8 +198,9 @@ function buildRecoveryLines(metadata, claims, session) { return `${lines.join('\n')}\n`; } -function startAgentLane(repoRoot, options) { - const startResult = runPackageAsset('branchStart', buildBranchStartArgs(options), { cwd: repoRoot }); +function startAgentLane(repoRoot, options, deps = {}) { + const packageAssetRunner = deps.packageAssetRunner || runPackageAsset; + const startResult = packageAssetRunner('branchStart', buildBranchStartArgs(options), { cwd: repoRoot }); let stdout = String(startResult.stdout || ''); let stderr = String(startResult.stderr || ''); if (isSpawnFailure(startResult)) { @@ -232,7 +233,7 @@ function startAgentLane(repoRoot, options) { }; } - const claimResult = runPackageAsset( + const claimResult = packageAssetRunner( 'lockTool', ['claim', '--branch', metadata.branch, ...options.claims], { cwd: metadata.worktreePath }, diff --git a/test/agents-lifecycle.test.js b/test/agents-lifecycle.test.js new file mode 100644 index 00000000..aae5f3bf --- /dev/null +++ b/test/agents-lifecycle.test.js @@ -0,0 +1,153 @@ +const { + test, + assert, + fs, + path, + runNode, + runCmd, + initRepoOnBranch, + seedCommit, +} = require('./helpers/install-test-helpers'); + +const { startAgentLane } = require('../src/agents/start'); +const { finishAgentSession } = require('../src/agents/finish'); +const { readAgentSession } = require('../src/agents/sessions'); + +function sessionPath(repoRoot, sessionId) { + return path.join(repoRoot, '.guardex', 'agents', 'sessions', `${sessionId}.json`); +} + +function activeSessionPath(repoRoot, sessionId) { + return path.join(repoRoot, '.omx', 'state', 'active-sessions', `${sessionId}.json`); +} + +function writeLockRegistry(repoRoot, branch) { + const lockPath = path.join(repoRoot, '.omx', 'state', 'agent-file-locks.json'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + lockPath, + `${JSON.stringify({ + locks: { + 'src/lifecycle.js': { + branch, + claimed_at: '2026-04-29T20:00:00.000Z', + allow_delete: false, + }, + }, + }, null, 2)}\n`, + 'utf8', + ); +} + +function makeBranchStartMock(repoRoot, branch, worktreePath) { + return (assetKey, args, options) => { + assert.equal(assetKey, 'branchStart'); + assert.equal(options.cwd, repoRoot); + assert.deepEqual(args, ['--task', 'exercise lifecycle', '--agent', 'codex', '--base', 'main']); + + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + const result = runCmd('git', ['worktree', 'add', '-b', branch, worktreePath, 'main'], repoRoot); + assert.equal(result.status, 0, result.stderr || result.stdout); + const configResult = runCmd('git', ['config', `branch.${branch}.guardexBase`, 'main'], repoRoot); + assert.equal(configResult.status, 0, configResult.stderr || configResult.stdout); + + fs.mkdirSync(path.join(worktreePath, 'src'), { recursive: true }); + fs.writeFileSync(path.join(worktreePath, 'src', 'lifecycle.js'), 'export const lifecycle = true;\n', 'utf8'); + + return { + status: 0, + stdout: [ + `[agent-branch-start] Created branch: ${branch}`, + `[agent-branch-start] Worktree: ${worktreePath}`, + '', + ].join('\n'), + stderr: '', + }; + }; +} + +test('agents local lane lifecycle resolves one canonical session across start status inspect and finish', () => { + const repoRoot = initRepoOnBranch('main'); + seedCommit(repoRoot); + const branch = 'agent/codex/lifecycle'; + const worktreePath = path.join(repoRoot, '.omx', 'agent-worktrees', 'repo__codex__lifecycle'); + const sessionId = 'agent__codex__lifecycle'; + + const startResult = startAgentLane( + repoRoot, + { + task: 'exercise lifecycle', + agent: 'codex', + base: 'main', + claims: [], + }, + { packageAssetRunner: makeBranchStartMock(repoRoot, branch, worktreePath) }, + ); + + assert.equal(startResult.status, 0, startResult.stderr || startResult.stdout); + assert.match(startResult.stdout, /Created branch: agent\/codex\/lifecycle/); + assert.equal(fs.existsSync(sessionPath(repoRoot, sessionId)), true); + assert.equal( + fs.existsSync(activeSessionPath(repoRoot, sessionId)), + false, + 'agents start must write the finish/status session store, not only active-session telemetry', + ); + + const session = readAgentSession(repoRoot, sessionId); + assert.equal(session.branch, branch); + assert.equal(session.worktreePath, worktreePath); + assert.equal(session.base, 'main'); + assert.equal(session.status, 'active'); + + const statusResult = runNode(['agents', 'status', '--target', repoRoot], repoRoot); + assert.equal(statusResult.status, 0, statusResult.stderr || statusResult.stdout); + assert.match(statusResult.stdout, /Agent sessions: 1/); + assert.match(statusResult.stdout, /agent__codex__lifecycle codex active branch=agent\/codex\/lifecycle base=main/); + assert.match(statusResult.stdout, /worktreeExists=yes locks=0 task=exercise lifecycle/); + + writeLockRegistry(repoRoot, branch); + + const filesResult = runNode(['agents', 'files', '--target', repoRoot, '--branch', branch, '--json'], repoRoot); + assert.equal(filesResult.status, 0, filesResult.stderr || filesResult.stdout); + const filesPayload = JSON.parse(filesResult.stdout); + assert.equal(filesPayload.branch, branch); + assert.equal(filesPayload.worktreePath, worktreePath); + assert.deepEqual(filesPayload.files, ['src/lifecycle.js']); + + const diffResult = runNode(['agents', 'diff', '--target', repoRoot, '--branch', branch], repoRoot); + assert.equal(diffResult.status, 0, diffResult.stderr || diffResult.stdout); + assert.match(diffResult.stdout, /src\/lifecycle\.js/); + assert.match(diffResult.stdout, /\+export const lifecycle = true;/); + + const locksResult = runNode(['agents', 'locks', '--target', repoRoot, '--branch', branch, '--json'], repoRoot); + assert.equal(locksResult.status, 0, locksResult.stderr || locksResult.stdout); + const locksPayload = JSON.parse(locksResult.stdout); + assert.deepEqual( + locksPayload.locks.map((lock) => [lock.file, lock.branch]), + [['src/lifecycle.js', branch]], + ); + + const finishCalls = []; + const finishResult = finishAgentSession( + repoRoot, + { sessionId, branch: '', finishArgs: ['--no-wait-for-merge'] }, + { + output: { write() {} }, + finishRunner(args) { + finishCalls.push(args); + return { ok: true }; + }, + }, + ); + + assert.deepEqual(finishCalls, [[ + '--target', + repoRoot, + '--branch', + branch, + '--no-wait-for-merge', + ]]); + assert.equal(finishResult.session.id, sessionId); + assert.equal(finishResult.session.branch, branch); + assert.equal(readAgentSession(repoRoot, sessionId).status, 'pr-opened'); +});