@tarunspandit/codexflow 0.29.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/AGENTS.example.md +18 -0
- package/CHANGELOG.md +311 -0
- package/CHATGPT_PROMPT.md +12 -0
- package/CODEX_PROMPT.md +12 -0
- package/CONTRIBUTING.md +51 -0
- package/DOMAIN_SETUP.md +241 -0
- package/FAQ.md +327 -0
- package/FAQ_ZH.md +279 -0
- package/LICENSE +21 -0
- package/NOTICE +9 -0
- package/PUBLIC_LAUNCH_CHECKLIST.md +111 -0
- package/README.md +287 -0
- package/README_ZH.md +461 -0
- package/SECURITY.md +145 -0
- package/config.example.env +31 -0
- package/dist/analysis/cache.js +27 -0
- package/dist/analysis/cache.js.map +1 -0
- package/dist/analysis/classify.js +102 -0
- package/dist/analysis/classify.js.map +1 -0
- package/dist/analysis/extract.js +139 -0
- package/dist/analysis/extract.js.map +1 -0
- package/dist/analysis/graph.js +22 -0
- package/dist/analysis/graph.js.map +1 -0
- package/dist/analysis/impact.js +167 -0
- package/dist/analysis/impact.js.map +1 -0
- package/dist/analysis/index.js +215 -0
- package/dist/analysis/index.js.map +1 -0
- package/dist/analysis/inventory.js +51 -0
- package/dist/analysis/inventory.js.map +1 -0
- package/dist/analysis/providers.js +24 -0
- package/dist/analysis/providers.js.map +1 -0
- package/dist/analysis/rank.js +27 -0
- package/dist/analysis/rank.js.map +1 -0
- package/dist/analysis/types.js +8 -0
- package/dist/analysis/types.js.map +1 -0
- package/dist/bashOps.js +233 -0
- package/dist/bashOps.js.map +1 -0
- package/dist/capabilitiesOps.js +365 -0
- package/dist/capabilitiesOps.js.map +1 -0
- package/dist/codexSessions.js +379 -0
- package/dist/codexSessions.js.map +1 -0
- package/dist/config.js +289 -0
- package/dist/config.js.map +1 -0
- package/dist/fsOps.js +286 -0
- package/dist/fsOps.js.map +1 -0
- package/dist/gitOps.js +79 -0
- package/dist/gitOps.js.map +1 -0
- package/dist/guard.js +198 -0
- package/dist/guard.js.map +1 -0
- package/dist/http.js +1671 -0
- package/dist/http.js.map +1 -0
- package/dist/proContext.js +274 -0
- package/dist/proContext.js.map +1 -0
- package/dist/profileStore.js +89 -0
- package/dist/profileStore.js.map +1 -0
- package/dist/projectCatalog.js +134 -0
- package/dist/projectCatalog.js.map +1 -0
- package/dist/redact.js +73 -0
- package/dist/redact.js.map +1 -0
- package/dist/searchOps.js +186 -0
- package/dist/searchOps.js.map +1 -0
- package/dist/server.js +2502 -0
- package/dist/server.js.map +1 -0
- package/dist/stdio.js +36 -0
- package/dist/stdio.js.map +1 -0
- package/dist/toolCardWidget.js +1155 -0
- package/dist/toolCardWidget.js.map +1 -0
- package/dist/workspaceOps.js +229 -0
- package/dist/workspaceOps.js.map +1 -0
- package/docs/.nojekyll +1 -0
- package/docs/favicon.svg +5 -0
- package/docs/index.html +638 -0
- package/docs/og.png +0 -0
- package/docs/script.js +80 -0
- package/docs/star.svg +11 -0
- package/docs/styles.css +1229 -0
- package/docs/zh.html +436 -0
- package/package.json +94 -0
- package/scripts/analysis-cli-smoke.mjs +81 -0
- package/scripts/analysis-smoke.mjs +179 -0
- package/scripts/clean.mjs +6 -0
- package/scripts/cli-smoke.mjs +168 -0
- package/scripts/codexflow.mjs +4375 -0
- package/scripts/doctor-smoke.mjs +90 -0
- package/scripts/execute-handoff-smoke.mjs +1110 -0
- package/scripts/http-smoke.mjs +812 -0
- package/scripts/pro-apply.mjs +141 -0
- package/scripts/pro-bundle.mjs +121 -0
- package/scripts/pro-smoke.mjs +95 -0
- package/scripts/settings-smoke.mjs +756 -0
- package/scripts/smoke.mjs +1194 -0
- package/scripts/stress.mjs +835 -0
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
function run(args, options = {}) {
|
|
7
|
+
const result = spawnSync(process.execPath, ['scripts/codexflow.mjs', ...args], {
|
|
8
|
+
cwd: path.resolve('.'),
|
|
9
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
...options
|
|
12
|
+
});
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requireSuccess(result, label) {
|
|
17
|
+
if (result.status !== 0) {
|
|
18
|
+
throw new Error(`${label} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function quoteArg(value) {
|
|
23
|
+
return `"${String(value).replaceAll('"', '\\"')}"`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-execute-handoff-'));
|
|
27
|
+
await fs.mkdir(path.join(root, '.ai-bridge'), { recursive: true });
|
|
28
|
+
await fs.writeFile(path.join(root, '.ai-bridge', 'current-plan.md'), '# Test plan\n\nAppend the implementation marker.\n', 'utf8');
|
|
29
|
+
await fs.writeFile(path.join(root, 'app.txt'), 'start\n', 'utf8');
|
|
30
|
+
await fs.writeFile(path.join(root, 'fake-agent.mjs'), `
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
|
|
33
|
+
const taskIndex = process.argv.indexOf('--task-file');
|
|
34
|
+
const modelIndex = process.argv.indexOf('--model');
|
|
35
|
+
if (taskIndex < 0) throw new Error('missing --task-file');
|
|
36
|
+
const plan = fs.readFileSync(process.argv[taskIndex + 1], 'utf8');
|
|
37
|
+
const model = modelIndex >= 0 ? process.argv[modelIndex + 1] : '';
|
|
38
|
+
fs.appendFileSync('app.txt', \`implemented with \${model}: \${plan.includes('implementation marker') ? 'yes' : 'no'}\\n\`);
|
|
39
|
+
console.log('fake agent completed');
|
|
40
|
+
`, 'utf8');
|
|
41
|
+
|
|
42
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: root, encoding: 'utf8' }), 'git init');
|
|
43
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: root, encoding: 'utf8' }), 'git add');
|
|
44
|
+
|
|
45
|
+
const dryRun = run([
|
|
46
|
+
'execute-handoff',
|
|
47
|
+
'--root',
|
|
48
|
+
root,
|
|
49
|
+
'--agent',
|
|
50
|
+
'opencode',
|
|
51
|
+
'--model',
|
|
52
|
+
'provider/model',
|
|
53
|
+
'--dry-run'
|
|
54
|
+
]);
|
|
55
|
+
requireSuccess(dryRun, 'execute-handoff dry-run');
|
|
56
|
+
if (!dryRun.stdout.includes('opencode run') || !dryRun.stdout.includes('provider/model')) {
|
|
57
|
+
throw new Error(`dry-run output did not show adapter command\n${dryRun.stdout}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const missingPlaceholder = run([
|
|
61
|
+
'execute-handoff',
|
|
62
|
+
'--root',
|
|
63
|
+
root,
|
|
64
|
+
'--agent',
|
|
65
|
+
'custom',
|
|
66
|
+
'--command',
|
|
67
|
+
`${quoteArg(process.execPath)} fake-agent.mjs`,
|
|
68
|
+
'--yes'
|
|
69
|
+
]);
|
|
70
|
+
if (missingPlaceholder.status === 0 || !missingPlaceholder.stderr.includes('must include {{plan_file}} or {{plan_text}}')) {
|
|
71
|
+
throw new Error(`custom command without plan placeholder should fail\nstdout:\n${missingPlaceholder.stdout}\nstderr:\n${missingPlaceholder.stderr}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await fs.writeFile(path.join(root, 'empty-arg-agent.mjs'), `
|
|
75
|
+
const emptyIndex = process.argv.indexOf('--empty');
|
|
76
|
+
if (emptyIndex < 0 || process.argv[emptyIndex + 1] !== '') {
|
|
77
|
+
console.error(\`EMPTY_ARG=\${JSON.stringify(process.argv[emptyIndex + 1])}\`);
|
|
78
|
+
process.exit(7);
|
|
79
|
+
}
|
|
80
|
+
`, 'utf8');
|
|
81
|
+
const emptyArg = run([
|
|
82
|
+
'execute-handoff',
|
|
83
|
+
'--root',
|
|
84
|
+
root,
|
|
85
|
+
'--agent',
|
|
86
|
+
'custom',
|
|
87
|
+
'--command',
|
|
88
|
+
`${quoteArg(process.execPath)} empty-arg-agent.mjs --empty "" --task-file {{plan_file}}`,
|
|
89
|
+
'--yes'
|
|
90
|
+
]);
|
|
91
|
+
requireSuccess(emptyArg, 'execute-handoff empty quoted arg');
|
|
92
|
+
|
|
93
|
+
const executed = run([
|
|
94
|
+
'execute-handoff',
|
|
95
|
+
'--root',
|
|
96
|
+
root,
|
|
97
|
+
'--agent',
|
|
98
|
+
'custom',
|
|
99
|
+
'--command',
|
|
100
|
+
`${quoteArg(process.execPath)} fake-agent.mjs --model {{model}} --task-file {{plan_file}}`,
|
|
101
|
+
'--model',
|
|
102
|
+
'local/test-model',
|
|
103
|
+
'--yes'
|
|
104
|
+
]);
|
|
105
|
+
requireSuccess(executed, 'execute-handoff custom');
|
|
106
|
+
|
|
107
|
+
const status = await fs.readFile(path.join(root, '.ai-bridge', 'agent-status.md'), 'utf8');
|
|
108
|
+
const diff = await fs.readFile(path.join(root, '.ai-bridge', 'implementation-diff.patch'), 'utf8');
|
|
109
|
+
const log = await fs.readFile(path.join(root, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
110
|
+
const app = await fs.readFile(path.join(root, 'app.txt'), 'utf8');
|
|
111
|
+
|
|
112
|
+
for (const expected of ['Agent Execution Status', 'Agent: custom', 'Exit code: 0', 'Git status excerpt', 'app.txt', 'fake agent completed']) {
|
|
113
|
+
if (!status.includes(expected)) throw new Error(`status missing ${expected}\n${status}`);
|
|
114
|
+
}
|
|
115
|
+
if (!diff.includes('implemented with local/test-model')) {
|
|
116
|
+
throw new Error(`diff did not include implementation marker\n${diff}`);
|
|
117
|
+
}
|
|
118
|
+
if (!log.includes('"event":"execute_handoff"') || !log.includes('"agent":"custom"')) {
|
|
119
|
+
throw new Error(`execution log missing structured event\n${log}`);
|
|
120
|
+
}
|
|
121
|
+
if (!app.includes('implemented with local/test-model: yes')) {
|
|
122
|
+
throw new Error(`fake agent did not edit app.txt\n${app}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const runStateRaw = await fs.readFile(path.join(root, '.ai-bridge', 'handoff-run-state.json'), 'utf8');
|
|
126
|
+
const runState = JSON.parse(runStateRaw);
|
|
127
|
+
if (runState.state !== 'completed') {
|
|
128
|
+
throw new Error(`handoff-run-state did not record completion\n${runStateRaw}`);
|
|
129
|
+
}
|
|
130
|
+
if (runState.exit_code !== 0 || runState.timed_out !== false || runState.executor !== 'custom') {
|
|
131
|
+
throw new Error(`handoff-run-state missing expected run fields\n${runStateRaw}`);
|
|
132
|
+
}
|
|
133
|
+
if (!runState.plan_hash || !runState.started_at || !runState.finished_at || runState.status_file !== '.ai-bridge/agent-status.md') {
|
|
134
|
+
throw new Error(`handoff-run-state missing lifecycle fields\n${runStateRaw}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const fakeCodexBin = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-fake-codex-bin-'));
|
|
138
|
+
const fakeCodexLog = path.join(root, 'fake-codex-args.json');
|
|
139
|
+
const fakeCodexScript = path.join(root, 'fake-codex.mjs');
|
|
140
|
+
await fs.writeFile(fakeCodexScript, `
|
|
141
|
+
import fs from 'node:fs';
|
|
142
|
+
|
|
143
|
+
const args = process.argv.slice(2);
|
|
144
|
+
fs.writeFileSync(process.env.CODEXFLOW_FAKE_CODEX_LOG, JSON.stringify(args, null, 2));
|
|
145
|
+
if (args[0] !== 'exec') throw new Error('expected codex exec');
|
|
146
|
+
if (!args.includes('--ephemeral')) throw new Error('missing --ephemeral');
|
|
147
|
+
if (!args.includes('workspace-write')) throw new Error('missing workspace-write sandbox');
|
|
148
|
+
if (!args.includes('approval_policy="never"')) throw new Error('missing approval_policy never');
|
|
149
|
+
const outputIndex = args.indexOf('--output-last-message');
|
|
150
|
+
if (outputIndex < 0) throw new Error('missing --output-last-message');
|
|
151
|
+
if (!args.at(-1)?.includes('current-plan.md')) throw new Error('missing plan file prompt');
|
|
152
|
+
fs.writeFileSync(args[outputIndex + 1], 'fake codex last message\\n');
|
|
153
|
+
fs.appendFileSync('app.txt', 'codex adapter executed\\n');
|
|
154
|
+
console.log('fake codex completed');
|
|
155
|
+
`, 'utf8');
|
|
156
|
+
|
|
157
|
+
if (process.platform === 'win32') {
|
|
158
|
+
await fs.writeFile(
|
|
159
|
+
path.join(fakeCodexBin, 'codex.cmd'),
|
|
160
|
+
`@echo off\r\n"${process.execPath}" "${fakeCodexScript}" %*\r\n`,
|
|
161
|
+
'utf8'
|
|
162
|
+
);
|
|
163
|
+
} else {
|
|
164
|
+
const fakeCodexPath = path.join(fakeCodexBin, 'codex');
|
|
165
|
+
await fs.writeFile(fakeCodexPath, `#!/usr/bin/env sh\nexec "${process.execPath}" "${fakeCodexScript}" "$@"\n`, 'utf8');
|
|
166
|
+
await fs.chmod(fakeCodexPath, 0o755);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const fakeCodexEnv = {
|
|
170
|
+
...process.env,
|
|
171
|
+
NO_COLOR: '1',
|
|
172
|
+
CODEXFLOW_FAKE_CODEX_LOG: fakeCodexLog,
|
|
173
|
+
PATH: `${fakeCodexBin}${path.delimiter}${process.env.PATH ?? process.env.Path ?? ''}`,
|
|
174
|
+
Path: `${fakeCodexBin}${path.delimiter}${process.env.Path ?? process.env.PATH ?? ''}`
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const codexDryRun = run([
|
|
178
|
+
'execute-handoff',
|
|
179
|
+
'--root',
|
|
180
|
+
root,
|
|
181
|
+
'--agent',
|
|
182
|
+
'codex',
|
|
183
|
+
'--model',
|
|
184
|
+
'gpt-test',
|
|
185
|
+
'--dry-run'
|
|
186
|
+
], { env: fakeCodexEnv });
|
|
187
|
+
requireSuccess(codexDryRun, 'execute-handoff codex dry-run');
|
|
188
|
+
for (const expected of ['codex', 'exec', '--ephemeral', 'workspace-write', 'approval_policy="never"', 'codex-last-message.md', 'current-plan.md']) {
|
|
189
|
+
if (!codexDryRun.stdout.includes(expected)) {
|
|
190
|
+
throw new Error(`codex dry-run missing ${expected}\n${codexDryRun.stdout}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const codexExecuted = run([
|
|
195
|
+
'execute-handoff',
|
|
196
|
+
'--root',
|
|
197
|
+
root,
|
|
198
|
+
'--agent',
|
|
199
|
+
'codex',
|
|
200
|
+
'--model',
|
|
201
|
+
'gpt-test',
|
|
202
|
+
'--yes'
|
|
203
|
+
], { env: fakeCodexEnv });
|
|
204
|
+
requireSuccess(codexExecuted, 'execute-handoff codex');
|
|
205
|
+
|
|
206
|
+
const fakeCodexArgs = JSON.parse(await fs.readFile(fakeCodexLog, 'utf8'));
|
|
207
|
+
const codexLastMessage = await fs.readFile(path.join(root, '.ai-bridge', 'codex-last-message.md'), 'utf8');
|
|
208
|
+
const codexApp = await fs.readFile(path.join(root, 'app.txt'), 'utf8');
|
|
209
|
+
if (!fakeCodexArgs.includes('gpt-test')) {
|
|
210
|
+
throw new Error(`codex adapter did not pass model\n${JSON.stringify(fakeCodexArgs)}`);
|
|
211
|
+
}
|
|
212
|
+
if (!codexLastMessage.includes('fake codex last message')) {
|
|
213
|
+
throw new Error(`codex adapter did not write last message\n${codexLastMessage}`);
|
|
214
|
+
}
|
|
215
|
+
if (!codexApp.includes('codex adapter executed')) {
|
|
216
|
+
throw new Error(`codex adapter did not execute fake codex\n${codexApp}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const executeStagedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-execute-staged-untracked-'));
|
|
220
|
+
await fs.mkdir(path.join(executeStagedRoot, '.ai-bridge'), { recursive: true });
|
|
221
|
+
await fs.writeFile(path.join(executeStagedRoot, '.ai-bridge', 'current-plan.md'), '# Staged plan\n\nStage one edit and create one file.\n', 'utf8');
|
|
222
|
+
await fs.writeFile(path.join(executeStagedRoot, 'app.txt'), 'base\n', 'utf8');
|
|
223
|
+
await fs.writeFile(path.join(executeStagedRoot, 'stage-agent.mjs'), `
|
|
224
|
+
import { spawnSync } from 'node:child_process';
|
|
225
|
+
import fs from 'node:fs';
|
|
226
|
+
fs.appendFileSync('app.txt', 'staged change\\n');
|
|
227
|
+
spawnSync('git', ['add', 'app.txt'], { stdio: 'inherit' });
|
|
228
|
+
fs.writeFileSync('new-feature.txt', 'new feature\\n');
|
|
229
|
+
`, 'utf8');
|
|
230
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: executeStagedRoot, encoding: 'utf8' }), 'execute staged git init');
|
|
231
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: executeStagedRoot, encoding: 'utf8' }), 'execute staged git add');
|
|
232
|
+
requireSuccess(run([
|
|
233
|
+
'execute-handoff',
|
|
234
|
+
'--root',
|
|
235
|
+
executeStagedRoot,
|
|
236
|
+
'--agent',
|
|
237
|
+
'custom',
|
|
238
|
+
'--command',
|
|
239
|
+
`${quoteArg(process.execPath)} stage-agent.mjs --task-file {{plan_file}}`,
|
|
240
|
+
'--yes'
|
|
241
|
+
]), 'execute-handoff staged/untracked diff');
|
|
242
|
+
const executeStagedDiff = await fs.readFile(path.join(executeStagedRoot, '.ai-bridge', 'implementation-diff.patch'), 'utf8');
|
|
243
|
+
if (!executeStagedDiff.includes('# Staged diff') || !executeStagedDiff.includes('staged change') || !executeStagedDiff.includes('# Untracked files') || !executeStagedDiff.includes('new-feature.txt')) {
|
|
244
|
+
throw new Error(`execute-handoff diff missed staged or untracked changes\n${executeStagedDiff}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const timeoutRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-execute-timeout-'));
|
|
248
|
+
await fs.mkdir(path.join(timeoutRoot, '.ai-bridge'), { recursive: true });
|
|
249
|
+
await fs.writeFile(path.join(timeoutRoot, '.ai-bridge', 'current-plan.md'), '# Timeout plan\n\nSleep too long.\n', 'utf8');
|
|
250
|
+
await fs.writeFile(path.join(timeoutRoot, 'slow-agent.mjs'), `setTimeout(() => {}, 5000);\n`, 'utf8');
|
|
251
|
+
const timeoutRun = run([
|
|
252
|
+
'execute-handoff',
|
|
253
|
+
'--root',
|
|
254
|
+
timeoutRoot,
|
|
255
|
+
'--agent',
|
|
256
|
+
'custom',
|
|
257
|
+
'--command',
|
|
258
|
+
`${quoteArg(process.execPath)} slow-agent.mjs --task-file {{plan_file}}`,
|
|
259
|
+
'--timeout-ms',
|
|
260
|
+
'50',
|
|
261
|
+
'--yes'
|
|
262
|
+
]);
|
|
263
|
+
if (timeoutRun.status === 0) {
|
|
264
|
+
throw new Error(`execute-handoff timeout exited successfully\nstdout:\n${timeoutRun.stdout}\nstderr:\n${timeoutRun.stderr}`);
|
|
265
|
+
}
|
|
266
|
+
const timeoutState = await fs.readFile(path.join(timeoutRoot, '.ai-bridge', 'handoff-run-state.json'), 'utf8');
|
|
267
|
+
if (!timeoutState.includes('"state": "timed_out"') || !timeoutState.includes('"exit_code": null')) {
|
|
268
|
+
throw new Error(`execute-handoff timeout state was wrong\n${timeoutState}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (process.platform !== 'win32') {
|
|
272
|
+
const stubbornRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-execute-stubborn-timeout-'));
|
|
273
|
+
await fs.mkdir(path.join(stubbornRoot, '.ai-bridge'), { recursive: true });
|
|
274
|
+
await fs.writeFile(path.join(stubbornRoot, '.ai-bridge', 'current-plan.md'), '# Stubborn timeout plan\n\nIgnore SIGTERM.\n', 'utf8');
|
|
275
|
+
await fs.writeFile(path.join(stubbornRoot, 'stubborn-agent.mjs'), `
|
|
276
|
+
process.on('SIGTERM', () => console.log('ignored SIGTERM'));
|
|
277
|
+
setTimeout(() => process.exit(42), 6000);
|
|
278
|
+
setInterval(() => {}, 1000);
|
|
279
|
+
`, 'utf8');
|
|
280
|
+
const stubbornStarted = Date.now();
|
|
281
|
+
const stubbornRun = run([
|
|
282
|
+
'execute-handoff',
|
|
283
|
+
'--root',
|
|
284
|
+
stubbornRoot,
|
|
285
|
+
'--agent',
|
|
286
|
+
'custom',
|
|
287
|
+
'--command',
|
|
288
|
+
`${quoteArg(process.execPath)} stubborn-agent.mjs --task-file {{plan_file}}`,
|
|
289
|
+
'--timeout-ms',
|
|
290
|
+
'1000',
|
|
291
|
+
'--yes'
|
|
292
|
+
]);
|
|
293
|
+
const stubbornDuration = Date.now() - stubbornStarted;
|
|
294
|
+
if (stubbornRun.status === 0 || stubbornDuration > 5000) {
|
|
295
|
+
throw new Error(`execute-handoff stubborn timeout did not escalate\nstatus: ${stubbornRun.status}\nduration: ${stubbornDuration}\nstdout:\n${stubbornRun.stdout}\nstderr:\n${stubbornRun.stderr}`);
|
|
296
|
+
}
|
|
297
|
+
const stubbornStatus = await fs.readFile(path.join(stubbornRoot, '.ai-bridge', 'agent-status.md'), 'utf8');
|
|
298
|
+
if (!stubbornStatus.includes('Timed out: yes') || !stubbornStatus.includes('Signal: SIGKILL')) {
|
|
299
|
+
throw new Error(`execute-handoff stubborn timeout status was wrong\n${stubbornStatus}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const noisyRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-execute-noisy-'));
|
|
304
|
+
await fs.mkdir(path.join(noisyRoot, '.ai-bridge'), { recursive: true });
|
|
305
|
+
await fs.writeFile(path.join(noisyRoot, '.ai-bridge', 'current-plan.md'), '# Noisy plan\n\nPrint a lot, then edit.\n', 'utf8');
|
|
306
|
+
await fs.writeFile(path.join(noisyRoot, 'app.txt'), 'base\n', 'utf8');
|
|
307
|
+
await fs.writeFile(path.join(noisyRoot, 'noisy-agent.mjs'), `
|
|
308
|
+
import fs from 'node:fs';
|
|
309
|
+
console.log('x'.repeat(200000));
|
|
310
|
+
fs.appendFileSync('app.txt', 'after noisy output\\n');
|
|
311
|
+
`, 'utf8');
|
|
312
|
+
requireSuccess(run([
|
|
313
|
+
'execute-handoff',
|
|
314
|
+
'--root',
|
|
315
|
+
noisyRoot,
|
|
316
|
+
'--agent',
|
|
317
|
+
'custom',
|
|
318
|
+
'--command',
|
|
319
|
+
`${quoteArg(process.execPath)} noisy-agent.mjs --task-file {{plan_file}}`,
|
|
320
|
+
'--max-output-bytes',
|
|
321
|
+
'2048',
|
|
322
|
+
'--yes'
|
|
323
|
+
]), 'execute-handoff noisy output');
|
|
324
|
+
const noisyApp = await fs.readFile(path.join(noisyRoot, 'app.txt'), 'utf8');
|
|
325
|
+
const noisyStatus = await fs.readFile(path.join(noisyRoot, '.ai-bridge', 'agent-status.md'), 'utf8');
|
|
326
|
+
if (!noisyApp.includes('after noisy output') || !noisyStatus.includes('[output truncated to 4000 bytes]')) {
|
|
327
|
+
throw new Error(`execute-handoff output cap killed or failed to truncate\napp:\n${noisyApp}\nstatus:\n${noisyStatus}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const watchRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-watch-handoff-'));
|
|
331
|
+
await fs.mkdir(path.join(watchRoot, '.ai-bridge'), { recursive: true });
|
|
332
|
+
await fs.writeFile(path.join(watchRoot, '.ai-bridge', 'current-plan.md'), '# Current Plan\n\nNo plan written yet.\n', 'utf8');
|
|
333
|
+
await fs.writeFile(path.join(watchRoot, 'app.txt'), 'start\n', 'utf8');
|
|
334
|
+
await fs.writeFile(path.join(watchRoot, 'watch-agent.mjs'), `
|
|
335
|
+
import fs from 'node:fs';
|
|
336
|
+
|
|
337
|
+
const taskIndex = process.argv.indexOf('--task-file');
|
|
338
|
+
if (taskIndex < 0) throw new Error('missing --task-file');
|
|
339
|
+
const plan = fs.readFileSync(process.argv[taskIndex + 1], 'utf8');
|
|
340
|
+
fs.appendFileSync('app.txt', \`watch implemented: \${plan.split('\\n')[0]}\\n\`);
|
|
341
|
+
console.log('watch agent completed');
|
|
342
|
+
`, 'utf8');
|
|
343
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: watchRoot, encoding: 'utf8' }), 'watch git init');
|
|
344
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: watchRoot, encoding: 'utf8' }), 'watch git add');
|
|
345
|
+
|
|
346
|
+
const watchCommand = [
|
|
347
|
+
'watch-handoff',
|
|
348
|
+
'--root',
|
|
349
|
+
watchRoot,
|
|
350
|
+
'--agent',
|
|
351
|
+
'custom',
|
|
352
|
+
'--command',
|
|
353
|
+
`${process.execPath} watch-agent.mjs --task-file {{plan_file}}`,
|
|
354
|
+
'--once',
|
|
355
|
+
'--yes',
|
|
356
|
+
'--debounce-ms',
|
|
357
|
+
'0'
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
requireSuccess(run(watchCommand), 'watch-handoff scaffold skip');
|
|
361
|
+
let watchApp = await fs.readFile(path.join(watchRoot, 'app.txt'), 'utf8');
|
|
362
|
+
if (watchApp !== 'start\n') {
|
|
363
|
+
throw new Error(`watch executed scaffolded empty plan\n${watchApp}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await fs.writeFile(path.join(watchRoot, '.ai-bridge', 'current-plan.md'), '# Watch plan 1\n\nAppend watch marker.\n', 'utf8');
|
|
367
|
+
requireSuccess(run(watchCommand), 'watch-handoff first run');
|
|
368
|
+
watchApp = await fs.readFile(path.join(watchRoot, 'app.txt'), 'utf8');
|
|
369
|
+
if ((watchApp.match(/watch implemented/g) ?? []).length !== 1) {
|
|
370
|
+
throw new Error(`watch first run did not execute exactly once\n${watchApp}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
requireSuccess(run(watchCommand), 'watch-handoff duplicate skip');
|
|
374
|
+
watchApp = await fs.readFile(path.join(watchRoot, 'app.txt'), 'utf8');
|
|
375
|
+
if ((watchApp.match(/watch implemented/g) ?? []).length !== 1) {
|
|
376
|
+
throw new Error(`watch duplicate plan was executed again\n${watchApp}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
await fs.writeFile(path.join(watchRoot, '.ai-bridge', 'current-plan.md'), '# Watch plan 2\n\nAppend second watch marker.\n', 'utf8');
|
|
380
|
+
requireSuccess(run(watchCommand), 'watch-handoff changed plan');
|
|
381
|
+
watchApp = await fs.readFile(path.join(watchRoot, 'app.txt'), 'utf8');
|
|
382
|
+
if ((watchApp.match(/watch implemented/g) ?? []).length !== 2 || !watchApp.includes('# Watch plan 2')) {
|
|
383
|
+
throw new Error(`watch changed plan did not execute\n${watchApp}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const watchState = await fs.readFile(path.join(watchRoot, '.ai-bridge', 'watch-handoff-state.json'), 'utf8');
|
|
387
|
+
const watchLog = await fs.readFile(path.join(watchRoot, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
388
|
+
if (!watchState.includes('lastPlanHash') || !watchLog.includes('"event":"watch_handoff_started"') || !watchLog.includes('"event":"watch_handoff_finished"')) {
|
|
389
|
+
throw new Error(`watch did not write state/log\nstate:\n${watchState}\nlog:\n${watchLog}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const watchTimeoutRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-watch-timeout-'));
|
|
393
|
+
await fs.mkdir(path.join(watchTimeoutRoot, '.ai-bridge'), { recursive: true });
|
|
394
|
+
await fs.writeFile(path.join(watchTimeoutRoot, '.ai-bridge', 'current-plan.md'), '# Watch timeout\n\nSleep too long.\n', 'utf8');
|
|
395
|
+
await fs.writeFile(path.join(watchTimeoutRoot, 'slow-agent.mjs'), `setTimeout(() => {}, 5000);\n`, 'utf8');
|
|
396
|
+
const watchTimeoutRun = run([
|
|
397
|
+
'watch-handoff',
|
|
398
|
+
'--root',
|
|
399
|
+
watchTimeoutRoot,
|
|
400
|
+
'--agent',
|
|
401
|
+
'custom',
|
|
402
|
+
'--command',
|
|
403
|
+
`${quoteArg(process.execPath)} slow-agent.mjs --task-file {{plan_file}}`,
|
|
404
|
+
'--once',
|
|
405
|
+
'--timeout-ms',
|
|
406
|
+
'50',
|
|
407
|
+
'--yes',
|
|
408
|
+
'--debounce-ms',
|
|
409
|
+
'0'
|
|
410
|
+
]);
|
|
411
|
+
if (watchTimeoutRun.status === 0) {
|
|
412
|
+
throw new Error(`watch-handoff timeout exited successfully\nstdout:\n${watchTimeoutRun.stdout}\nstderr:\n${watchTimeoutRun.stderr}`);
|
|
413
|
+
}
|
|
414
|
+
const watchTimeoutState = await fs.readFile(path.join(watchTimeoutRoot, '.ai-bridge', 'handoff-run-state.json'), 'utf8');
|
|
415
|
+
if (!watchTimeoutState.includes('"state": "timed_out"') || !watchTimeoutState.includes('"exit_code": null')) {
|
|
416
|
+
throw new Error(`watch-handoff timeout state was wrong\n${watchTimeoutState}`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const loopRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-handoff-'));
|
|
420
|
+
await fs.mkdir(path.join(loopRoot, '.ai-bridge'), { recursive: true });
|
|
421
|
+
await fs.writeFile(path.join(loopRoot, '.ai-bridge', 'current-plan.md'), '# Loop plan 1\n\nAppend loop first marker.\n', 'utf8');
|
|
422
|
+
await fs.writeFile(path.join(loopRoot, 'app.txt'), 'start\n', 'utf8');
|
|
423
|
+
await fs.writeFile(path.join(loopRoot, 'loop-agent.mjs'), `
|
|
424
|
+
import fs from 'node:fs';
|
|
425
|
+
|
|
426
|
+
const taskIndex = process.argv.indexOf('--task-file');
|
|
427
|
+
if (taskIndex < 0) throw new Error('missing --task-file');
|
|
428
|
+
const plan = fs.readFileSync(process.argv[taskIndex + 1], 'utf8');
|
|
429
|
+
const marker = plan.includes('Loop fix plan') ? 'fix' : 'first';
|
|
430
|
+
fs.appendFileSync('app.txt', \`loop \${marker}\\n\`);
|
|
431
|
+
console.log(\`loop agent completed \${marker}\`);
|
|
432
|
+
`, 'utf8');
|
|
433
|
+
await fs.writeFile(path.join(loopRoot, 'loop-reviewer.mjs'), `
|
|
434
|
+
import fs from 'node:fs';
|
|
435
|
+
|
|
436
|
+
const planIndex = process.argv.indexOf('--plan-file');
|
|
437
|
+
const statusIndex = process.argv.indexOf('--status');
|
|
438
|
+
const diffIndex = process.argv.indexOf('--diff');
|
|
439
|
+
if (planIndex < 0 || statusIndex < 0 || diffIndex < 0) throw new Error('missing review artifacts');
|
|
440
|
+
const planPath = process.argv[planIndex + 1];
|
|
441
|
+
const plan = fs.readFileSync(planPath, 'utf8');
|
|
442
|
+
fs.readFileSync(process.argv[statusIndex + 1], 'utf8');
|
|
443
|
+
fs.readFileSync(process.argv[diffIndex + 1], 'utf8');
|
|
444
|
+
if (!plan.includes('Loop fix plan')) {
|
|
445
|
+
fs.writeFileSync(planPath, '# Loop fix plan\\n\\nAppend loop fix marker.\\n');
|
|
446
|
+
console.log('CODEXFLOW_REVIEW=FAIL');
|
|
447
|
+
} else {
|
|
448
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
449
|
+
}
|
|
450
|
+
`, 'utf8');
|
|
451
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: loopRoot, encoding: 'utf8' }), 'loop git init');
|
|
452
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: loopRoot, encoding: 'utf8' }), 'loop git add');
|
|
453
|
+
|
|
454
|
+
const loopRun = run([
|
|
455
|
+
'loop-handoff',
|
|
456
|
+
'--root',
|
|
457
|
+
loopRoot,
|
|
458
|
+
'--agent',
|
|
459
|
+
'custom',
|
|
460
|
+
'--command',
|
|
461
|
+
`${quoteArg(process.execPath)} loop-agent.mjs --task-file {{plan_file}}`,
|
|
462
|
+
'--review-command',
|
|
463
|
+
`${quoteArg(process.execPath)} loop-reviewer.mjs --status {{status_file}} --diff {{diff_file}} --log {{log_file}} --plan-file {{plan_file}}`,
|
|
464
|
+
'--max-iters',
|
|
465
|
+
'3',
|
|
466
|
+
'--stop-if-no-files-changed',
|
|
467
|
+
'--stop-if-same-diff',
|
|
468
|
+
'--yes'
|
|
469
|
+
]);
|
|
470
|
+
requireSuccess(loopRun, 'loop-handoff custom');
|
|
471
|
+
|
|
472
|
+
const loopApp = await fs.readFile(path.join(loopRoot, 'app.txt'), 'utf8');
|
|
473
|
+
const loopReview = await fs.readFile(path.join(loopRoot, '.ai-bridge', 'loop-review.md'), 'utf8');
|
|
474
|
+
const loopState = await fs.readFile(path.join(loopRoot, '.ai-bridge', 'loop-handoff-state.json'), 'utf8');
|
|
475
|
+
const loopLog = await fs.readFile(path.join(loopRoot, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
476
|
+
|
|
477
|
+
if (!loopApp.includes('loop first') || !loopApp.includes('loop fix')) {
|
|
478
|
+
throw new Error(`loop did not execute first and fix iterations\n${loopApp}`);
|
|
479
|
+
}
|
|
480
|
+
if (!loopReview.includes('Verdict: PASS')) {
|
|
481
|
+
throw new Error(`loop review did not record PASS\n${loopReview}`);
|
|
482
|
+
}
|
|
483
|
+
if (!loopState.includes('"iteration": 2') || !loopLog.includes('"event":"loop_handoff_iteration_started"') || !loopLog.includes('"event":"loop_handoff_finished"')) {
|
|
484
|
+
throw new Error(`loop did not write state/log\nstate:\n${loopState}\nlog:\n${loopLog}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const failedExecutorRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-failed-executor-'));
|
|
488
|
+
await fs.mkdir(path.join(failedExecutorRoot, '.ai-bridge'), { recursive: true });
|
|
489
|
+
await fs.writeFile(path.join(failedExecutorRoot, '.ai-bridge', 'current-plan.md'), '# Failed executor plan\n\nAppend marker and fail.\n', 'utf8');
|
|
490
|
+
await fs.writeFile(path.join(failedExecutorRoot, 'app.txt'), 'start\n', 'utf8');
|
|
491
|
+
await fs.writeFile(path.join(failedExecutorRoot, 'fail-agent.mjs'), `
|
|
492
|
+
import fs from 'node:fs';
|
|
493
|
+
fs.appendFileSync('app.txt', 'changed before failure\\n');
|
|
494
|
+
console.log('agent changed file then failed');
|
|
495
|
+
process.exit(2);
|
|
496
|
+
`, 'utf8');
|
|
497
|
+
await fs.writeFile(path.join(failedExecutorRoot, 'pass-reviewer.mjs'), `
|
|
498
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
499
|
+
`, 'utf8');
|
|
500
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: failedExecutorRoot, encoding: 'utf8' }), 'failed executor git init');
|
|
501
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: failedExecutorRoot, encoding: 'utf8' }), 'failed executor git add');
|
|
502
|
+
|
|
503
|
+
const failedExecutorRun = run([
|
|
504
|
+
'loop-handoff',
|
|
505
|
+
'--root',
|
|
506
|
+
failedExecutorRoot,
|
|
507
|
+
'--agent',
|
|
508
|
+
'custom',
|
|
509
|
+
'--command',
|
|
510
|
+
`${quoteArg(process.execPath)} fail-agent.mjs --task-file {{plan_file}}`,
|
|
511
|
+
'--review-command',
|
|
512
|
+
`${quoteArg(process.execPath)} pass-reviewer.mjs --plan-file {{plan_file}}`,
|
|
513
|
+
'--yes'
|
|
514
|
+
]);
|
|
515
|
+
if (failedExecutorRun.status === 0) {
|
|
516
|
+
throw new Error(`loop accepted reviewer PASS after failed executor\nstdout:\n${failedExecutorRun.stdout}\nstderr:\n${failedExecutorRun.stderr}`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const failedExecutorState = await fs.readFile(path.join(failedExecutorRoot, '.ai-bridge', 'loop-handoff-state.json'), 'utf8');
|
|
520
|
+
const failedExecutorLog = await fs.readFile(path.join(failedExecutorRoot, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
521
|
+
if (!failedExecutorState.includes('"verdict": "FAIL"') || !failedExecutorState.includes('"rejectedPassReason": "executor_failed"') || !failedExecutorLog.includes('"stop_reason":"executor_failed"')) {
|
|
522
|
+
throw new Error(`loop did not reject reviewer PASS after failed executor\nstate:\n${failedExecutorState}\nlog:\n${failedExecutorLog}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const failedReviewerRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-failed-reviewer-'));
|
|
526
|
+
await fs.mkdir(path.join(failedReviewerRoot, '.ai-bridge'), { recursive: true });
|
|
527
|
+
await fs.writeFile(path.join(failedReviewerRoot, '.ai-bridge', 'current-plan.md'), '# Failed reviewer plan\n\nAppend marker.\n', 'utf8');
|
|
528
|
+
await fs.writeFile(path.join(failedReviewerRoot, 'app.txt'), 'start\n', 'utf8');
|
|
529
|
+
await fs.writeFile(path.join(failedReviewerRoot, 'agent.mjs'), `
|
|
530
|
+
import fs from 'node:fs';
|
|
531
|
+
fs.appendFileSync('app.txt', 'changed before failed reviewer\\n');
|
|
532
|
+
console.log('agent changed file');
|
|
533
|
+
`, 'utf8');
|
|
534
|
+
await fs.writeFile(path.join(failedReviewerRoot, 'failed-reviewer.mjs'), `
|
|
535
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
536
|
+
process.exit(3);
|
|
537
|
+
`, 'utf8');
|
|
538
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: failedReviewerRoot, encoding: 'utf8' }), 'failed reviewer git init');
|
|
539
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: failedReviewerRoot, encoding: 'utf8' }), 'failed reviewer git add');
|
|
540
|
+
|
|
541
|
+
const failedReviewerRun = run([
|
|
542
|
+
'loop-handoff',
|
|
543
|
+
'--root',
|
|
544
|
+
failedReviewerRoot,
|
|
545
|
+
'--agent',
|
|
546
|
+
'custom',
|
|
547
|
+
'--command',
|
|
548
|
+
`${quoteArg(process.execPath)} agent.mjs --task-file {{plan_file}}`,
|
|
549
|
+
'--review-command',
|
|
550
|
+
`${quoteArg(process.execPath)} failed-reviewer.mjs --plan-file {{plan_file}}`,
|
|
551
|
+
'--yes'
|
|
552
|
+
]);
|
|
553
|
+
if (failedReviewerRun.status === 0) {
|
|
554
|
+
throw new Error(`loop accepted reviewer PASS after failed reviewer process\nstdout:\n${failedReviewerRun.stdout}\nstderr:\n${failedReviewerRun.stderr}`);
|
|
555
|
+
}
|
|
556
|
+
const failedReviewerState = await fs.readFile(path.join(failedReviewerRoot, '.ai-bridge', 'loop-handoff-state.json'), 'utf8');
|
|
557
|
+
const failedReviewerLog = await fs.readFile(path.join(failedReviewerRoot, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
558
|
+
if (!failedReviewerState.includes('"rejectedPassReason": "reviewer_failed"') || !failedReviewerLog.includes('"stop_reason":"reviewer_error"')) {
|
|
559
|
+
throw new Error(`loop did not reject reviewer PASS after failed reviewer process\nstate:\n${failedReviewerState}\nlog:\n${failedReviewerLog}`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const barePassRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-bare-pass-'));
|
|
563
|
+
await fs.mkdir(path.join(barePassRoot, '.ai-bridge'), { recursive: true });
|
|
564
|
+
await fs.writeFile(path.join(barePassRoot, '.ai-bridge', 'current-plan.md'), '# Bare pass plan\n\nAppend marker.\n', 'utf8');
|
|
565
|
+
await fs.writeFile(path.join(barePassRoot, 'app.txt'), 'start\n', 'utf8');
|
|
566
|
+
await fs.writeFile(path.join(barePassRoot, 'agent.mjs'), `
|
|
567
|
+
import fs from 'node:fs';
|
|
568
|
+
fs.appendFileSync('app.txt', 'changed before bare pass\\n');
|
|
569
|
+
`, 'utf8');
|
|
570
|
+
await fs.writeFile(path.join(barePassRoot, 'bare-reviewer.mjs'), `
|
|
571
|
+
console.log('PASS');
|
|
572
|
+
`, 'utf8');
|
|
573
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: barePassRoot, encoding: 'utf8' }), 'bare pass git init');
|
|
574
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: barePassRoot, encoding: 'utf8' }), 'bare pass git add');
|
|
575
|
+
|
|
576
|
+
const barePassRun = run([
|
|
577
|
+
'loop-handoff',
|
|
578
|
+
'--root',
|
|
579
|
+
barePassRoot,
|
|
580
|
+
'--agent',
|
|
581
|
+
'custom',
|
|
582
|
+
'--command',
|
|
583
|
+
`${quoteArg(process.execPath)} agent.mjs --task-file {{plan_file}}`,
|
|
584
|
+
'--review-command',
|
|
585
|
+
`${quoteArg(process.execPath)} bare-reviewer.mjs --plan-file {{plan_file}}`,
|
|
586
|
+
'--yes'
|
|
587
|
+
]);
|
|
588
|
+
if (barePassRun.status === 0) {
|
|
589
|
+
throw new Error(`loop accepted bare PASS reviewer output\nstdout:\n${barePassRun.stdout}\nstderr:\n${barePassRun.stderr}`);
|
|
590
|
+
}
|
|
591
|
+
const barePassLog = await fs.readFile(path.join(barePassRoot, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
592
|
+
if (!barePassLog.includes('"stop_reason":"unknown_verdict"')) {
|
|
593
|
+
throw new Error(`loop did not reject bare PASS as unknown verdict\nlog:\n${barePassLog}`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const loopStagedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-staged-change-'));
|
|
597
|
+
await fs.mkdir(path.join(loopStagedRoot, '.ai-bridge'), { recursive: true });
|
|
598
|
+
await fs.writeFile(path.join(loopStagedRoot, '.ai-bridge', 'current-plan.md'), '# Staged plan\n\nStage a change.\n', 'utf8');
|
|
599
|
+
await fs.writeFile(path.join(loopStagedRoot, 'app.txt'), 'start\n', 'utf8');
|
|
600
|
+
await fs.writeFile(path.join(loopStagedRoot, 'stage-agent.mjs'), `
|
|
601
|
+
import fs from 'node:fs';
|
|
602
|
+
import { spawnSync } from 'node:child_process';
|
|
603
|
+
fs.appendFileSync('app.txt', 'staged change\\n');
|
|
604
|
+
const result = spawnSync('git', ['add', 'app.txt'], { stdio: 'inherit' });
|
|
605
|
+
process.exit(result.status ?? 1);
|
|
606
|
+
`, 'utf8');
|
|
607
|
+
await fs.writeFile(path.join(loopStagedRoot, 'reviewer.mjs'), `
|
|
608
|
+
import fs from 'node:fs';
|
|
609
|
+
const diffPath = process.argv[process.argv.indexOf('--diff') + 1];
|
|
610
|
+
const diff = fs.readFileSync(diffPath, 'utf8');
|
|
611
|
+
if (!diff.includes('# Staged diff') || !diff.includes('staged change')) process.exit(4);
|
|
612
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
613
|
+
`, 'utf8');
|
|
614
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: loopStagedRoot, encoding: 'utf8' }), 'staged git init');
|
|
615
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: loopStagedRoot, encoding: 'utf8' }), 'staged git add');
|
|
616
|
+
|
|
617
|
+
const stagedRun = run([
|
|
618
|
+
'loop-handoff',
|
|
619
|
+
'--root',
|
|
620
|
+
loopStagedRoot,
|
|
621
|
+
'--agent',
|
|
622
|
+
'custom',
|
|
623
|
+
'--command',
|
|
624
|
+
`${quoteArg(process.execPath)} stage-agent.mjs --task-file {{plan_file}}`,
|
|
625
|
+
'--review-command',
|
|
626
|
+
`${quoteArg(process.execPath)} reviewer.mjs --diff {{diff_file}} --plan-file {{plan_file}}`,
|
|
627
|
+
'--stop-if-no-files-changed',
|
|
628
|
+
'--yes'
|
|
629
|
+
]);
|
|
630
|
+
requireSuccess(stagedRun, 'loop-handoff staged-only change');
|
|
631
|
+
|
|
632
|
+
const untrackedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-untracked-change-'));
|
|
633
|
+
await fs.mkdir(path.join(untrackedRoot, '.ai-bridge'), { recursive: true });
|
|
634
|
+
await fs.writeFile(path.join(untrackedRoot, '.ai-bridge', 'current-plan.md'), '# Untracked plan\n\nCreate a new file.\n', 'utf8');
|
|
635
|
+
await fs.writeFile(path.join(untrackedRoot, 'app.txt'), 'start\n', 'utf8');
|
|
636
|
+
await fs.writeFile(path.join(untrackedRoot, 'untracked-agent.mjs'), `
|
|
637
|
+
import fs from 'node:fs';
|
|
638
|
+
fs.writeFileSync('new-feature.txt', 'new feature\\n');
|
|
639
|
+
`, 'utf8');
|
|
640
|
+
await fs.writeFile(path.join(untrackedRoot, 'reviewer.mjs'), `
|
|
641
|
+
import fs from 'node:fs';
|
|
642
|
+
const diffPath = process.argv[process.argv.indexOf('--diff') + 1];
|
|
643
|
+
const diff = fs.readFileSync(diffPath, 'utf8');
|
|
644
|
+
if (!diff.includes('# Untracked files') || !diff.includes('new-feature.txt')) process.exit(5);
|
|
645
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
646
|
+
`, 'utf8');
|
|
647
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: untrackedRoot, encoding: 'utf8' }), 'untracked git init');
|
|
648
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: untrackedRoot, encoding: 'utf8' }), 'untracked git add');
|
|
649
|
+
|
|
650
|
+
const untrackedRun = run([
|
|
651
|
+
'loop-handoff',
|
|
652
|
+
'--root',
|
|
653
|
+
untrackedRoot,
|
|
654
|
+
'--agent',
|
|
655
|
+
'custom',
|
|
656
|
+
'--command',
|
|
657
|
+
`${quoteArg(process.execPath)} untracked-agent.mjs --task-file {{plan_file}}`,
|
|
658
|
+
'--review-command',
|
|
659
|
+
`${quoteArg(process.execPath)} reviewer.mjs --diff {{diff_file}} --plan-file {{plan_file}}`,
|
|
660
|
+
'--stop-if-no-files-changed',
|
|
661
|
+
'--yes'
|
|
662
|
+
]);
|
|
663
|
+
requireSuccess(untrackedRun, 'loop-handoff untracked file change');
|
|
664
|
+
|
|
665
|
+
const boundedUntrackedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-bounded-untracked-'));
|
|
666
|
+
await fs.mkdir(path.join(boundedUntrackedRoot, '.ai-bridge'), { recursive: true });
|
|
667
|
+
await fs.writeFile(path.join(boundedUntrackedRoot, '.ai-bridge', 'current-plan.md'), '# Bounded untracked plan\n\nCreate symlink and large untracked files.\n', 'utf8');
|
|
668
|
+
await fs.writeFile(path.join(boundedUntrackedRoot, 'app.txt'), 'start\n', 'utf8');
|
|
669
|
+
await fs.writeFile(path.join(boundedUntrackedRoot, 'bounded-agent.mjs'), `
|
|
670
|
+
import fs from 'node:fs';
|
|
671
|
+
fs.writeFileSync('large-artifact.bin', Buffer.alloc(96 * 1024, 'a'));
|
|
672
|
+
fs.symlinkSync('app.txt', 'app-link.txt');
|
|
673
|
+
`, 'utf8');
|
|
674
|
+
await fs.writeFile(path.join(boundedUntrackedRoot, 'reviewer.mjs'), `
|
|
675
|
+
import fs from 'node:fs';
|
|
676
|
+
const diffPath = process.argv[process.argv.indexOf('--diff') + 1];
|
|
677
|
+
const diff = fs.readFileSync(diffPath, 'utf8');
|
|
678
|
+
const linkLine = diff.split(/\\r?\\n/).find((line) => line.includes('app-link.txt'));
|
|
679
|
+
if (!linkLine || !linkLine.includes('(symlink, target=app.txt') || linkLine.includes('sha256')) process.exit(6);
|
|
680
|
+
if (!diff.includes('large-artifact.bin') || !diff.includes('sha256_first_65536=') || !diff.includes('fingerprint_truncated=true')) process.exit(7);
|
|
681
|
+
if (Buffer.byteLength(diff, 'utf8') > 4500) process.exit(8);
|
|
682
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
683
|
+
`, 'utf8');
|
|
684
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: boundedUntrackedRoot, encoding: 'utf8' }), 'bounded untracked git init');
|
|
685
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: boundedUntrackedRoot, encoding: 'utf8' }), 'bounded untracked git add');
|
|
686
|
+
|
|
687
|
+
const boundedUntrackedRun = run([
|
|
688
|
+
'loop-handoff',
|
|
689
|
+
'--root',
|
|
690
|
+
boundedUntrackedRoot,
|
|
691
|
+
'--agent',
|
|
692
|
+
'custom',
|
|
693
|
+
'--command',
|
|
694
|
+
`${quoteArg(process.execPath)} bounded-agent.mjs --task-file {{plan_file}}`,
|
|
695
|
+
'--review-command',
|
|
696
|
+
`${quoteArg(process.execPath)} reviewer.mjs --diff {{diff_file}} --plan-file {{plan_file}}`,
|
|
697
|
+
'--max-output-bytes',
|
|
698
|
+
'4000',
|
|
699
|
+
'--stop-if-no-files-changed',
|
|
700
|
+
'--yes'
|
|
701
|
+
]);
|
|
702
|
+
requireSuccess(boundedUntrackedRun, 'loop-handoff bounded untracked fingerprints');
|
|
703
|
+
|
|
704
|
+
const dirtyBaselineRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-dirty-baseline-'));
|
|
705
|
+
await fs.mkdir(path.join(dirtyBaselineRoot, '.ai-bridge'), { recursive: true });
|
|
706
|
+
await fs.writeFile(path.join(dirtyBaselineRoot, '.ai-bridge', 'current-plan.md'), '# Dirty baseline plan\n\nDo nothing.\n', 'utf8');
|
|
707
|
+
await fs.writeFile(path.join(dirtyBaselineRoot, 'app.txt'), 'start\n', 'utf8');
|
|
708
|
+
await fs.writeFile(path.join(dirtyBaselineRoot, 'noop-agent.mjs'), `
|
|
709
|
+
console.log('noop agent');
|
|
710
|
+
`, 'utf8');
|
|
711
|
+
await fs.writeFile(path.join(dirtyBaselineRoot, 'reviewer.mjs'), `
|
|
712
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
713
|
+
`, 'utf8');
|
|
714
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: dirtyBaselineRoot, encoding: 'utf8' }), 'dirty baseline git init');
|
|
715
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: dirtyBaselineRoot, encoding: 'utf8' }), 'dirty baseline git add');
|
|
716
|
+
await fs.appendFile(path.join(dirtyBaselineRoot, 'app.txt'), 'preexisting dirty change\\n', 'utf8');
|
|
717
|
+
|
|
718
|
+
const dirtyBaselineRun = run([
|
|
719
|
+
'loop-handoff',
|
|
720
|
+
'--root',
|
|
721
|
+
dirtyBaselineRoot,
|
|
722
|
+
'--agent',
|
|
723
|
+
'custom',
|
|
724
|
+
'--command',
|
|
725
|
+
`${quoteArg(process.execPath)} noop-agent.mjs --task-file {{plan_file}}`,
|
|
726
|
+
'--review-command',
|
|
727
|
+
`${quoteArg(process.execPath)} reviewer.mjs --plan-file {{plan_file}}`,
|
|
728
|
+
'--stop-if-no-files-changed',
|
|
729
|
+
'--yes'
|
|
730
|
+
]);
|
|
731
|
+
if (dirtyBaselineRun.status === 0) {
|
|
732
|
+
throw new Error(`loop accepted preexisting dirty baseline as executor changes\nstdout:\n${dirtyBaselineRun.stdout}\nstderr:\n${dirtyBaselineRun.stderr}`);
|
|
733
|
+
}
|
|
734
|
+
const dirtyBaselineLog = await fs.readFile(path.join(dirtyBaselineRoot, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
735
|
+
if (!dirtyBaselineLog.includes('"stop_reason":"no_files_changed"')) {
|
|
736
|
+
throw new Error(`loop did not stop no-op executor against dirty baseline\nlog:\n${dirtyBaselineLog}`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const repeatedSameRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-repeated-same-'));
|
|
740
|
+
await fs.mkdir(path.join(repeatedSameRoot, '.ai-bridge'), { recursive: true });
|
|
741
|
+
await fs.writeFile(path.join(repeatedSameRoot, '.ai-bridge', 'current-plan.md'), '# Repeated same plan\n\nWrite final content.\n', 'utf8');
|
|
742
|
+
await fs.writeFile(path.join(repeatedSameRoot, 'app.txt'), 'base\n', 'utf8');
|
|
743
|
+
await fs.writeFile(path.join(repeatedSameRoot, 'same-agent.mjs'), `
|
|
744
|
+
import fs from 'node:fs';
|
|
745
|
+
const markerPath = '.ai-bridge/rewrite-count.txt';
|
|
746
|
+
const count = fs.existsSync(markerPath) ? Number(fs.readFileSync(markerPath, 'utf8')) : 0;
|
|
747
|
+
fs.writeFileSync('app.txt', 'same final content\\n');
|
|
748
|
+
const stamp = new Date(1700000000000 + (count + 1) * 1000);
|
|
749
|
+
fs.utimesSync('app.txt', stamp, stamp);
|
|
750
|
+
fs.writeFileSync(markerPath, String(count + 1));
|
|
751
|
+
`, 'utf8');
|
|
752
|
+
await fs.writeFile(path.join(repeatedSameRoot, 'reviewer.mjs'), `
|
|
753
|
+
import fs from 'node:fs';
|
|
754
|
+
const planPath = process.argv[process.argv.indexOf('--plan-file') + 1];
|
|
755
|
+
const plan = fs.readFileSync(planPath, 'utf8');
|
|
756
|
+
if (plan.includes('Repeated same plan')) {
|
|
757
|
+
fs.writeFileSync(planPath, '# Repeated same follow-up\\n\\nRewrite identical content.\\n');
|
|
758
|
+
console.log('CODEXFLOW_REVIEW=FAIL');
|
|
759
|
+
} else {
|
|
760
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
761
|
+
}
|
|
762
|
+
`, 'utf8');
|
|
763
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: repeatedSameRoot, encoding: 'utf8' }), 'repeated same git init');
|
|
764
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: repeatedSameRoot, encoding: 'utf8' }), 'repeated same git add');
|
|
765
|
+
|
|
766
|
+
const repeatedSameRun = run([
|
|
767
|
+
'loop-handoff',
|
|
768
|
+
'--root',
|
|
769
|
+
repeatedSameRoot,
|
|
770
|
+
'--agent',
|
|
771
|
+
'custom',
|
|
772
|
+
'--command',
|
|
773
|
+
`${quoteArg(process.execPath)} same-agent.mjs --task-file {{plan_file}}`,
|
|
774
|
+
'--review-command',
|
|
775
|
+
`${quoteArg(process.execPath)} reviewer.mjs --plan-file {{plan_file}}`,
|
|
776
|
+
'--max-iters',
|
|
777
|
+
'3',
|
|
778
|
+
'--stop-if-no-files-changed',
|
|
779
|
+
'--yes'
|
|
780
|
+
]);
|
|
781
|
+
if (repeatedSameRun.status === 0) {
|
|
782
|
+
throw new Error(`loop treated repeated identical content writes as new changes\nstdout:\n${repeatedSameRun.stdout}\nstderr:\n${repeatedSameRun.stderr}`);
|
|
783
|
+
}
|
|
784
|
+
const repeatedSameLog = await fs.readFile(path.join(repeatedSameRoot, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
785
|
+
if (!repeatedSameLog.includes('"stop_reason":"no_files_changed"')) {
|
|
786
|
+
throw new Error(`loop did not stop on repeated identical content write\nlog:\n${repeatedSameLog}`);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const subdirRepoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-subdir-repo-'));
|
|
790
|
+
const subdirRoot = path.join(subdirRepoRoot, 'packages', 'demo');
|
|
791
|
+
await fs.mkdir(path.join(subdirRoot, '.ai-bridge'), { recursive: true });
|
|
792
|
+
await fs.writeFile(path.join(subdirRoot, '.ai-bridge', 'current-plan.md'), '# Subdir plan\n\nAppend the first marker.\n', 'utf8');
|
|
793
|
+
await fs.writeFile(path.join(subdirRoot, 'app.txt'), 'base\n', 'utf8');
|
|
794
|
+
await fs.writeFile(path.join(subdirRoot, 'agent.mjs'), `
|
|
795
|
+
import fs from 'node:fs';
|
|
796
|
+
const planPath = process.argv[process.argv.indexOf('--task-file') + 1];
|
|
797
|
+
const plan = fs.readFileSync(planPath, 'utf8');
|
|
798
|
+
fs.appendFileSync('app.txt', plan.includes('second marker') ? 'second subdir change\\n' : 'first subdir change\\n');
|
|
799
|
+
`, 'utf8');
|
|
800
|
+
await fs.writeFile(path.join(subdirRoot, 'reviewer.mjs'), `
|
|
801
|
+
import fs from 'node:fs';
|
|
802
|
+
const planPath = process.argv[process.argv.indexOf('--plan-file') + 1];
|
|
803
|
+
const app = fs.readFileSync('app.txt', 'utf8');
|
|
804
|
+
if (app.includes('second subdir change')) {
|
|
805
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
806
|
+
} else if (app.includes('first subdir change')) {
|
|
807
|
+
fs.writeFileSync(planPath, '# Subdir follow-up\\n\\nAppend the second marker.\\n');
|
|
808
|
+
console.log('CODEXFLOW_REVIEW=FAIL');
|
|
809
|
+
} else {
|
|
810
|
+
process.exit(9);
|
|
811
|
+
}
|
|
812
|
+
`, 'utf8');
|
|
813
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: subdirRepoRoot, encoding: 'utf8' }), 'subdir repo git init');
|
|
814
|
+
requireSuccess(spawnSync('git', ['add', 'packages/demo/app.txt', 'packages/demo/agent.mjs', 'packages/demo/reviewer.mjs'], { cwd: subdirRepoRoot, encoding: 'utf8' }), 'subdir repo git add');
|
|
815
|
+
requireSuccess(spawnSync('git', ['-c', 'user.email=codexflow@example.invalid', '-c', 'user.name=CodexFlow Smoke', 'commit', '-m', 'init'], { cwd: subdirRepoRoot, encoding: 'utf8' }), 'subdir repo git commit');
|
|
816
|
+
|
|
817
|
+
const subdirRun = run([
|
|
818
|
+
'loop-handoff',
|
|
819
|
+
'--root',
|
|
820
|
+
subdirRoot,
|
|
821
|
+
'--agent',
|
|
822
|
+
'custom',
|
|
823
|
+
'--command',
|
|
824
|
+
`${quoteArg(process.execPath)} agent.mjs --task-file {{plan_file}}`,
|
|
825
|
+
'--review-command',
|
|
826
|
+
`${quoteArg(process.execPath)} reviewer.mjs --plan-file {{plan_file}}`,
|
|
827
|
+
'--max-iters',
|
|
828
|
+
'2',
|
|
829
|
+
'--require-clean-git-start',
|
|
830
|
+
'--stop-if-no-files-changed',
|
|
831
|
+
'--yes'
|
|
832
|
+
]);
|
|
833
|
+
requireSuccess(subdirRun, 'loop-handoff workspace nested below git top-level');
|
|
834
|
+
|
|
835
|
+
const subdirUntrackedRepoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-subdir-untracked-'));
|
|
836
|
+
const subdirUntrackedRoot = path.join(subdirUntrackedRepoRoot, 'packages', 'demo');
|
|
837
|
+
await fs.mkdir(path.join(subdirUntrackedRoot, '.ai-bridge'), { recursive: true });
|
|
838
|
+
await fs.writeFile(path.join(subdirUntrackedRoot, '.ai-bridge', 'current-plan.md'), '# Untracked subdir plan\n\nShould not start.\n', 'utf8');
|
|
839
|
+
await fs.writeFile(path.join(subdirUntrackedRoot, 'app.txt'), 'untracked workspace file\n', 'utf8');
|
|
840
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: subdirUntrackedRepoRoot, encoding: 'utf8' }), 'subdir untracked repo git init');
|
|
841
|
+
|
|
842
|
+
const subdirUntrackedRun = run([
|
|
843
|
+
'loop-handoff',
|
|
844
|
+
'--root',
|
|
845
|
+
subdirUntrackedRoot,
|
|
846
|
+
'--agent',
|
|
847
|
+
'custom',
|
|
848
|
+
'--command',
|
|
849
|
+
`${quoteArg(process.execPath)} -e "process.exit(0)" --task-file {{plan_file}}`,
|
|
850
|
+
'--review-command',
|
|
851
|
+
`${quoteArg(process.execPath)} -e "console.log('CODEXFLOW_REVIEW=PASS')" --plan-file {{plan_file}}`,
|
|
852
|
+
'--require-clean-git-start',
|
|
853
|
+
'--yes'
|
|
854
|
+
]);
|
|
855
|
+
if (subdirUntrackedRun.status === 0) {
|
|
856
|
+
throw new Error(`loop accepted untracked files under a nested workspace as clean\nstdout:\n${subdirUntrackedRun.stdout}\nstderr:\n${subdirUntrackedRun.stderr}`);
|
|
857
|
+
}
|
|
858
|
+
if (!subdirUntrackedRun.stderr.includes('--require-clean-git-start refused') || !subdirUntrackedRun.stderr.includes('app.txt')) {
|
|
859
|
+
throw new Error(`loop did not report nested untracked workspace file\nstdout:\n${subdirUntrackedRun.stdout}\nstderr:\n${subdirUntrackedRun.stderr}`);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const subdirOutsideUntrackedRepoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-subdir-outside-untracked-'));
|
|
863
|
+
const subdirOutsideUntrackedRoot = path.join(subdirOutsideUntrackedRepoRoot, 'packages', 'demo');
|
|
864
|
+
await fs.mkdir(path.join(subdirOutsideUntrackedRoot, '.ai-bridge'), { recursive: true });
|
|
865
|
+
await fs.writeFile(path.join(subdirOutsideUntrackedRoot, '.ai-bridge', 'current-plan.md'), '# Outside untracked plan\n\nAppend a marker.\n', 'utf8');
|
|
866
|
+
await fs.writeFile(path.join(subdirOutsideUntrackedRoot, 'app.txt'), 'base\n', 'utf8');
|
|
867
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: subdirOutsideUntrackedRepoRoot, encoding: 'utf8' }), 'subdir outside untracked repo git init');
|
|
868
|
+
requireSuccess(spawnSync('git', ['add', 'packages/demo/app.txt'], { cwd: subdirOutsideUntrackedRepoRoot, encoding: 'utf8' }), 'subdir outside untracked repo git add');
|
|
869
|
+
requireSuccess(spawnSync('git', ['-c', 'user.email=codexflow@example.invalid', '-c', 'user.name=CodexFlow Smoke', 'commit', '-m', 'init'], { cwd: subdirOutsideUntrackedRepoRoot, encoding: 'utf8' }), 'subdir outside untracked repo git commit');
|
|
870
|
+
const outsideGeneratedDir = path.join(
|
|
871
|
+
subdirOutsideUntrackedRepoRoot,
|
|
872
|
+
'packages',
|
|
873
|
+
'generated',
|
|
874
|
+
'x'.repeat(170),
|
|
875
|
+
'y'.repeat(170),
|
|
876
|
+
'z'.repeat(170)
|
|
877
|
+
);
|
|
878
|
+
await fs.mkdir(outsideGeneratedDir, { recursive: true });
|
|
879
|
+
const outsideFileSuffix = 'a'.repeat(110);
|
|
880
|
+
for (let index = 0; index < 1800; index += 1) {
|
|
881
|
+
await fs.writeFile(path.join(outsideGeneratedDir, `${String(index).padStart(4, '0')}-${outsideFileSuffix}.txt`), 'outside workspace\n', 'utf8');
|
|
882
|
+
}
|
|
883
|
+
const subdirOutsideUntrackedRun = run([
|
|
884
|
+
'loop-handoff',
|
|
885
|
+
'--root',
|
|
886
|
+
subdirOutsideUntrackedRoot,
|
|
887
|
+
'--agent',
|
|
888
|
+
'custom',
|
|
889
|
+
'--command',
|
|
890
|
+
`${quoteArg(process.execPath)} -e "require('node:fs').appendFileSync('app.txt', 'workspace change\\n')" -- --task-file {{plan_file}}`,
|
|
891
|
+
'--review-command',
|
|
892
|
+
`${quoteArg(process.execPath)} -e "console.log('CODEXFLOW_REVIEW=PASS')" -- --plan-file {{plan_file}}`,
|
|
893
|
+
'--require-clean-git-start',
|
|
894
|
+
'--yes'
|
|
895
|
+
]);
|
|
896
|
+
requireSuccess(subdirOutsideUntrackedRun, 'loop-handoff ignores large untracked tree outside nested workspace');
|
|
897
|
+
|
|
898
|
+
const largeDirtyRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-large-dirty-'));
|
|
899
|
+
await fs.mkdir(path.join(largeDirtyRoot, '.ai-bridge'), { recursive: true });
|
|
900
|
+
await fs.writeFile(path.join(largeDirtyRoot, '.ai-bridge', 'current-plan.md'), '# Large dirty plan\n\nAppend to later file.\n', 'utf8');
|
|
901
|
+
await fs.writeFile(path.join(largeDirtyRoot, 'aaa-large.txt'), 'base large\n', 'utf8');
|
|
902
|
+
await fs.writeFile(path.join(largeDirtyRoot, 'zzz-later.txt'), 'base later\n', 'utf8');
|
|
903
|
+
await fs.writeFile(path.join(largeDirtyRoot, 'agent.mjs'), `
|
|
904
|
+
import fs from 'node:fs';
|
|
905
|
+
fs.appendFileSync('zzz-later.txt', 'executor changed later file\\n');
|
|
906
|
+
`, 'utf8');
|
|
907
|
+
await fs.writeFile(path.join(largeDirtyRoot, 'reviewer.mjs'), `
|
|
908
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
909
|
+
`, 'utf8');
|
|
910
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: largeDirtyRoot, encoding: 'utf8' }), 'large dirty git init');
|
|
911
|
+
requireSuccess(spawnSync('git', ['add', 'aaa-large.txt', 'zzz-later.txt'], { cwd: largeDirtyRoot, encoding: 'utf8' }), 'large dirty git add');
|
|
912
|
+
requireSuccess(spawnSync('git', ['-c', 'user.email=codexflow@example.invalid', '-c', 'user.name=CodexFlow Smoke', 'commit', '-m', 'init'], { cwd: largeDirtyRoot, encoding: 'utf8' }), 'large dirty git commit');
|
|
913
|
+
await fs.writeFile(path.join(largeDirtyRoot, 'aaa-large.txt'), `${'preexisting dirty line\n'.repeat(9000)}`, 'utf8');
|
|
914
|
+
|
|
915
|
+
const largeDirtyRun = run([
|
|
916
|
+
'loop-handoff',
|
|
917
|
+
'--root',
|
|
918
|
+
largeDirtyRoot,
|
|
919
|
+
'--agent',
|
|
920
|
+
'custom',
|
|
921
|
+
'--command',
|
|
922
|
+
`${quoteArg(process.execPath)} agent.mjs --task-file {{plan_file}}`,
|
|
923
|
+
'--review-command',
|
|
924
|
+
`${quoteArg(process.execPath)} reviewer.mjs --plan-file {{plan_file}}`,
|
|
925
|
+
'--max-output-bytes',
|
|
926
|
+
'4000',
|
|
927
|
+
'--stop-if-no-files-changed',
|
|
928
|
+
'--yes'
|
|
929
|
+
]);
|
|
930
|
+
requireSuccess(largeDirtyRun, 'loop-handoff large dirty baseline with later executor change');
|
|
931
|
+
|
|
932
|
+
const unavailableDiffRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-unavailable-diff-'));
|
|
933
|
+
await fs.mkdir(path.join(unavailableDiffRoot, '.ai-bridge'), { recursive: true });
|
|
934
|
+
await fs.writeFile(path.join(unavailableDiffRoot, '.ai-bridge', 'current-plan.md'), '# Unavailable diff plan\n\nWrite a very large tracked diff.\n', 'utf8');
|
|
935
|
+
await fs.writeFile(path.join(unavailableDiffRoot, 'huge.txt'), 'base\n', 'utf8');
|
|
936
|
+
await fs.writeFile(path.join(unavailableDiffRoot, 'agent.mjs'), `
|
|
937
|
+
import fs from 'node:fs';
|
|
938
|
+
fs.writeFileSync('huge.txt', \`changed\\n\${'x'.repeat(2_500_000)}\\n\`);
|
|
939
|
+
`, 'utf8');
|
|
940
|
+
await fs.writeFile(path.join(unavailableDiffRoot, 'reviewer.mjs'), `
|
|
941
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
942
|
+
`, 'utf8');
|
|
943
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: unavailableDiffRoot, encoding: 'utf8' }), 'unavailable diff git init');
|
|
944
|
+
requireSuccess(spawnSync('git', ['add', 'huge.txt'], { cwd: unavailableDiffRoot, encoding: 'utf8' }), 'unavailable diff git add');
|
|
945
|
+
|
|
946
|
+
const unavailableDiffRun = run([
|
|
947
|
+
'loop-handoff',
|
|
948
|
+
'--root',
|
|
949
|
+
unavailableDiffRoot,
|
|
950
|
+
'--agent',
|
|
951
|
+
'custom',
|
|
952
|
+
'--command',
|
|
953
|
+
`${quoteArg(process.execPath)} agent.mjs --task-file {{plan_file}}`,
|
|
954
|
+
'--review-command',
|
|
955
|
+
`${quoteArg(process.execPath)} reviewer.mjs --plan-file {{plan_file}}`,
|
|
956
|
+
'--max-output-bytes',
|
|
957
|
+
'4000',
|
|
958
|
+
'--stop-if-no-files-changed',
|
|
959
|
+
'--yes'
|
|
960
|
+
]);
|
|
961
|
+
requireSuccess(unavailableDiffRun, 'loop-handoff unavailable diff artifact with real executor change');
|
|
962
|
+
const unavailableDiffArtifact = await fs.readFile(path.join(unavailableDiffRoot, '.ai-bridge', 'implementation-diff.patch'), 'utf8');
|
|
963
|
+
if (!unavailableDiffArtifact.includes('# git changes unavailable')) {
|
|
964
|
+
throw new Error(`unavailable diff smoke did not exercise the bounded diff artifact path\n${unavailableDiffArtifact.slice(0, 1000)}`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const largePlanRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-large-plan-'));
|
|
968
|
+
const largePlanText = `# Large accepted plan\n\n${'Keep this valid plan above the output cap.\n'.repeat(160)}`;
|
|
969
|
+
if (Buffer.byteLength(largePlanText, 'utf8') <= 4000) throw new Error('large plan smoke fixture is not larger than max-output cap');
|
|
970
|
+
await fs.mkdir(path.join(largePlanRoot, '.ai-bridge'), { recursive: true });
|
|
971
|
+
await fs.writeFile(path.join(largePlanRoot, '.ai-bridge', 'current-plan.md'), largePlanText, 'utf8');
|
|
972
|
+
await fs.writeFile(path.join(largePlanRoot, 'app.txt'), 'start\n', 'utf8');
|
|
973
|
+
await fs.writeFile(path.join(largePlanRoot, 'agent.mjs'), `
|
|
974
|
+
import fs from 'node:fs';
|
|
975
|
+
fs.appendFileSync('app.txt', 'changed with large plan\\n');
|
|
976
|
+
`, 'utf8');
|
|
977
|
+
await fs.writeFile(path.join(largePlanRoot, 'reviewer.mjs'), `
|
|
978
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
979
|
+
`, 'utf8');
|
|
980
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: largePlanRoot, encoding: 'utf8' }), 'large plan git init');
|
|
981
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: largePlanRoot, encoding: 'utf8' }), 'large plan git add');
|
|
982
|
+
|
|
983
|
+
const largePlanRun = run([
|
|
984
|
+
'loop-handoff',
|
|
985
|
+
'--root',
|
|
986
|
+
largePlanRoot,
|
|
987
|
+
'--agent',
|
|
988
|
+
'custom',
|
|
989
|
+
'--command',
|
|
990
|
+
`${quoteArg(process.execPath)} agent.mjs --task-file {{plan_file}}`,
|
|
991
|
+
'--review-command',
|
|
992
|
+
`${quoteArg(process.execPath)} reviewer.mjs --plan-file {{plan_file}}`,
|
|
993
|
+
'--max-output-bytes',
|
|
994
|
+
'4000',
|
|
995
|
+
'--yes'
|
|
996
|
+
]);
|
|
997
|
+
requireSuccess(largePlanRun, 'loop-handoff valid plan larger than output cap');
|
|
998
|
+
|
|
999
|
+
const stagedRenameRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-staged-rename-'));
|
|
1000
|
+
await fs.mkdir(path.join(stagedRenameRoot, '.ai-bridge'), { recursive: true });
|
|
1001
|
+
await fs.writeFile(path.join(stagedRenameRoot, '.ai-bridge', 'current-plan.md'), '# Staged rename plan\n\nNo-op.\n', 'utf8');
|
|
1002
|
+
await fs.writeFile(path.join(stagedRenameRoot, 'src.txt'), 'tracked source\n', 'utf8');
|
|
1003
|
+
await fs.writeFile(path.join(stagedRenameRoot, 'noop-agent.mjs'), `
|
|
1004
|
+
console.log('noop agent');
|
|
1005
|
+
`, 'utf8');
|
|
1006
|
+
await fs.writeFile(path.join(stagedRenameRoot, 'reviewer.mjs'), `
|
|
1007
|
+
console.log('CODEXFLOW_REVIEW=PASS');
|
|
1008
|
+
`, 'utf8');
|
|
1009
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: stagedRenameRoot, encoding: 'utf8' }), 'staged rename git init');
|
|
1010
|
+
requireSuccess(spawnSync('git', ['add', 'src.txt'], { cwd: stagedRenameRoot, encoding: 'utf8' }), 'staged rename git add');
|
|
1011
|
+
requireSuccess(spawnSync('git', ['-c', 'user.email=codexflow@example.invalid', '-c', 'user.name=CodexFlow Smoke', 'commit', '-m', 'init'], { cwd: stagedRenameRoot, encoding: 'utf8' }), 'staged rename git commit');
|
|
1012
|
+
requireSuccess(spawnSync('git', ['mv', 'src.txt', '.ai-bridge/src.txt'], { cwd: stagedRenameRoot, encoding: 'utf8' }), 'staged rename git mv');
|
|
1013
|
+
|
|
1014
|
+
const stagedRenameRun = run([
|
|
1015
|
+
'loop-handoff',
|
|
1016
|
+
'--root',
|
|
1017
|
+
stagedRenameRoot,
|
|
1018
|
+
'--agent',
|
|
1019
|
+
'custom',
|
|
1020
|
+
'--command',
|
|
1021
|
+
`${quoteArg(process.execPath)} noop-agent.mjs --task-file {{plan_file}}`,
|
|
1022
|
+
'--review-command',
|
|
1023
|
+
`${quoteArg(process.execPath)} reviewer.mjs --plan-file {{plan_file}}`,
|
|
1024
|
+
'--require-clean-git-start',
|
|
1025
|
+
'--yes'
|
|
1026
|
+
]);
|
|
1027
|
+
if (stagedRenameRun.status === 0) {
|
|
1028
|
+
throw new Error(`loop accepted staged rename from outside into .ai-bridge as clean\nstdout:\n${stagedRenameRun.stdout}\nstderr:\n${stagedRenameRun.stderr}`);
|
|
1029
|
+
}
|
|
1030
|
+
if (!stagedRenameRun.stderr.includes('--require-clean-git-start refused') || !stagedRenameRun.stderr.includes('src.txt -> .ai-bridge/src.txt')) {
|
|
1031
|
+
throw new Error(`loop did not report staged rename as non-handoff change\nstdout:\n${stagedRenameRun.stdout}\nstderr:\n${stagedRenameRun.stderr}`);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const deletedPlanRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-deleted-followup-'));
|
|
1035
|
+
await fs.mkdir(path.join(deletedPlanRoot, '.ai-bridge'), { recursive: true });
|
|
1036
|
+
await fs.writeFile(path.join(deletedPlanRoot, '.ai-bridge', 'current-plan.md'), '# Delete follow-up plan\n\nAppend marker.\n', 'utf8');
|
|
1037
|
+
await fs.writeFile(path.join(deletedPlanRoot, 'app.txt'), 'start\n', 'utf8');
|
|
1038
|
+
await fs.writeFile(path.join(deletedPlanRoot, 'agent.mjs'), `
|
|
1039
|
+
import fs from 'node:fs';
|
|
1040
|
+
fs.appendFileSync('app.txt', 'changed before deleted follow-up\\n');
|
|
1041
|
+
`, 'utf8');
|
|
1042
|
+
await fs.writeFile(path.join(deletedPlanRoot, 'delete-plan-reviewer.mjs'), `
|
|
1043
|
+
import fs from 'node:fs';
|
|
1044
|
+
const planPath = process.argv[process.argv.indexOf('--plan-file') + 1];
|
|
1045
|
+
fs.rmSync(planPath);
|
|
1046
|
+
console.log('CODEXFLOW_REVIEW=FAIL');
|
|
1047
|
+
`, 'utf8');
|
|
1048
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: deletedPlanRoot, encoding: 'utf8' }), 'deleted plan git init');
|
|
1049
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: deletedPlanRoot, encoding: 'utf8' }), 'deleted plan git add');
|
|
1050
|
+
|
|
1051
|
+
const deletedPlanRun = run([
|
|
1052
|
+
'loop-handoff',
|
|
1053
|
+
'--root',
|
|
1054
|
+
deletedPlanRoot,
|
|
1055
|
+
'--agent',
|
|
1056
|
+
'custom',
|
|
1057
|
+
'--command',
|
|
1058
|
+
`${quoteArg(process.execPath)} agent.mjs --task-file {{plan_file}}`,
|
|
1059
|
+
'--review-command',
|
|
1060
|
+
`${quoteArg(process.execPath)} delete-plan-reviewer.mjs --plan-file {{plan_file}}`,
|
|
1061
|
+
'--yes'
|
|
1062
|
+
]);
|
|
1063
|
+
if (deletedPlanRun.status === 0) {
|
|
1064
|
+
throw new Error(`loop accepted deleted follow-up plan after reviewer FAIL\nstdout:\n${deletedPlanRun.stdout}\nstderr:\n${deletedPlanRun.stderr}`);
|
|
1065
|
+
}
|
|
1066
|
+
const deletedPlanState = await fs.readFile(path.join(deletedPlanRoot, '.ai-bridge', 'loop-handoff-state.json'), 'utf8');
|
|
1067
|
+
const deletedPlanLog = await fs.readFile(path.join(deletedPlanRoot, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
1068
|
+
if (!deletedPlanState.includes('"followupPlanExists": false') || !deletedPlanState.includes('"hasUsableFollowupPlan": false') || !deletedPlanLog.includes('"stop_reason":"no_followup_plan"')) {
|
|
1069
|
+
throw new Error(`loop did not stop cleanly after deleted follow-up plan\nstate:\n${deletedPlanState}\nlog:\n${deletedPlanLog}`);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const implicitDeletedPlanRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codexflow-loop-implicit-deleted-plan-'));
|
|
1073
|
+
await fs.mkdir(path.join(implicitDeletedPlanRoot, '.ai-bridge'), { recursive: true });
|
|
1074
|
+
await fs.writeFile(path.join(implicitDeletedPlanRoot, '.ai-bridge', 'current-plan.md'), '# Implicit delete plan\n\nAppend marker.\n', 'utf8');
|
|
1075
|
+
await fs.writeFile(path.join(implicitDeletedPlanRoot, 'app.txt'), 'start\n', 'utf8');
|
|
1076
|
+
await fs.writeFile(path.join(implicitDeletedPlanRoot, 'agent.mjs'), `
|
|
1077
|
+
import fs from 'node:fs';
|
|
1078
|
+
fs.appendFileSync('app.txt', 'changed before implicit deleted plan\\n');
|
|
1079
|
+
`, 'utf8');
|
|
1080
|
+
await fs.writeFile(path.join(implicitDeletedPlanRoot, 'delete-plan-reviewer.mjs'), `
|
|
1081
|
+
import fs from 'node:fs';
|
|
1082
|
+
const planPath = process.argv[process.argv.indexOf('--plan-file') + 1];
|
|
1083
|
+
fs.rmSync(planPath);
|
|
1084
|
+
`, 'utf8');
|
|
1085
|
+
requireSuccess(spawnSync('git', ['init'], { cwd: implicitDeletedPlanRoot, encoding: 'utf8' }), 'implicit deleted plan git init');
|
|
1086
|
+
requireSuccess(spawnSync('git', ['add', 'app.txt'], { cwd: implicitDeletedPlanRoot, encoding: 'utf8' }), 'implicit deleted plan git add');
|
|
1087
|
+
|
|
1088
|
+
const implicitDeletedPlanRun = run([
|
|
1089
|
+
'loop-handoff',
|
|
1090
|
+
'--root',
|
|
1091
|
+
implicitDeletedPlanRoot,
|
|
1092
|
+
'--agent',
|
|
1093
|
+
'custom',
|
|
1094
|
+
'--command',
|
|
1095
|
+
`${quoteArg(process.execPath)} agent.mjs --task-file {{plan_file}}`,
|
|
1096
|
+
'--review-command',
|
|
1097
|
+
`${quoteArg(process.execPath)} delete-plan-reviewer.mjs --plan-file {{plan_file}}`,
|
|
1098
|
+
'--allow-implicit-review-verdict',
|
|
1099
|
+
'--yes'
|
|
1100
|
+
]);
|
|
1101
|
+
if (implicitDeletedPlanRun.status === 0) {
|
|
1102
|
+
throw new Error(`loop inferred PASS after reviewer deleted the plan\nstdout:\n${implicitDeletedPlanRun.stdout}\nstderr:\n${implicitDeletedPlanRun.stderr}`);
|
|
1103
|
+
}
|
|
1104
|
+
const implicitDeletedPlanState = await fs.readFile(path.join(implicitDeletedPlanRoot, '.ai-bridge', 'loop-handoff-state.json'), 'utf8');
|
|
1105
|
+
const implicitDeletedPlanLog = await fs.readFile(path.join(implicitDeletedPlanRoot, '.ai-bridge', 'execution-log.jsonl'), 'utf8');
|
|
1106
|
+
if (!implicitDeletedPlanState.includes('"nextPlanChanged": true') || !implicitDeletedPlanState.includes('"followupPlanExists": false') || !implicitDeletedPlanLog.includes('"stop_reason":"no_followup_plan"')) {
|
|
1107
|
+
throw new Error(`loop did not fail closed after implicit reviewer deleted the plan\nstate:\n${implicitDeletedPlanState}\nlog:\n${implicitDeletedPlanLog}`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
console.log('✓ execute-handoff, watch-handoff, and loop-handoff smoke test passed');
|