tlc-claude-code 2.4.10 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/tlc/autofix.md +34 -1
- package/.claude/commands/tlc/build.md +203 -27
- package/.claude/commands/tlc/ci.md +178 -414
- package/.claude/commands/tlc/coverage.md +34 -0
- package/.claude/commands/tlc/deploy.md +19 -6
- package/.claude/commands/tlc/discuss.md +34 -0
- package/.claude/commands/tlc/docs.md +35 -1
- package/.claude/commands/tlc/e2e.md +300 -0
- package/.claude/commands/tlc/edge-cases.md +35 -1
- package/.claude/commands/tlc/init.md +38 -8
- package/.claude/commands/tlc/issues.md +46 -0
- package/.claude/commands/tlc/new-project.md +46 -4
- package/.claude/commands/tlc/plan.md +76 -0
- package/.claude/commands/tlc/quick.md +33 -0
- package/.claude/commands/tlc/release.md +85 -135
- package/.claude/commands/tlc/restore.md +14 -0
- package/.claude/commands/tlc/review.md +80 -1
- package/.claude/commands/tlc/tlc.md +134 -0
- package/.claude/commands/tlc/verify.md +64 -65
- package/.claude/commands/tlc/watchci.md +10 -0
- package/.claude/hooks/tlc-block-tools.sh +13 -0
- package/.claude/hooks/tlc-session-init.sh +9 -0
- package/CODING-STANDARDS.md +35 -10
- package/package.json +1 -1
- package/server/lib/block-tools-hook.js +23 -0
- package/server/lib/e2e/acceptance-parser.js +132 -0
- package/server/lib/e2e/acceptance-parser.test.js +110 -0
- package/server/lib/e2e/framework-detector.js +47 -0
- package/server/lib/e2e/framework-detector.test.js +94 -0
- package/server/lib/e2e/log-assertions.js +107 -0
- package/server/lib/e2e/log-assertions.test.js +68 -0
- package/server/lib/e2e/test-generator.js +159 -0
- package/server/lib/e2e/test-generator.test.js +121 -0
- package/server/lib/e2e/verify-runner.js +191 -0
- package/server/lib/e2e/verify-runner.test.js +167 -0
- package/server/lib/github/config.js +458 -0
- package/server/lib/github/config.test.js +385 -0
- package/server/lib/github/gh-client.js +303 -0
- package/server/lib/github/gh-client.test.js +499 -0
- package/server/lib/github/gh-projects.js +594 -0
- package/server/lib/github/gh-projects.test.js +583 -0
- package/server/lib/github/index.js +19 -0
- package/server/lib/github/plan-sync.js +456 -0
- package/server/lib/github/plan-sync.test.js +805 -0
- package/server/lib/hooks/block-tools-hook.test.js +54 -0
- package/server/lib/orchestration/cli-dispatch.js +16 -1
- package/server/lib/orchestration/cli-dispatch.test.js +94 -8
- package/server/lib/orchestration/completion-checker.js +101 -0
- package/server/lib/orchestration/completion-checker.test.js +177 -0
- package/server/lib/orchestration/result-verifier.js +143 -0
- package/server/lib/orchestration/result-verifier.test.js +291 -0
- package/server/lib/orchestration/session-dispatcher.js +99 -0
- package/server/lib/orchestration/session-dispatcher.test.js +215 -0
- package/server/lib/orchestration/session-status.js +147 -0
- package/server/lib/orchestration/session-status.test.js +130 -0
- package/server/lib/release/agent-runner-updates.js +24 -0
- package/server/lib/release/agent-runner-updates.test.js +22 -0
- package/server/lib/release/changelog-generator.js +142 -0
- package/server/lib/release/changelog-generator.test.js +113 -0
- package/server/lib/release/ci-watcher.js +83 -0
- package/server/lib/release/ci-watcher.test.js +81 -0
- package/server/lib/release/health-checker.js +111 -0
- package/server/lib/release/health-checker.test.js +121 -0
- package/server/lib/release/release-pipeline.js +187 -0
- package/server/lib/release/release-pipeline.test.js +262 -0
- package/server/lib/release/version-bumper.js +183 -0
- package/server/lib/release/version-bumper.test.js +142 -0
- package/server/lib/routing-preamble.integration.test.js +12 -0
- package/server/lib/routing-preamble.js +13 -2
- package/server/lib/routing-preamble.test.js +49 -0
- package/server/lib/scaffolding/ci-detector.js +139 -0
- package/server/lib/scaffolding/ci-detector.test.js +198 -0
- package/server/lib/scaffolding/ci-scaffolder.js +347 -0
- package/server/lib/scaffolding/ci-scaffolder.test.js +157 -0
- package/server/lib/scaffolding/deploy-detector.js +135 -0
- package/server/lib/scaffolding/deploy-detector.test.js +106 -0
- package/server/lib/scaffolding/health-scaffold.js +374 -0
- package/server/lib/scaffolding/health-scaffold.test.js +99 -0
- package/server/lib/scaffolding/logger-scaffold.js +196 -0
- package/server/lib/scaffolding/logger-scaffold.test.js +146 -0
- package/server/lib/scaffolding/migration-detector.js +78 -0
- package/server/lib/scaffolding/migration-detector.test.js +127 -0
- package/server/lib/scaffolding/snapshot-manager.js +142 -0
- package/server/lib/scaffolding/snapshot-manager.test.js +225 -0
- package/server/lib/task-router-config.js +50 -20
- package/server/lib/task-router-config.test.js +29 -15
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { BUILD_ROUTING_FLAG, shouldBlockAgent } from '../block-tools-hook.js';
|
|
6
|
+
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
|
|
9
|
+
function createProjectRoot() {
|
|
10
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'block-tools-hook-'));
|
|
11
|
+
tempDirs.push(projectRoot);
|
|
12
|
+
return projectRoot;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeFlag(projectRoot, value) {
|
|
16
|
+
const flagPath = path.join(projectRoot, BUILD_ROUTING_FLAG);
|
|
17
|
+
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
|
|
18
|
+
fs.writeFileSync(flagPath, value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const dir of tempDirs.splice(0)) {
|
|
23
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('shouldBlockAgent', () => {
|
|
28
|
+
it('returns provider when file has codex', () => {
|
|
29
|
+
const projectRoot = createProjectRoot();
|
|
30
|
+
writeFlag(projectRoot, 'codex');
|
|
31
|
+
|
|
32
|
+
expect(shouldBlockAgent(projectRoot)).toBe('codex');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns null when claude', () => {
|
|
36
|
+
const projectRoot = createProjectRoot();
|
|
37
|
+
writeFlag(projectRoot, 'claude');
|
|
38
|
+
|
|
39
|
+
expect(shouldBlockAgent(projectRoot)).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns null when missing', () => {
|
|
43
|
+
const projectRoot = createProjectRoot();
|
|
44
|
+
|
|
45
|
+
expect(shouldBlockAgent(projectRoot)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns null when empty', () => {
|
|
49
|
+
const projectRoot = createProjectRoot();
|
|
50
|
+
writeFlag(projectRoot, '');
|
|
51
|
+
|
|
52
|
+
expect(shouldBlockAgent(projectRoot)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -49,10 +49,14 @@ function buildCommand(provider, prompt, worktreePath, flags) {
|
|
|
49
49
|
let args;
|
|
50
50
|
|
|
51
51
|
if (normalizedProvider === 'codex') {
|
|
52
|
-
args = ['exec', '--json', '--full-auto'];
|
|
52
|
+
args = ['exec', '--json', '--full-auto', '--ephemeral'];
|
|
53
53
|
if (worktreePath) {
|
|
54
54
|
args.push('-C', worktreePath);
|
|
55
55
|
}
|
|
56
|
+
// Short prompts go inline; long prompts use stdin
|
|
57
|
+
if (prompt && prompt.length < 1000) {
|
|
58
|
+
usesStdin = false;
|
|
59
|
+
}
|
|
56
60
|
} else if (normalizedProvider === 'gemini') {
|
|
57
61
|
usesStdin = false;
|
|
58
62
|
args = [...baseFlags, '-p', prompt];
|
|
@@ -84,6 +88,10 @@ function buildCommand(provider, prompt, worktreePath, flags) {
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
args.push(...baseFlags);
|
|
91
|
+
// For codex with short prompts, append prompt as inline argument
|
|
92
|
+
if (normalizedProvider === 'codex' && !usesStdin && prompt) {
|
|
93
|
+
args.push(prompt);
|
|
94
|
+
}
|
|
87
95
|
return { command: normalizedProvider, args, spawnOptions, usesStdin };
|
|
88
96
|
}
|
|
89
97
|
|
|
@@ -112,6 +120,13 @@ async function dispatch({
|
|
|
112
120
|
spawn = require('child_process').spawn,
|
|
113
121
|
binaryOverride,
|
|
114
122
|
}) {
|
|
123
|
+
const PROMPT_SIZE_THRESHOLD = 16000;
|
|
124
|
+
if (prompt && prompt.length > PROMPT_SIZE_THRESHOLD) {
|
|
125
|
+
console.warn(
|
|
126
|
+
`[cli-dispatch] Prompt length (${prompt.length} chars) exceeds ${PROMPT_SIZE_THRESHOLD} char threshold (~${Math.round(prompt.length / 4)} tokens). Consider breaking the prompt into smaller tasks.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
115
130
|
const start = Date.now();
|
|
116
131
|
const built = buildCommand(provider, prompt, worktreePath, flags);
|
|
117
132
|
// Allow standalone to preserve custom/absolute binary paths
|
|
@@ -30,7 +30,7 @@ function createMockProcess({ stdout = '', stderr = '', exitCode = 0, delay = 0 }
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
describe('cli-dispatch', () => {
|
|
33
|
-
it('builds codex args with -C,
|
|
33
|
+
it('builds codex args with -C, handles prompt, and extracts threadId', async () => {
|
|
34
34
|
const stdout = [
|
|
35
35
|
'{"type":"thread.started","thread_id":"thread-123"}',
|
|
36
36
|
'{"type":"agent_message","message":{"items":[{"type":"text","text":"done"}]}}',
|
|
@@ -46,12 +46,18 @@ describe('cli-dispatch', () => {
|
|
|
46
46
|
spawn,
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
54
|
-
expect(
|
|
49
|
+
const args = spawn.mock.calls[0][1];
|
|
50
|
+
expect(args).toContain('exec');
|
|
51
|
+
expect(args).toContain('--json');
|
|
52
|
+
expect(args).toContain('--full-auto');
|
|
53
|
+
expect(args).toContain('--ephemeral');
|
|
54
|
+
expect(args).toContain('-C');
|
|
55
|
+
expect(args).toContain('/tmp/worktree');
|
|
56
|
+
expect(args).toContain('--model');
|
|
57
|
+
expect(args).toContain('gpt-5.4');
|
|
58
|
+
// Short prompt (<1000 chars) goes inline
|
|
59
|
+
expect(args).toContain('Review this change');
|
|
60
|
+
expect(proc.stdin.write).not.toHaveBeenCalled();
|
|
55
61
|
expect(proc.stdin.end).toHaveBeenCalled();
|
|
56
62
|
expect(result.threadId).toBe('thread-123');
|
|
57
63
|
expect(result.exitCode).toBe(0);
|
|
@@ -221,7 +227,7 @@ describe('cli-dispatch', () => {
|
|
|
221
227
|
|
|
222
228
|
expect(spawn).toHaveBeenCalledWith(
|
|
223
229
|
'codex',
|
|
224
|
-
['
|
|
230
|
+
expect.arrayContaining(['-C', '/tmp/codex-worktree']),
|
|
225
231
|
{}
|
|
226
232
|
);
|
|
227
233
|
});
|
|
@@ -239,4 +245,84 @@ describe('cli-dispatch', () => {
|
|
|
239
245
|
expect(result.threadId).toBeNull();
|
|
240
246
|
expect(result.exitCode).toBe(0);
|
|
241
247
|
});
|
|
248
|
+
|
|
249
|
+
it('buildCommand for codex includes --ephemeral flag', async () => {
|
|
250
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
251
|
+
const spawn = vi.fn(() => proc);
|
|
252
|
+
|
|
253
|
+
await dispatch({
|
|
254
|
+
provider: 'codex',
|
|
255
|
+
prompt: 'Do something',
|
|
256
|
+
spawn,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const args = spawn.mock.calls[0][1];
|
|
260
|
+
expect(args).toContain('--ephemeral');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('buildCommand for codex includes -o flag when outputFile provided', async () => {
|
|
264
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
265
|
+
const spawn = vi.fn(() => proc);
|
|
266
|
+
|
|
267
|
+
await dispatch({
|
|
268
|
+
provider: 'codex',
|
|
269
|
+
prompt: 'Do something',
|
|
270
|
+
flags: ['-o', '/tmp/output.json'],
|
|
271
|
+
spawn,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const args = spawn.mock.calls[0][1];
|
|
275
|
+
expect(args).toContain('-o');
|
|
276
|
+
expect(args).toContain('/tmp/output.json');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('buildCommand for codex uses inline argument for short prompts', async () => {
|
|
280
|
+
const shortPrompt = 'Fix the bug';
|
|
281
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
282
|
+
const spawn = vi.fn(() => proc);
|
|
283
|
+
|
|
284
|
+
const result = await dispatch({
|
|
285
|
+
provider: 'codex',
|
|
286
|
+
prompt: shortPrompt,
|
|
287
|
+
spawn,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const args = spawn.mock.calls[0][1];
|
|
291
|
+
expect(args).toContain(shortPrompt);
|
|
292
|
+
expect(proc.stdin.write).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('buildCommand for codex uses stdin for long prompts', async () => {
|
|
296
|
+
const longPrompt = 'x'.repeat(1000);
|
|
297
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
298
|
+
const spawn = vi.fn(() => proc);
|
|
299
|
+
|
|
300
|
+
await dispatch({
|
|
301
|
+
provider: 'codex',
|
|
302
|
+
prompt: longPrompt,
|
|
303
|
+
spawn,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const args = spawn.mock.calls[0][1];
|
|
307
|
+
expect(args).not.toContain(longPrompt);
|
|
308
|
+
expect(proc.stdin.write).toHaveBeenCalledWith(longPrompt);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('dispatch warns when prompt exceeds size threshold', async () => {
|
|
312
|
+
const hugePrompt = 'x'.repeat(16001);
|
|
313
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
314
|
+
const spawn = vi.fn(() => proc);
|
|
315
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
316
|
+
|
|
317
|
+
await dispatch({
|
|
318
|
+
provider: 'codex',
|
|
319
|
+
prompt: hugePrompt,
|
|
320
|
+
spawn,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
324
|
+
expect.stringContaining('exceeds')
|
|
325
|
+
);
|
|
326
|
+
warnSpy.mockRestore();
|
|
327
|
+
});
|
|
242
328
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
|
|
3
|
+
async function readActiveSessions(activeSessionsPath) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = await fs.readFile(activeSessionsPath, 'utf8');
|
|
6
|
+
const parsed = JSON.parse(raw);
|
|
7
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
8
|
+
} catch (error) {
|
|
9
|
+
if (error && error.code === 'ENOENT') {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function writeActiveSessions(activeSessionsPath, sessions) {
|
|
17
|
+
await fs.mkdir(require('path').dirname(activeSessionsPath), { recursive: true });
|
|
18
|
+
await fs.writeFile(activeSessionsPath, JSON.stringify(sessions, null, 2));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function checkCompletions({
|
|
22
|
+
activeSessionsPath,
|
|
23
|
+
orchestratorUrl,
|
|
24
|
+
fetch = globalThis.fetch,
|
|
25
|
+
}) {
|
|
26
|
+
const activeSessions = await readActiveSessions(activeSessionsPath);
|
|
27
|
+
if (activeSessions.length === 0) {
|
|
28
|
+
return {
|
|
29
|
+
completions: [],
|
|
30
|
+
failures: [],
|
|
31
|
+
stillRunning: 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const completions = [];
|
|
36
|
+
const failures = [];
|
|
37
|
+
const remainingSessions = [];
|
|
38
|
+
|
|
39
|
+
let orchestratorReachable = true;
|
|
40
|
+
|
|
41
|
+
for (const session of activeSessions) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(
|
|
44
|
+
`${String(orchestratorUrl).replace(/\/+$/, '')}/sessions/${session.sessionId}`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
// Server returned error for this session — keep it as running, don't abort
|
|
49
|
+
remainingSessions.push(session);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const payload = await response.json();
|
|
54
|
+
|
|
55
|
+
if (payload.status === 'completed') {
|
|
56
|
+
completions.push(session);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (payload.status === 'failed') {
|
|
61
|
+
failures.push({
|
|
62
|
+
...session,
|
|
63
|
+
...(payload.reason ? { reason: payload.reason } : {}),
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
remainingSessions.push(session);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!orchestratorReachable) {
|
|
71
|
+
// Already know orchestrator is down — keep remaining sessions as-is
|
|
72
|
+
remainingSessions.push(session);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// First network error — check if orchestrator is entirely unreachable
|
|
76
|
+
orchestratorReachable = false;
|
|
77
|
+
remainingSessions.push(session);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!orchestratorReachable && completions.length === 0 && failures.length === 0) {
|
|
82
|
+
return {
|
|
83
|
+
completions: [],
|
|
84
|
+
failures: [],
|
|
85
|
+
stillRunning: -1,
|
|
86
|
+
error: 'orchestrator unreachable',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await writeActiveSessions(activeSessionsPath, remainingSessions);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
completions,
|
|
94
|
+
failures,
|
|
95
|
+
stillRunning: remainingSessions.length,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
checkCompletions,
|
|
101
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const { checkCompletions } = require('./completion-checker.js');
|
|
7
|
+
|
|
8
|
+
function makeSession(overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
sessionId: 'session-1',
|
|
11
|
+
taskName: 'Task 1',
|
|
12
|
+
startedAt: '2026-03-30T08:00:00.000Z',
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function writeSessionsFile(activeSessionsPath, sessions) {
|
|
18
|
+
fs.mkdirSync(path.dirname(activeSessionsPath), { recursive: true });
|
|
19
|
+
fs.writeFileSync(activeSessionsPath, JSON.stringify(sessions, null, 2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('completion-checker', () => {
|
|
23
|
+
const tempDirs = [];
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
while (tempDirs.length > 0) {
|
|
28
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function makeActiveSessionsPath() {
|
|
33
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'completion-checker-test-'));
|
|
34
|
+
tempDirs.push(tempDir);
|
|
35
|
+
return path.join(tempDir, '.tlc', '.active-sessions.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('returns empty when there are no active sessions', async () => {
|
|
39
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
40
|
+
writeSessionsFile(activeSessionsPath, []);
|
|
41
|
+
|
|
42
|
+
const fetch = vi.fn();
|
|
43
|
+
const result = await checkCompletions({
|
|
44
|
+
activeSessionsPath,
|
|
45
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
46
|
+
fetch,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
completions: [],
|
|
51
|
+
failures: [],
|
|
52
|
+
stillRunning: 0,
|
|
53
|
+
});
|
|
54
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
55
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reports a completed session and removes it from the file', async () => {
|
|
59
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
60
|
+
writeSessionsFile(activeSessionsPath, [makeSession()]);
|
|
61
|
+
|
|
62
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
63
|
+
ok: true,
|
|
64
|
+
json: async () => ({ status: 'completed' }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await checkCompletions({
|
|
68
|
+
activeSessionsPath,
|
|
69
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
70
|
+
fetch,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(fetch).toHaveBeenCalledWith('http://orchestrator.test/sessions/session-1');
|
|
74
|
+
expect(result).toEqual({
|
|
75
|
+
completions: [makeSession()],
|
|
76
|
+
failures: [],
|
|
77
|
+
stillRunning: 0,
|
|
78
|
+
});
|
|
79
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reports a failed session with reason', async () => {
|
|
83
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
84
|
+
writeSessionsFile(activeSessionsPath, [makeSession()]);
|
|
85
|
+
|
|
86
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
87
|
+
ok: true,
|
|
88
|
+
json: async () => ({ status: 'failed', reason: 'Tests failed' }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = await checkCompletions({
|
|
92
|
+
activeSessionsPath,
|
|
93
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
94
|
+
fetch,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
completions: [],
|
|
99
|
+
failures: [{ ...makeSession(), reason: 'Tests failed' }],
|
|
100
|
+
stillRunning: 0,
|
|
101
|
+
});
|
|
102
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles multiple sessions with mixed running and completed states', async () => {
|
|
106
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
107
|
+
const running = makeSession({ sessionId: 'session-1', taskName: 'Task 1' });
|
|
108
|
+
const completed = makeSession({ sessionId: 'session-2', taskName: 'Task 2' });
|
|
109
|
+
const failed = makeSession({ sessionId: 'session-3', taskName: 'Task 3' });
|
|
110
|
+
writeSessionsFile(activeSessionsPath, [running, completed, failed]);
|
|
111
|
+
|
|
112
|
+
const fetch = vi.fn().mockImplementation(async (url) => {
|
|
113
|
+
if (url.endsWith('/session-1')) {
|
|
114
|
+
return { ok: true, json: async () => ({ status: 'running' }) };
|
|
115
|
+
}
|
|
116
|
+
if (url.endsWith('/session-2')) {
|
|
117
|
+
return { ok: true, json: async () => ({ status: 'completed' }) };
|
|
118
|
+
}
|
|
119
|
+
if (url.endsWith('/session-3')) {
|
|
120
|
+
return { ok: true, json: async () => ({ status: 'failed', reason: 'Rejected' }) };
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await checkCompletions({
|
|
126
|
+
activeSessionsPath,
|
|
127
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
128
|
+
fetch,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result).toEqual({
|
|
132
|
+
completions: [completed],
|
|
133
|
+
failures: [{ ...failed, reason: 'Rejected' }],
|
|
134
|
+
stillRunning: 1,
|
|
135
|
+
});
|
|
136
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([running]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns an error result when the orchestrator is unreachable', async () => {
|
|
140
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
141
|
+
const session = makeSession();
|
|
142
|
+
writeSessionsFile(activeSessionsPath, [session]);
|
|
143
|
+
|
|
144
|
+
const fetch = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'));
|
|
145
|
+
|
|
146
|
+
const result = await checkCompletions({
|
|
147
|
+
activeSessionsPath,
|
|
148
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
149
|
+
fetch,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(result).toEqual({
|
|
153
|
+
completions: [],
|
|
154
|
+
failures: [],
|
|
155
|
+
stillRunning: -1,
|
|
156
|
+
error: 'orchestrator unreachable',
|
|
157
|
+
});
|
|
158
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([session]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('treats a missing file as no active sessions', async () => {
|
|
162
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
163
|
+
|
|
164
|
+
const result = await checkCompletions({
|
|
165
|
+
activeSessionsPath,
|
|
166
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
167
|
+
fetch: vi.fn(),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result).toEqual({
|
|
171
|
+
completions: [],
|
|
172
|
+
failures: [],
|
|
173
|
+
stillRunning: 0,
|
|
174
|
+
});
|
|
175
|
+
expect(fs.existsSync(activeSessionsPath)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result Verifier
|
|
3
|
+
* Verifies that a dispatched agent actually produced meaningful work.
|
|
4
|
+
* Codex can exit 0 but produce nothing — this module catches that.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { execSync: defaultExecSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const TEST_FILE_PATTERN = /\.(test|spec)\./;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Take a snapshot of the current git state for later comparison.
|
|
13
|
+
* @param {{ worktreePath: string, exec?: Function }} options
|
|
14
|
+
* @returns {{ gitHash: string, trackedFiles: string[], untrackedFiles: string[] }}
|
|
15
|
+
*/
|
|
16
|
+
function takeSnapshot({ worktreePath, exec = defaultExecSync }) {
|
|
17
|
+
const run = (cmd) => String(exec(cmd, { cwd: worktreePath })).trim();
|
|
18
|
+
|
|
19
|
+
const gitHash = run('git rev-parse HEAD');
|
|
20
|
+
const trackedRaw = run('git ls-files');
|
|
21
|
+
const untrackedRaw = run('git ls-files --others --exclude-standard');
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
gitHash,
|
|
25
|
+
trackedFiles: trackedRaw ? trackedRaw.split('\n').filter(Boolean) : [],
|
|
26
|
+
untrackedFiles: untrackedRaw ? untrackedRaw.split('\n').filter(Boolean) : [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Verify that an agent dispatch actually produced meaningful results.
|
|
32
|
+
* @param {{
|
|
33
|
+
* worktreePath: string,
|
|
34
|
+
* expectedFiles: string[],
|
|
35
|
+
* preSnapshot: { gitHash: string, trackedFiles: string[], untrackedFiles: string[] },
|
|
36
|
+
* exec?: Function,
|
|
37
|
+
* runTests?: boolean,
|
|
38
|
+
* testCommand?: string
|
|
39
|
+
* }} options
|
|
40
|
+
* @returns {{
|
|
41
|
+
* success: boolean,
|
|
42
|
+
* filesChanged: string[],
|
|
43
|
+
* testsAdded: string[],
|
|
44
|
+
* testsPassing: boolean | null,
|
|
45
|
+
* summary: string,
|
|
46
|
+
* failureReason: string | null
|
|
47
|
+
* }}
|
|
48
|
+
*/
|
|
49
|
+
function verifyResult({
|
|
50
|
+
worktreePath,
|
|
51
|
+
expectedFiles = [],
|
|
52
|
+
preSnapshot,
|
|
53
|
+
exec = defaultExecSync,
|
|
54
|
+
runTests = false,
|
|
55
|
+
testCommand = 'npm test',
|
|
56
|
+
}) {
|
|
57
|
+
const run = (cmd) => String(exec(cmd, { cwd: worktreePath })).trim();
|
|
58
|
+
|
|
59
|
+
// 1. Detect file changes via git diff
|
|
60
|
+
let filesChanged = [];
|
|
61
|
+
try {
|
|
62
|
+
const diffOutput = run(`git diff --name-only ${preSnapshot.gitHash} HEAD`);
|
|
63
|
+
filesChanged = diffOutput ? diffOutput.split('\n').filter(Boolean) : [];
|
|
64
|
+
|
|
65
|
+
// Also pick up untracked files that appeared after the snapshot
|
|
66
|
+
const untrackedRaw = run('git ls-files --others --exclude-standard');
|
|
67
|
+
const untrackedNow = untrackedRaw ? untrackedRaw.split('\n').filter(Boolean) : [];
|
|
68
|
+
const newUntracked = untrackedNow.filter(
|
|
69
|
+
(f) => !preSnapshot.untrackedFiles.includes(f)
|
|
70
|
+
);
|
|
71
|
+
filesChanged = [...new Set([...filesChanged, ...newUntracked])];
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// If git diff itself fails (e.g., timeout), report timeout
|
|
74
|
+
return {
|
|
75
|
+
success: false,
|
|
76
|
+
filesChanged: [],
|
|
77
|
+
testsAdded: [],
|
|
78
|
+
testsPassing: null,
|
|
79
|
+
summary: `Verification failed: ${err.message}`,
|
|
80
|
+
failureReason: err.code === 'ETIMEDOUT' ? 'timeout' : 'timeout',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. No changes at all — Codex exited 0 but did nothing
|
|
85
|
+
if (filesChanged.length === 0) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
filesChanged: [],
|
|
89
|
+
testsAdded: [],
|
|
90
|
+
testsPassing: null,
|
|
91
|
+
summary: 'No files were changed. The agent exited successfully but produced no output.',
|
|
92
|
+
failureReason: 'no_changes',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 3. Identify test files
|
|
97
|
+
const testsAdded = filesChanged.filter((f) => TEST_FILE_PATTERN.test(f));
|
|
98
|
+
|
|
99
|
+
// 4. If no tests were added/modified, it's a partial result
|
|
100
|
+
if (testsAdded.length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
success: false,
|
|
103
|
+
filesChanged,
|
|
104
|
+
testsAdded: [],
|
|
105
|
+
testsPassing: null,
|
|
106
|
+
summary: `${filesChanged.length} file(s) changed but no test files were added or modified.`,
|
|
107
|
+
failureReason: 'no_tests',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 5. Optionally run tests
|
|
112
|
+
let testsPassing = null;
|
|
113
|
+
if (runTests) {
|
|
114
|
+
try {
|
|
115
|
+
run(testCommand);
|
|
116
|
+
testsPassing = true;
|
|
117
|
+
} catch (_) {
|
|
118
|
+
return {
|
|
119
|
+
success: false,
|
|
120
|
+
filesChanged,
|
|
121
|
+
testsAdded,
|
|
122
|
+
testsPassing: false,
|
|
123
|
+
summary: `${filesChanged.length} file(s) changed with ${testsAdded.length} test file(s), but tests are failing.`,
|
|
124
|
+
failureReason: 'tests_failing',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 6. Success
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
filesChanged,
|
|
133
|
+
testsAdded,
|
|
134
|
+
testsPassing,
|
|
135
|
+
summary: `${filesChanged.length} file(s) changed, ${testsAdded.length} test file(s) added/modified.${testsPassing === true ? ' All tests passing.' : ''}`,
|
|
136
|
+
failureReason: null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
takeSnapshot,
|
|
142
|
+
verifyResult,
|
|
143
|
+
};
|