Skip to content

Commit e708fc2

Browse files
author
Test User
committed
fix: resolve queue, slicer, and reviewer runtime bugs
1 parent 193fc24 commit e708fc2

9 files changed

Lines changed: 322 additions & 29 deletions

File tree

packages/cli/src/__tests__/commands/queue.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ vi.mock('@/cli/commands/shared/env-builder.js', () => ({
4242
import { spawn } from 'child_process';
4343
import { queueCommand } from '@/cli/commands/queue.js';
4444
import {
45+
claimJobSlot,
4546
dispatchNextJob,
4647
getScriptPath,
4748
loadConfig,
@@ -133,6 +134,34 @@ describe('queue command', () => {
133134
expect(markJobRunning).toHaveBeenCalledWith(42);
134135
});
135136

137+
it('dispatch resolves the merger cron script for merger jobs', async () => {
138+
vi.mocked(dispatchNextJob).mockReturnValue({
139+
id: 77,
140+
projectPath: '/projects/foo',
141+
projectName: 'foo',
142+
jobType: 'merger',
143+
priority: 4,
144+
status: 'dispatched',
145+
envJson: {},
146+
enqueuedAt: 100,
147+
dispatchedAt: 110,
148+
expiredAt: null,
149+
} as never);
150+
151+
vi.mocked(getScriptPath).mockReturnValue('/pkg/dist/scripts/night-watch-merger-cron.sh');
152+
153+
await runQueue(['dispatch']);
154+
155+
expect(getScriptPath).toHaveBeenCalledWith('night-watch-merger-cron.sh');
156+
expect(spawn).toHaveBeenCalledWith(
157+
'bash',
158+
['/pkg/dist/scripts/night-watch-merger-cron.sh', '/projects/foo'],
159+
expect.objectContaining({
160+
cwd: '/projects/foo',
161+
}),
162+
);
163+
});
164+
136165
it('dispatch is a no-op when there are no pending jobs', async () => {
137166
vi.mocked(dispatchNextJob).mockReturnValue(null);
138167

@@ -220,6 +249,28 @@ describe('queue command', () => {
220249
});
221250
});
222251

252+
it('claim accepts pr-resolver jobs', async () => {
253+
vi.mocked(claimJobSlot).mockReturnValue({ claimed: true, id: 88 } as never);
254+
255+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
256+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
257+
258+
await runQueue(['claim', 'pr-resolver', '/projects/foo']);
259+
260+
expect(claimJobSlot).toHaveBeenCalledWith(
261+
'/projects/foo',
262+
'foo',
263+
'pr-resolver',
264+
undefined,
265+
queueConfig,
266+
);
267+
expect(writeSpy).toHaveBeenCalledWith('88\n');
268+
expect(exitSpy).toHaveBeenCalledWith(0);
269+
270+
writeSpy.mockRestore();
271+
exitSpy.mockRestore();
272+
});
273+
223274
it('dispatch preserves persisted reviewer runtime markers from the queue entry', async () => {
224275
vi.mocked(buildQueuedJobEnv).mockReturnValue({
225276
NW_PROVIDER_CMD: 'claude',

packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,102 @@ describe('core flow smoke tests (bash scripts)', () => {
10411041
expect(result.stdout).toContain('NIGHT_WATCH_RESULT:timeout');
10421042
});
10431043

1044+
it('reviewer targeted mode should give attempt 1 the full remaining runtime budget', () => {
1045+
const projectDir = mkTempDir('nw-smoke-reviewer-budget-');
1046+
initGitRepo(projectDir);
1047+
fs.mkdirSync(path.join(projectDir, 'logs'), { recursive: true });
1048+
1049+
const fakeBin = mkTempDir('nw-smoke-reviewer-budget-bin-');
1050+
const scoreReadyFile = path.join(projectDir, '.smoke-review-score-ready');
1051+
1052+
fs.writeFileSync(
1053+
path.join(fakeBin, 'claude'),
1054+
'#!/usr/bin/env bash\n' + 'sleep 2\n' + 'touch "$NW_SMOKE_SCORE_READY_FILE"\n' + 'exit 0\n',
1055+
{ encoding: 'utf-8', mode: 0o755 },
1056+
);
1057+
1058+
fs.writeFileSync(
1059+
path.join(fakeBin, 'gh'),
1060+
'#!/usr/bin/env bash\n' +
1061+
'args="$*"\n' +
1062+
'if [[ "$1" == "repo" && "$2" == "view" ]]; then\n' +
1063+
" echo 'owner/repo'\n" +
1064+
' exit 0\n' +
1065+
'fi\n' +
1066+
'if [[ "$1" == "pr" && "$2" == "view" ]]; then\n' +
1067+
' if [[ "$args" == *"title,headRefName,body,url"* ]]; then\n' +
1068+
' echo \'{"title":"Budget Test","headRefName":"night-watch/budget-test","body":"Budget test body","url":"https://example.test/pr/1"}\'\n' +
1069+
' elif [[ "$args" == *"mergeStateStatus"* ]]; then\n' +
1070+
" echo 'CLEAN'\n" +
1071+
' elif [[ "$args" == *"headRefOid"* ]]; then\n' +
1072+
" echo 'abc123'\n" +
1073+
' elif [[ "$args" == *"comments"* ]]; then\n' +
1074+
' if [[ -f "$NW_SMOKE_SCORE_READY_FILE" ]]; then\n' +
1075+
" echo 'Overall Score: 85/100'\n" +
1076+
' fi\n' +
1077+
' else\n' +
1078+
' echo \'{"number":1}\'\n' +
1079+
' fi\n' +
1080+
' exit 0\n' +
1081+
'fi\n' +
1082+
'if [[ "$1" == "pr" && "$2" == "list" ]]; then\n' +
1083+
' if [[ "$args" == *"number,headRefName,labels"* ]]; then\n' +
1084+
" printf '1\\tnight-watch/budget-test\\t\\n'\n" +
1085+
' else\n' +
1086+
" printf 'night-watch/budget-test\\n'\n" +
1087+
' fi\n' +
1088+
' exit 0\n' +
1089+
'fi\n' +
1090+
'if [[ "$1" == "pr" && "$2" == "checks" ]]; then\n' +
1091+
' if [[ "$args" == *"--json"* ]]; then\n' +
1092+
" echo '[]'\n" +
1093+
' else\n' +
1094+
" echo ''\n" +
1095+
' fi\n' +
1096+
' exit 0\n' +
1097+
'fi\n' +
1098+
'if [[ "$1" == "api" ]]; then\n' +
1099+
' if [[ -f "$NW_SMOKE_SCORE_READY_FILE" ]]; then\n' +
1100+
" echo 'Overall Score: 85/100'\n" +
1101+
' else\n' +
1102+
" echo ''\n" +
1103+
' fi\n' +
1104+
' exit 0\n' +
1105+
'fi\n' +
1106+
'if [[ "$1" == "issue" && "$2" == "view" ]]; then\n' +
1107+
' echo \'{"title":"Linked Issue","body":"Body","url":"https://example.test/issue/1"}\'\n' +
1108+
' exit 0\n' +
1109+
'fi\n' +
1110+
'if [[ "$1" == "pr" && "$2" == "edit" ]]; then\n' +
1111+
' exit 0\n' +
1112+
'fi\n' +
1113+
'exit 0\n',
1114+
{ encoding: 'utf-8', mode: 0o755 },
1115+
);
1116+
1117+
const result = runScript(reviewerScript, projectDir, {
1118+
PATH: `${fakeBin}:${process.env.PATH}`,
1119+
NW_PROVIDER_CMD: 'claude',
1120+
NW_DEFAULT_BRANCH: 'main',
1121+
NW_BRANCH_PATTERNS: 'night-watch/',
1122+
NW_REVIEWER_MAX_RUNTIME: '3',
1123+
NW_REVIEWER_MAX_RETRIES: '2',
1124+
NW_REVIEWER_RETRY_DELAY: '0',
1125+
NW_REVIEWER_WORKER_MODE: '0',
1126+
NW_REVIEWER_PARALLEL: '0',
1127+
NW_AUTO_MERGE: '0',
1128+
NW_QUEUE_ENABLED: '0',
1129+
NW_TARGET_PR: '1',
1130+
NW_SMOKE_SCORE_READY_FILE: scoreReadyFile,
1131+
});
1132+
1133+
const reviewerLog = fs.readFileSync(path.join(projectDir, 'logs', 'reviewer.log'), 'utf-8');
1134+
1135+
expect(result.status).toBe(0);
1136+
expect(reviewerLog).toContain('RETRY: Starting attempt 1/3 (timeout: 3s) pr=1');
1137+
expect(result.stdout).toContain('NIGHT_WATCH_RESULT:success_reviewed|prs=#1');
1138+
});
1139+
10441140
it('qa should emit timeout when provider exceeds NW_QA_MAX_RUNTIME', () => {
10451141
const projectDir = mkTempDir('nw-smoke-qa-timeout-');
10461142
initGitRepo(projectDir);
@@ -1879,7 +1975,7 @@ describe('core flow smoke tests (bash scripts)', () => {
18791975
'fi\n' +
18801976
'if [[ "$1" == "pr" && "$2" == "checks" ]]; then\n' +
18811977
' if [[ "$args" == *"--json"* ]]; then\n' +
1882-
" echo '[{\"bucket\":\"fail\",\"state\":\"failure\",\"conclusion\":\"failure\",\"name\":\"test-check\"}]'\n" +
1978+
' echo \'[{"bucket":"fail","state":"failure","conclusion":"failure","name":"test-check"}]\'\n' +
18831979
' else\n' +
18841980
" echo 'fail 1/1 checks'\n" +
18851981
' fi\n' +

packages/cli/src/__tests__/scripts/night-watch-helpers.test.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,8 @@ describe('night-watch helpers', () => {
5757

5858
fs.mkdirSync(path.dirname(staleWorktreeDir), { recursive: true });
5959
expect(
60-
runShell(
61-
`git worktree add -b "night-watch/stale-branch" "${staleWorktreeDir}" HEAD`,
62-
repoDir,
63-
).status,
60+
runShell(`git worktree add -b "night-watch/stale-branch" "${staleWorktreeDir}" HEAD`, repoDir)
61+
.status,
6462
).toBe(0);
6563

6664
fs.rmSync(staleWorktreeDir, { recursive: true, force: true });
@@ -80,4 +78,30 @@ describe('night-watch helpers', () => {
8078
expect(staleAfter.stdout).not.toContain(staleWorktreeDir);
8179
expect(staleAfter.stdout).not.toContain('night-watch/stale-branch');
8280
});
81+
82+
it('cleanup_worktrees removes unregistered stale night-watch worktree directories on disk', () => {
83+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'night-watch-helpers-orphan-'));
84+
const repoDir = path.join(tempDir, 'repo');
85+
const orphanWorktreeDir = path.join(tempDir, 'repo-nw-review-runner-pr-42-orphan');
86+
const logFile = path.join(tempDir, 'cleanup-orphan.log');
87+
88+
fs.mkdirSync(repoDir, { recursive: true });
89+
90+
expect(runShell('git init', repoDir).status).toBe(0);
91+
expect(runShell('git config user.name "Night Watch Test"', repoDir).status).toBe(0);
92+
expect(runShell('git config user.email "[email protected]"', repoDir).status).toBe(0);
93+
94+
fs.writeFileSync(path.join(repoDir, 'README.md'), '# test\n');
95+
expect(runShell('git add README.md && git commit -m "init"', repoDir).status).toBe(0);
96+
97+
fs.mkdirSync(orphanWorktreeDir, { recursive: true });
98+
fs.writeFileSync(path.join(orphanWorktreeDir, 'stale.txt'), 'stale\n');
99+
100+
const cleanupResult = runShell(
101+
`source "${helpersScript}"; LOG_FILE="${logFile}"; NW_CRON_TRIGGER=1; cleanup_worktrees "${repoDir}"`,
102+
repoDir,
103+
);
104+
expect(cleanupResult.status).toBe(0);
105+
expect(fs.existsSync(orphanWorktreeDir)).toBe(false);
106+
});
83107
});

packages/cli/src/commands/queue.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,16 @@ import { buildQueuedJobEnv } from './shared/env-builder.js';
3333

3434
const logger = createLogger('queue');
3535

36-
const VALID_JOB_TYPES: JobType[] = ['executor', 'reviewer', 'qa', 'audit', 'slicer', 'planner'];
36+
const VALID_JOB_TYPES: JobType[] = [
37+
'executor',
38+
'reviewer',
39+
'qa',
40+
'audit',
41+
'slicer',
42+
'planner',
43+
'pr-resolver',
44+
'merger',
45+
];
3746

3847
function formatTimestamp(unixTs: number | null): string {
3948
if (unixTs === null) return '-';
@@ -211,7 +220,10 @@ export function createQueueCommand(): Command {
211220
.command('resolve-key')
212221
.description('Resolve the provider bucket key for a given project and job type')
213222
.requiredOption('--project <dir>', 'Project directory')
214-
.requiredOption('--job-type <type>', 'Job type (executor, reviewer, qa, audit, slicer)')
223+
.requiredOption(
224+
'--job-type <type>',
225+
'Job type (executor, reviewer, qa, audit, slicer, planner, pr-resolver, merger)',
226+
)
215227
.action((opts: { project: string; jobType: string }) => {
216228
try {
217229
const config = loadConfig(opts.project);
@@ -441,6 +453,10 @@ function getScriptNameForJobType(jobType: JobType): string | null {
441453
return 'night-watch-slicer-cron.sh';
442454
case 'planner':
443455
return 'night-watch-plan-cron.sh';
456+
case 'pr-resolver':
457+
return 'night-watch-pr-resolver-cron.sh';
458+
case 'merger':
459+
return 'night-watch-merger-cron.sh';
444460
default:
445461
return null;
446462
}

packages/core/src/__tests__/roadmap-scanner.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,64 @@ Generated: 2026-03-03T12:00:00.000Z
627627
expect(providerArgs).not.toContain('--prompt');
628628
});
629629

630+
it('sliceRoadmapItem should resolve preset commands and env for named slicer presets', async () => {
631+
const item: IRoadmapItem = {
632+
hash: 'glm50001',
633+
title: 'GLM Preset Feature',
634+
description: 'Uses a named preset',
635+
checked: false,
636+
section: 'Features',
637+
};
638+
639+
mockProviderSuccess('01-glm-preset-feature.md');
640+
641+
const config: INightWatchConfig = {
642+
...defaultConfig,
643+
prdDir: path.relative(tempDir, prdDir),
644+
providerEnv: {
645+
GLOBAL_PROVIDER_ENV: '1',
646+
},
647+
jobProviders: {
648+
slicer: 'glm-5',
649+
},
650+
providerPresets: {
651+
'glm-5': {
652+
name: 'GLM-5',
653+
command: 'claude',
654+
promptFlag: '-p',
655+
autoApproveFlag: '--dangerously-skip-permissions',
656+
modelFlag: '--model',
657+
model: 'glm-5',
658+
envVars: {
659+
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
660+
API_TIMEOUT_MS: '3000000',
661+
},
662+
},
663+
},
664+
};
665+
666+
const result = await sliceRoadmapItem(tempDir, prdDir, item, config);
667+
668+
expect(result.sliced).toBe(true);
669+
670+
const spawnMock = childProcess.spawn as ReturnType<typeof vi.fn>;
671+
expect(spawnMock).toHaveBeenCalledTimes(1);
672+
673+
const [providerCmd, providerArgs, spawnOptions] = spawnMock.mock.calls[0] as [
674+
string,
675+
string[],
676+
{ env: Record<string, string> },
677+
];
678+
expect(providerCmd).toBe('claude');
679+
expect(providerArgs).toContain('-p');
680+
expect(providerArgs).toContain('--model');
681+
expect(providerArgs).toContain('glm-5');
682+
expect(providerArgs).toContain('--dangerously-skip-permissions');
683+
expect(spawnOptions.env.ANTHROPIC_BASE_URL).toBe('https://api.z.ai/api/anthropic');
684+
expect(spawnOptions.env.API_TIMEOUT_MS).toBe('3000000');
685+
expect(spawnOptions.env.GLOBAL_PROVIDER_ENV).toBe('1');
686+
});
687+
630688
it('sliceRoadmapItem should fail when provider succeeds but does not create file', async () => {
631689
const item: IRoadmapItem = {
632690
hash: 'nofil1234',

packages/core/src/templates/slicer-prompt.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,10 +177,15 @@ export function loadSlicerTemplate(templateDir?: string): string {
177177
return cachedTemplate;
178178
}
179179

180-
// Determine the template file path
181-
const templatePath = templateDir
182-
? path.join(templateDir, 'slicer.md')
183-
: path.resolve(__dirname, '..', '..', 'templates', 'slicer.md');
180+
const candidatePaths = templateDir
181+
? [path.join(templateDir, 'slicer.md')]
182+
: [
183+
path.resolve(__dirname, 'slicer.md'),
184+
path.resolve(__dirname, '..', 'templates', 'slicer.md'),
185+
path.resolve(__dirname, '..', '..', '..', '..', 'templates', 'slicer.md'),
186+
];
187+
const templatePath =
188+
candidatePaths.find((candidate) => fs.existsSync(candidate)) ?? candidatePaths[0]!;
184189

185190
try {
186191
cachedTemplate = fs.readFileSync(templatePath, 'utf-8');

0 commit comments

Comments
 (0)