Skip to content

Commit 2f8ed5f

Browse files
author
StackMemory Bot (CLI)
committed
fix(conductor): harden lane mode cleanup
1 parent b6c3afb commit 2f8ed5f

3 files changed

Lines changed: 533 additions & 5 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { Command } from 'commander';
3+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
4+
import { join } from 'path';
5+
import { tmpdir } from 'os';
6+
7+
describe('conductor lane mode', () => {
8+
let tempDir: string;
9+
let sigintBefore: Function[];
10+
let sigtermBefore: Function[];
11+
12+
beforeEach(() => {
13+
tempDir = mkdtempSync(join(tmpdir(), 'sm-conductor-lane-'));
14+
sigintBefore = process.listeners('SIGINT');
15+
sigtermBefore = process.listeners('SIGTERM');
16+
vi.resetModules();
17+
});
18+
19+
afterEach(() => {
20+
for (const listener of process.listeners('SIGINT')) {
21+
if (!sigintBefore.includes(listener)) {
22+
process.removeListener('SIGINT', listener);
23+
}
24+
}
25+
for (const listener of process.listeners('SIGTERM')) {
26+
if (!sigtermBefore.includes(listener)) {
27+
process.removeListener('SIGTERM', listener);
28+
}
29+
}
30+
vi.restoreAllMocks();
31+
vi.resetModules();
32+
rmSync(tempDir, { recursive: true, force: true });
33+
});
34+
35+
it('skips lane cleanup when worktree cleanliness cannot be verified', async () => {
36+
const execSync = vi.fn((cmd: string) => {
37+
if (cmd === 'git branch --show-current') return 'lane/main\n';
38+
if (cmd === `git branch --list 'worktree-agent-*'`) {
39+
return ' worktree-agent-123\n';
40+
}
41+
if (
42+
cmd === 'git merge-base --is-ancestor "worktree-agent-123" "lane/main"'
43+
) {
44+
throw Object.assign(new Error('not ancestor'), { status: 1 });
45+
}
46+
if (cmd === 'git cherry "lane/main" "worktree-agent-123"') return '';
47+
if (cmd === 'git worktree list --porcelain') {
48+
return [
49+
'worktree /tmp/worktree-agent-123',
50+
'branch refs/heads/worktree-agent-123',
51+
'',
52+
].join('\n');
53+
}
54+
if (cmd === 'git -C "/tmp/worktree-agent-123" status --short') {
55+
throw new Error('status unavailable');
56+
}
57+
throw new Error(`Unexpected execSync: ${cmd}`);
58+
});
59+
60+
vi.doMock('child_process', async () => {
61+
const actual =
62+
await vi.importActual<typeof import('child_process')>('child_process');
63+
return { ...actual, execSync };
64+
});
65+
66+
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
67+
const { createConductorCommands } = await import('../orchestrate.js');
68+
69+
const program = new Command();
70+
program.exitOverride();
71+
program.addCommand(createConductorCommands());
72+
73+
await program.parseAsync([
74+
'node',
75+
'stackmemory',
76+
'conductor',
77+
'lane',
78+
'cleanup',
79+
'--repo',
80+
tempDir,
81+
]);
82+
83+
const output = consoleLog.mock.calls
84+
.map((call) => String(call[0]))
85+
.join('\n');
86+
expect(output).toContain('unknown');
87+
expect(output).toContain('could not verify clean state');
88+
expect(
89+
execSync.mock.calls.some(([cmd]) =>
90+
String(cmd).includes('git worktree remove')
91+
)
92+
).toBe(false);
93+
});
94+
95+
it('uses worktrees in auto mode when lane mode is enabled', async () => {
96+
const repoRoot = join(tempDir, 'repo');
97+
const workspaceRoot = join(tempDir, 'workspaces');
98+
const appServerPath = join(tempDir, 'claude-app-server.cjs');
99+
100+
mkdirSync(join(repoRoot, '.git', 'gitbutler'), { recursive: true });
101+
writeFileSync(appServerPath, 'module.exports = {};');
102+
103+
const execSync = vi.fn((cmd: string) => {
104+
if (cmd === 'but --version') return 'gitbutler 1.0.0\n';
105+
throw new Error(`Unexpected execSync: ${cmd}`);
106+
});
107+
108+
vi.doMock('child_process', async () => {
109+
const actual =
110+
await vi.importActual<typeof import('child_process')>('child_process');
111+
return { ...actual, execSync };
112+
});
113+
114+
const { Conductor } = await import('../orchestrator.js');
115+
const conductor = new Conductor({
116+
activeStates: ['Todo'],
117+
terminalStates: ['Done', 'Cancelled'],
118+
inProgressState: 'In Progress',
119+
inReviewState: 'In Review',
120+
pollIntervalMs: 1,
121+
maxConcurrent: 1,
122+
workspaceRoot,
123+
repoRoot,
124+
baseBranch: 'main',
125+
appServerPath,
126+
turnTimeoutMs: 1,
127+
maxRetries: 0,
128+
hookTimeoutMs: 1,
129+
agentMode: 'cli',
130+
workspaceMode: 'auto',
131+
laneBranch: 'lane/main',
132+
});
133+
134+
(conductor as unknown as Record<string, unknown>).createLinearClient = vi
135+
.fn()
136+
.mockResolvedValue(null);
137+
(conductor as unknown as Record<string, unknown>).cacheWorkflowStates = vi
138+
.fn()
139+
.mockResolvedValue(undefined);
140+
(conductor as unknown as Record<string, unknown>).writeStatusFile = vi.fn();
141+
(conductor as unknown as Record<string, unknown>).poll = vi
142+
.fn()
143+
.mockResolvedValue(undefined);
144+
(conductor as unknown as Record<string, unknown>).schedulePoll = vi
145+
.fn()
146+
.mockResolvedValue(undefined);
147+
148+
await conductor.start();
149+
150+
expect(
151+
execSync.mock.calls.some(([cmd]) => String(cmd) === 'but --version')
152+
).toBe(false);
153+
expect(
154+
(conductor as unknown as { useGitButler: boolean }).useGitButler
155+
).toBe(false);
156+
});
157+
158+
it('rejects explicit gitbutler mode when lane mode is enabled', async () => {
159+
const repoRoot = join(tempDir, 'repo');
160+
const workspaceRoot = join(tempDir, 'workspaces');
161+
const appServerPath = join(tempDir, 'claude-app-server.cjs');
162+
163+
mkdirSync(repoRoot, { recursive: true });
164+
writeFileSync(appServerPath, 'module.exports = {};');
165+
166+
const execSync = vi.fn();
167+
168+
vi.doMock('child_process', async () => {
169+
const actual =
170+
await vi.importActual<typeof import('child_process')>('child_process');
171+
return { ...actual, execSync };
172+
});
173+
174+
const { Conductor } = await import('../orchestrator.js');
175+
const conductor = new Conductor({
176+
activeStates: ['Todo'],
177+
terminalStates: ['Done', 'Cancelled'],
178+
inProgressState: 'In Progress',
179+
inReviewState: 'In Review',
180+
pollIntervalMs: 1,
181+
maxConcurrent: 1,
182+
workspaceRoot,
183+
repoRoot,
184+
baseBranch: 'main',
185+
appServerPath,
186+
turnTimeoutMs: 1,
187+
maxRetries: 0,
188+
hookTimeoutMs: 1,
189+
agentMode: 'cli',
190+
workspaceMode: 'gitbutler',
191+
laneBranch: 'lane/main',
192+
});
193+
194+
await expect(conductor.start()).rejects.toThrow(
195+
'--lane is only supported with git worktrees'
196+
);
197+
});
198+
});

0 commit comments

Comments
 (0)