@syntax-syllogism/aloop 0.5.3 → 0.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/CHANGELOG.md +43 -0
- package/README.md +49 -15
- package/bin/loop.mjs +197 -7
- package/package.json +7 -1
- package/presets/work-item/loop.config.mjs +1 -1
- package/presets/work-item/prompts/docs.md +6 -4
- package/presets/work-item/prompts/pr-description.md +27 -0
- package/presets/work-item/prompts/review.md +6 -1
- package/prompts/address.md +2 -1
- package/prompts/docs.md +5 -3
- package/prompts/{git.md → pr-description.md} +11 -8
- package/prompts/review.md +9 -4
- package/src/adapters.mjs +84 -9
- package/src/backends/gitlab.mjs +231 -0
- package/src/command.mjs +156 -6
- package/src/config.mjs +278 -53
- package/src/git.mjs +26 -0
- package/src/hermetic.mjs +160 -0
- package/src/index.mjs +22 -1
- package/src/manifest.mjs +67 -0
- package/src/metrics.mjs +251 -0
- package/src/operations.mjs +651 -0
- package/src/pipeline.mjs +505 -427
- package/src/policy.mjs +63 -0
- package/src/publish.mjs +183 -0
- package/src/reporter.mjs +102 -0
- package/src/runner.mjs +509 -0
- package/src/state.mjs +338 -16
- package/src/verdict.mjs +36 -2
- package/src/worktree.mjs +33 -0
- package/presets/work-item/prompts/git.md +0 -44
package/src/pipeline.mjs
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import { adapterFor, agentForPhase, passthroughRenderer, validateAgent } from './adapters.mjs';
|
|
1
|
+
import { appendFile, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
5
|
+
import { adapterFor, agentForPhase, permissionLevel, PERMISSIONS, passthroughRenderer, validateAgent } from './adapters.mjs';
|
|
6
6
|
import { runCommand } from './command.mjs';
|
|
7
7
|
import { loadConfig } from './config.mjs';
|
|
8
8
|
import { GitFacade } from './git.mjs';
|
|
9
|
+
import { hermeticEnvironment, hermeticInvocation, hermeticSnapshot } from './hermetic.mjs';
|
|
10
|
+
import { canonicalizeConfig, hashConfig, hashText } from './manifest.mjs';
|
|
11
|
+
import { computeRunMetrics } from './metrics.mjs';
|
|
12
|
+
import { publish } from './publish.mjs';
|
|
9
13
|
import { renderPrompt } from './prompts.mjs';
|
|
14
|
+
import { banner, describeAgent, ensureConfirmationAvailable, log, openTerminalInput, report } from './reporter.mjs';
|
|
10
15
|
import { RunState, slugFor } from './state.mjs';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
function log(message) {
|
|
16
|
-
console.log(message);
|
|
17
|
-
}
|
|
16
|
+
import { runPhases } from './runner.mjs';
|
|
17
|
+
import { formatFindings } from './verdict.mjs';
|
|
18
|
+
import { planWorktree, resumedWorktree, setupWorktree } from './worktree.mjs';
|
|
18
19
|
|
|
19
20
|
async function readStdin() {
|
|
20
21
|
if (process.stdin.isTTY) return '';
|
|
@@ -38,14 +39,6 @@ export function buildTaskContext({ task, taskFile }) {
|
|
|
38
39
|
return '';
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function banner(text) {
|
|
42
|
-
log(`\n── ${text} ${'─'.repeat(Math.max(0, 60 - text.length))}`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function describeAgent(agent) {
|
|
46
|
-
return `${agent.name}${agent.model ? ` model=${agent.model}` : ''}${agent.effort ? ` effort=${agent.effort}` : ''}`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
42
|
function overrideSavedEngines(agentSettings, engine, customAdapters) {
|
|
50
43
|
const overrides = {};
|
|
51
44
|
const overriddenSettings = Object.fromEntries(
|
|
@@ -68,117 +61,302 @@ function configOverrideRecord(config, path) {
|
|
|
68
61
|
};
|
|
69
62
|
}
|
|
70
63
|
|
|
71
|
-
async function
|
|
64
|
+
async function fileExists(path) {
|
|
72
65
|
try {
|
|
73
|
-
return (await stat(path)).
|
|
66
|
+
return (await stat(path)).isFile();
|
|
74
67
|
} catch {
|
|
75
68
|
return false;
|
|
76
69
|
}
|
|
77
70
|
}
|
|
78
71
|
|
|
79
|
-
async function
|
|
72
|
+
async function createSourceSnapshot(ctx) {
|
|
73
|
+
const temporaryRoot = await mkdtemp(join(tmpdir(), 'aloop-source-'));
|
|
74
|
+
const snapshot = join(temporaryRoot, 'repo');
|
|
80
75
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
await runCommand('git', ['clone', '--no-hardlinks', '--no-local', ctx.worktree, snapshot], {
|
|
77
|
+
cwd: temporaryRoot,
|
|
78
|
+
timeoutMs: ctx.config.timeoutMs,
|
|
79
|
+
});
|
|
80
|
+
const snapshotGit = new GitFacade(snapshot);
|
|
81
|
+
if (!(await snapshotGit.localBranchExists(ctx.baseBranch))) {
|
|
82
|
+
await snapshotGit.run(['branch', ctx.baseBranch, await ctx.git.revParse(ctx.baseBranch)]);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
path: snapshot,
|
|
86
|
+
cleanup: () => rm(temporaryRoot, { recursive: true, force: true }),
|
|
87
|
+
};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
90
|
+
throw error;
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (await directoryExists(savedPath)) return savedPath;
|
|
91
|
-
} else {
|
|
92
|
-
const registeredPath = await git.worktreePath(branch);
|
|
93
|
-
if (registeredPath && await directoryExists(registeredPath)) return registeredPath;
|
|
94
|
-
}
|
|
95
|
-
throw new Error(
|
|
96
|
-
`Saved worktree "${savedPath}" for branch "${branch}" is unavailable. Restore it or re-register it with git worktree before resuming.`,
|
|
97
|
-
);
|
|
94
|
+
|
|
95
|
+
async function tee(path, text) {
|
|
96
|
+
await appendFile(path, text);
|
|
98
97
|
}
|
|
99
98
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
rejectInput(error);
|
|
111
|
-
};
|
|
112
|
-
input.once('open', onOpen);
|
|
113
|
-
input.once('error', onError);
|
|
99
|
+
function phaseInvocation(phase, ctx, { command, args, cwd, mounts }) {
|
|
100
|
+
if (!phase.hermetic) {
|
|
101
|
+
return { command, args, env: undefined, policy: { mode: 'host' } };
|
|
102
|
+
}
|
|
103
|
+
return hermeticInvocation({
|
|
104
|
+
settings: phase.hermetic,
|
|
105
|
+
command,
|
|
106
|
+
args,
|
|
107
|
+
cwd,
|
|
108
|
+
mounts,
|
|
114
109
|
});
|
|
115
110
|
}
|
|
116
111
|
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
} catch {
|
|
123
|
-
throw new Error('no terminal available to confirm phases; re-run with --yes to run unattended');
|
|
124
|
-
}
|
|
125
|
-
terminalInput.destroy();
|
|
112
|
+
function gitMetadataMount(ctx, mode) {
|
|
113
|
+
if (!ctx.gitCommonDir) return [];
|
|
114
|
+
const relativePath = relative(ctx.worktree, ctx.gitCommonDir);
|
|
115
|
+
const isInsideWorktree = relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
|
|
116
|
+
return isInsideWorktree ? [] : [{ path: ctx.gitCommonDir, mode }];
|
|
126
117
|
}
|
|
127
118
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
119
|
+
function manifestRole(phase) {
|
|
120
|
+
if (phase.verdict) return 'verdict';
|
|
121
|
+
if (phase.kind === 'gate') return 'gate';
|
|
122
|
+
if (phase.kind === 'publish') return 'publish';
|
|
123
|
+
if (phase.role === 'repair') return 'repair';
|
|
124
|
+
return 'agent';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function engineRecord(agent) {
|
|
128
|
+
if (!agent) return null;
|
|
129
|
+
return {
|
|
130
|
+
name: agent.name,
|
|
131
|
+
...(agent.model ? { model: agent.model } : {}),
|
|
132
|
+
...(agent.effort ? { effort: agent.effort } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function manifestEntry(phase, ctx, values = {}) {
|
|
137
|
+
return {
|
|
138
|
+
phase: phase.name,
|
|
139
|
+
kind: phase.kind,
|
|
140
|
+
role: manifestRole(phase),
|
|
141
|
+
inputSha: null,
|
|
142
|
+
outputSha: null,
|
|
143
|
+
promptHash: null,
|
|
144
|
+
configHash: ctx.configHash,
|
|
145
|
+
artifacts: [],
|
|
146
|
+
gateReceipts: [],
|
|
147
|
+
engine: null,
|
|
148
|
+
status: 'completed',
|
|
149
|
+
durationMs: 0,
|
|
150
|
+
...values,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function budgetUsage(ctx) {
|
|
155
|
+
const total = computeRunMetrics(ctx.state.manifest).total;
|
|
156
|
+
const startedAt = Date.parse(ctx.state.data.startedAt);
|
|
157
|
+
const hasUsageEntries = total.usageCoverage.applicableEntries > 0;
|
|
158
|
+
return {
|
|
159
|
+
tokens: total.tokens ?? (hasUsageEntries ? null : 0),
|
|
160
|
+
usd: total.cost ?? (hasUsageEntries ? null : 0),
|
|
161
|
+
wallClockMs: Number.isFinite(startedAt) ? Math.max(0, Date.now() - startedAt) : null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function budgetWarning(ctx, field, message) {
|
|
166
|
+
if (ctx.budgetWarnings.has(field)) return;
|
|
167
|
+
ctx.budgetWarnings.add(field);
|
|
168
|
+
log(` warning: ${message}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function budgetStatus(ctx) {
|
|
172
|
+
if (ctx.dryRun) return null;
|
|
173
|
+
const budget = ctx.config.budget;
|
|
174
|
+
if (!budget || !Object.keys(budget).length) return null;
|
|
175
|
+
|
|
176
|
+
const usage = budgetUsage(ctx);
|
|
177
|
+
const checks = [
|
|
178
|
+
['tokens', 'tokens', 'token usage is unavailable; the token budget cannot be enforced.'],
|
|
179
|
+
['usd', 'usd', 'cost usage is unavailable; the dollar budget cannot be enforced.'],
|
|
180
|
+
['wallClockMs', 'wallClockMs', 'the run start time is unavailable; the wall-clock budget cannot be enforced.'],
|
|
181
|
+
];
|
|
182
|
+
for (const [field, label, warning] of checks) {
|
|
183
|
+
if (budget[field] === undefined) continue;
|
|
184
|
+
if (usage[field] === null) {
|
|
185
|
+
budgetWarning(ctx, field, warning);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (usage[field] >= budget[field]) {
|
|
189
|
+
return {
|
|
190
|
+
reason: `budget exhausted: ${label}`,
|
|
191
|
+
output: `${label} usage ${usage[field]} reached the configured limit ${budget[field]}.`,
|
|
192
|
+
usage: usage[field],
|
|
193
|
+
limit: budget[field],
|
|
194
|
+
};
|
|
141
195
|
}
|
|
142
196
|
}
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
const answer = await rl.question(`${question} [Y/n/q] `);
|
|
146
|
-
const normalized = answer.trim().toLowerCase();
|
|
147
|
-
if (normalized === 'q') return 'quit';
|
|
148
|
-
return normalized === '' || normalized === 'y' ? 'yes' : 'skip';
|
|
149
|
-
} finally {
|
|
150
|
-
rl.close();
|
|
151
|
-
if (closeInput) confirmationInput.destroy();
|
|
152
|
-
}
|
|
197
|
+
return null;
|
|
153
198
|
}
|
|
154
199
|
|
|
155
|
-
|
|
156
|
-
|
|
200
|
+
function logBudgetStall(budget) {
|
|
201
|
+
log(` ${budget.reason}; ${budget.output}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function recordBudgetStall(ctx, phase, budget) {
|
|
205
|
+
logBudgetStall(budget);
|
|
206
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, {
|
|
207
|
+
status: 'stalled',
|
|
208
|
+
budgetStall: true,
|
|
209
|
+
failure: { reason: budget.reason, usage: budget.usage, limit: budget.limit },
|
|
210
|
+
}));
|
|
211
|
+
return { phase: phase.name, reason: budget.reason, output: budget.output };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function markBudgetExhaustedComplete(state, phase, details = {}) {
|
|
215
|
+
await state.markComplete(phase.name, { ...details, budgetExhausted: true });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function recordManifest(ctx, entry) {
|
|
219
|
+
if (ctx.dryRun) return;
|
|
220
|
+
ctx.state.manifest.append(entry);
|
|
221
|
+
await ctx.state.save();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function markStalledManifest(ctx, phase, result, startIndex) {
|
|
225
|
+
const stalledPhase = result.stalledPhase ?? phase.name;
|
|
226
|
+
const entry = ctx.state.manifest.entries
|
|
227
|
+
.slice(startIndex)
|
|
228
|
+
.findLast((candidate) => candidate.phase === stalledPhase);
|
|
229
|
+
if (entry) {
|
|
230
|
+
entry.status = 'stalled';
|
|
231
|
+
await ctx.state.save();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, { status: 'stalled' }));
|
|
157
235
|
}
|
|
158
236
|
|
|
159
237
|
async function runGate(phase, ctx) {
|
|
160
238
|
const commands = phase.commands ?? ctx.config.gate;
|
|
161
239
|
const logFile = ctx.state.logPath(phase.name);
|
|
240
|
+
await tee(logFile, '');
|
|
241
|
+
const started = performance.now();
|
|
242
|
+
const gateReceipts = [];
|
|
243
|
+
const execution = phaseInvocation(phase, ctx, {
|
|
244
|
+
command: ctx.config.shell,
|
|
245
|
+
args: ['-c', commands.join(' && ')],
|
|
246
|
+
cwd: ctx.worktree,
|
|
247
|
+
mounts: [
|
|
248
|
+
{ path: ctx.worktree, mode: 'ro' },
|
|
249
|
+
{ path: ctx.state.dir, mode: 'rw' },
|
|
250
|
+
...gitMetadataMount(ctx, 'ro'),
|
|
251
|
+
],
|
|
252
|
+
});
|
|
162
253
|
for (const command of commands) {
|
|
163
254
|
log(` $ ${command}`);
|
|
255
|
+
const commandStarted = performance.now();
|
|
164
256
|
try {
|
|
165
257
|
// Gate commands run through a shell on purpose: they are user-authored
|
|
166
258
|
// strings that routinely need quoting, pipes, `&&`, and env vars, and
|
|
167
259
|
// naive whitespace splitting mangles all four without complaining.
|
|
168
|
-
|
|
260
|
+
const commandExecution = phaseInvocation(phase, ctx, {
|
|
261
|
+
command: ctx.config.shell,
|
|
262
|
+
args: ['-c', command],
|
|
169
263
|
cwd: ctx.worktree,
|
|
264
|
+
mounts: [
|
|
265
|
+
{ path: ctx.worktree, mode: 'ro' },
|
|
266
|
+
{ path: ctx.state.dir, mode: 'rw' },
|
|
267
|
+
...gitMetadataMount(ctx, 'ro'),
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
await runCommand(commandExecution.command, commandExecution.args, {
|
|
271
|
+
cwd: commandExecution.policy.mode === 'host' ? ctx.worktree : undefined,
|
|
272
|
+
env: commandExecution.env,
|
|
170
273
|
timeoutMs: ctx.config.timeoutMs,
|
|
274
|
+
activeProcessPath: ctx.activeProcessPath,
|
|
171
275
|
onOutput: (text) => {
|
|
172
276
|
process.stdout.write(text);
|
|
173
277
|
void tee(logFile, text);
|
|
174
278
|
},
|
|
175
279
|
});
|
|
280
|
+
gateReceipts.push({ command, exitCode: 0, durationMs: Math.round(performance.now() - commandStarted) });
|
|
176
281
|
} catch (error) {
|
|
177
282
|
await tee(logFile, `\n${error.output ?? error.message}\n`);
|
|
178
|
-
|
|
283
|
+
gateReceipts.push({
|
|
284
|
+
command,
|
|
285
|
+
exitCode: typeof error.code === 'number' ? error.code : null,
|
|
286
|
+
durationMs: Math.round(performance.now() - commandStarted),
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
command,
|
|
291
|
+
output: error.output ?? error.message,
|
|
292
|
+
gateReceipts,
|
|
293
|
+
durationMs: Math.round(performance.now() - started),
|
|
294
|
+
execution: execution.policy,
|
|
295
|
+
};
|
|
179
296
|
}
|
|
180
297
|
}
|
|
181
|
-
return { ok: true };
|
|
298
|
+
return { ok: true, gateReceipts, durationMs: Math.round(performance.now() - started), execution: execution.policy };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function runPublish(phase, ctx, { remote, branch, base, approvedSha }) {
|
|
302
|
+
const startedAt = new Date().toISOString();
|
|
303
|
+
const started = performance.now();
|
|
304
|
+
const inputSha = await ctx.git.revParse();
|
|
305
|
+
const descriptionPath = join(ctx.state.dir, 'pr.md');
|
|
306
|
+
let result;
|
|
307
|
+
let failure = null;
|
|
308
|
+
try {
|
|
309
|
+
result = await publish({
|
|
310
|
+
git: ctx.git,
|
|
311
|
+
remote,
|
|
312
|
+
branch,
|
|
313
|
+
base,
|
|
314
|
+
prBodyPath: descriptionPath,
|
|
315
|
+
draft: ctx.config.publish.draft ?? true,
|
|
316
|
+
backend: ctx.config.publish.backend ?? 'github',
|
|
317
|
+
approvedSha,
|
|
318
|
+
...(phase.hermetic ? { env: hermeticEnvironment(phase.hermetic) } : {}),
|
|
319
|
+
});
|
|
320
|
+
} catch (error) {
|
|
321
|
+
failure = error;
|
|
322
|
+
}
|
|
323
|
+
const outputSha = await ctx.git.revParse();
|
|
324
|
+
const manifest = manifestEntry(phase, ctx, {
|
|
325
|
+
inputSha,
|
|
326
|
+
outputSha,
|
|
327
|
+
approvedSha,
|
|
328
|
+
artifacts: [
|
|
329
|
+
...(await fileExists(descriptionPath) ? ['pr.md'] : []),
|
|
330
|
+
],
|
|
331
|
+
startedAt,
|
|
332
|
+
completedAt: new Date().toISOString(),
|
|
333
|
+
durationMs: Math.round(performance.now() - started),
|
|
334
|
+
...(result ? {
|
|
335
|
+
remoteSha: result.remoteSha,
|
|
336
|
+
prUrl: result.pullRequest.url,
|
|
337
|
+
pullRequest: result.pullRequest,
|
|
338
|
+
status: 'completed',
|
|
339
|
+
} : {
|
|
340
|
+
status: 'stalled',
|
|
341
|
+
failure: { reason: failure?.message ?? 'publishing failed' },
|
|
342
|
+
}),
|
|
343
|
+
});
|
|
344
|
+
return { ok: Boolean(result), result, manifest, failure };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function withRetries(phase, operation, { failed = () => false } = {}) {
|
|
348
|
+
const maxAttempts = phase.retry?.maxAttempts ?? 1;
|
|
349
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
350
|
+
try {
|
|
351
|
+
const result = await operation();
|
|
352
|
+
if (!failed(result)) return result;
|
|
353
|
+
if (attempt === maxAttempts) return result;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (attempt === maxAttempts) throw error;
|
|
356
|
+
}
|
|
357
|
+
log(` ${phase.name} attempt ${attempt}/${maxAttempts} failed; retrying`);
|
|
358
|
+
}
|
|
359
|
+
throw new Error(`Phase "${phase.name}" exhausted its retry policy.`);
|
|
182
360
|
}
|
|
183
361
|
|
|
184
362
|
async function runSetup(commands, ctx) {
|
|
@@ -186,9 +364,10 @@ async function runSetup(commands, ctx) {
|
|
|
186
364
|
for (const command of commands) {
|
|
187
365
|
log(` $ ${command}`);
|
|
188
366
|
try {
|
|
189
|
-
await runCommand(
|
|
367
|
+
await runCommand(ctx.config.shell, ['-c', command], {
|
|
190
368
|
cwd: ctx.worktree,
|
|
191
369
|
timeoutMs: ctx.config.timeoutMs,
|
|
370
|
+
activeProcessPath: ctx.activeProcessPath,
|
|
192
371
|
onOutput: (text) => {
|
|
193
372
|
process.stdout.write(text);
|
|
194
373
|
void tee(logFile, text);
|
|
@@ -201,39 +380,121 @@ async function runSetup(commands, ctx) {
|
|
|
201
380
|
}
|
|
202
381
|
}
|
|
203
382
|
|
|
204
|
-
function
|
|
205
|
-
|
|
383
|
+
async function captureEngineVersions(agentSettings, customAdapters, cwd) {
|
|
384
|
+
const versions = {};
|
|
385
|
+
const engines = new Map(Object.values(agentSettings).map((agent) => [agent.name, agent]));
|
|
386
|
+
for (const [name, agent] of engines) {
|
|
387
|
+
const adapter = adapterFor(name, customAdapters);
|
|
388
|
+
if (!adapter.version) continue;
|
|
389
|
+
try {
|
|
390
|
+
const version = await adapter.version({ agent, cwd });
|
|
391
|
+
if (typeof version === 'string' && version.trim()) versions[name] = version.trim();
|
|
392
|
+
} catch {
|
|
393
|
+
// Version capture is diagnostic context and must not make a run fail.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return versions;
|
|
206
397
|
}
|
|
207
398
|
|
|
208
|
-
async function
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
399
|
+
async function captureRunSnapshot({ state, rootGit, baseBranch, branch, config, agentSettings, cwd, worktree }) {
|
|
400
|
+
const capturedAt = new Date().toISOString();
|
|
401
|
+
const gateDefinitions = {
|
|
402
|
+
default: [...config.gate],
|
|
403
|
+
phases: config.resolvedPhases
|
|
404
|
+
.flatMap((phase) => [phase, ...(phase.repair ?? [])])
|
|
405
|
+
.filter((phase) => phase.kind === 'gate')
|
|
406
|
+
.map((phase) => ({ name: phase.name, commands: [...(phase.commands ?? config.gate)] })),
|
|
407
|
+
};
|
|
408
|
+
const configHash = hashConfig(config);
|
|
409
|
+
return {
|
|
410
|
+
snapshotVersion: 1,
|
|
411
|
+
capturedAt,
|
|
412
|
+
runId: state.data.runId,
|
|
413
|
+
baseSha: await rootGit.revParse(baseBranch),
|
|
414
|
+
branch,
|
|
415
|
+
baseBranch,
|
|
416
|
+
resolvedConfig: canonicalizeConfig(config),
|
|
417
|
+
configHash,
|
|
418
|
+
// Prompt context is only fully known when each invocation runs. Keep the
|
|
419
|
+
// immutable snapshot honest by pointing at the manifest's actual hashes.
|
|
420
|
+
promptHashes: {
|
|
421
|
+
source: 'manifest.json',
|
|
422
|
+
selector: 'phases[*].promptHash',
|
|
423
|
+
},
|
|
424
|
+
engineVersions: await captureEngineVersions(agentSettings, config.adapters, worktree),
|
|
425
|
+
aloopVersion: packageJson.version,
|
|
426
|
+
gateDefinitions,
|
|
427
|
+
hermetic: hermeticSnapshot(config),
|
|
428
|
+
environment: {
|
|
429
|
+
nodeVersion: process.version,
|
|
430
|
+
platform: process.platform,
|
|
431
|
+
cwd: process.cwd(),
|
|
432
|
+
timestamp: capturedAt,
|
|
433
|
+
},
|
|
434
|
+
worktree,
|
|
435
|
+
runDirectory: state.dir,
|
|
436
|
+
cwd,
|
|
437
|
+
};
|
|
214
438
|
}
|
|
215
439
|
|
|
216
440
|
async function runAgent(phase, ctx, variables) {
|
|
441
|
+
const startedAt = new Date().toISOString();
|
|
442
|
+
const started = performance.now();
|
|
443
|
+
const inputSha = ctx.dryRun ? null : await ctx.git.revParse();
|
|
217
444
|
const agent = ctx.agentSettings[phase.name] ?? agentForPhase(ctx.config, phase.name);
|
|
218
445
|
const engineOverride = ctx.engineOverrides[phase.name];
|
|
219
446
|
const adapter = adapterFor(agent.name, ctx.config.adapters);
|
|
220
|
-
const
|
|
447
|
+
const worktreeWrite = permissionLevel(phase.permissions) === PERMISSIONS.WRITE_WORKTREE;
|
|
448
|
+
const artifactOnly = !worktreeWrite;
|
|
449
|
+
const sourceSnapshot = artifactOnly && !ctx.dryRun ? await createSourceSnapshot(ctx) : null;
|
|
450
|
+
const agentRepo = sourceSnapshot?.path ?? ctx.worktree;
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const { prompt, path } = await renderPrompt(phase.prompt, {
|
|
454
|
+
...variables,
|
|
455
|
+
...(sourceSnapshot ? { REPO: agentRepo } : {}),
|
|
456
|
+
}, {
|
|
221
457
|
projectPromptDir: ctx.promptDir,
|
|
222
458
|
});
|
|
459
|
+
const promptHash = hashText(prompt);
|
|
223
460
|
log(` engine: ${agent.name}${agent.model ? ` model: ${agent.model}` : ''}${agent.effort ? ` effort: ${agent.effort}` : ''} prompt: ${path}`);
|
|
224
461
|
if (engineOverride) log(` resumed override: ${describeAgent(engineOverride.from)} → ${describeAgent(engineOverride.to)}`);
|
|
225
462
|
|
|
463
|
+
// Every read-only agent starts in the driver-owned artifact directory. The
|
|
464
|
+
// source snapshot is disposable, so an adapter's writable added directories
|
|
465
|
+
// cannot reach the real worktree while the agent can still inspect a Git tree.
|
|
466
|
+
const agentCwd = artifactOnly ? ctx.state.dir : ctx.worktree;
|
|
467
|
+
// Only the verdict phase's prompt is instructed to edit the task file (its
|
|
468
|
+
// Code Review section), so only it is granted the external task-file repo as
|
|
469
|
+
// writable. Every other artifact-only phase — pr-description included — gets
|
|
470
|
+
// just its own run directory; it has no business touching the task file repo.
|
|
471
|
+
const artifactOnlyDirs = phase.role === 'verdict' ? ctx.artifactDirs : [ctx.state.dir];
|
|
226
472
|
const { command, args } = adapter.command({
|
|
227
473
|
prompt,
|
|
228
|
-
cwd:
|
|
229
|
-
addDirs: ctx.addDirs,
|
|
474
|
+
cwd: agentCwd,
|
|
475
|
+
addDirs: worktreeWrite ? ctx.addDirs : [...artifactOnlyDirs, ...(sourceSnapshot ? [sourceSnapshot.path] : [])],
|
|
230
476
|
timeoutMs: ctx.config.timeoutMs,
|
|
231
477
|
agent,
|
|
478
|
+
permissions: phase.permissions,
|
|
479
|
+
artifactOnly,
|
|
480
|
+
});
|
|
481
|
+
const execution = phaseInvocation(phase, ctx, {
|
|
482
|
+
command,
|
|
483
|
+
args,
|
|
484
|
+
cwd: agentCwd,
|
|
485
|
+
mounts: [
|
|
486
|
+
{ path: ctx.worktree, mode: worktreeWrite ? 'rw' : 'ro' },
|
|
487
|
+
...(worktreeWrite
|
|
488
|
+
? ctx.addDirs.filter((path) => path !== ctx.worktree).map((path) => ({ path, mode: 'rw' }))
|
|
489
|
+
: artifactOnlyDirs.map((path) => ({ path, mode: 'rw' }))),
|
|
490
|
+
...(sourceSnapshot ? [{ path: sourceSnapshot.path, mode: 'ro' }] : []),
|
|
491
|
+
...gitMetadataMount(ctx, worktreeWrite ? 'rw' : 'ro'),
|
|
492
|
+
],
|
|
232
493
|
});
|
|
233
494
|
if (ctx.dryRun) {
|
|
234
|
-
log(` would run: ${[command, ...args].map((value) => JSON.stringify(String(value))).join(' ')}`);
|
|
495
|
+
log(` would run: ${[execution.command, ...execution.args].map((value) => JSON.stringify(String(value))).join(' ')}`);
|
|
235
496
|
log(`\n${prompt}\n`);
|
|
236
|
-
return { agent, command, args, dryRun: true };
|
|
497
|
+
return { agent, command, args, execution, dryRun: true };
|
|
237
498
|
}
|
|
238
499
|
const logFile = ctx.state.logPath(phase.name);
|
|
239
500
|
const overrideNote = engineOverride ? ` (resumed override: ${describeAgent(engineOverride.from)} → ${describeAgent(engineOverride.to)})` : '';
|
|
@@ -242,227 +503,71 @@ async function runAgent(phase, ctx, variables) {
|
|
|
242
503
|
// human text and passes through, so a vendor warning is never swallowed.
|
|
243
504
|
const renderer = adapter.createRenderer?.() ?? passthroughRenderer();
|
|
244
505
|
let bytesEmitted = 0;
|
|
506
|
+
const pendingLogWrites = [];
|
|
245
507
|
const emit = (text) => {
|
|
246
508
|
if (!text) return;
|
|
247
509
|
bytesEmitted += Buffer.byteLength(text);
|
|
248
510
|
process.stdout.write(text);
|
|
249
|
-
|
|
511
|
+
pendingLogWrites.push(tee(logFile, text));
|
|
250
512
|
};
|
|
513
|
+
let commandError;
|
|
251
514
|
try {
|
|
252
|
-
await runCommand(command, args, {
|
|
253
|
-
cwd:
|
|
515
|
+
await runCommand(execution.command, execution.args, {
|
|
516
|
+
cwd: execution.policy.mode === 'host' ? agentCwd : undefined,
|
|
517
|
+
env: execution.env,
|
|
254
518
|
timeoutMs: ctx.config.timeoutMs,
|
|
519
|
+
activeProcessPath: ctx.activeProcessPath,
|
|
255
520
|
onOutput: (text, stream) => emit(stream === 'stderr' ? text : renderer.write(text)),
|
|
256
521
|
});
|
|
522
|
+
} catch (error) {
|
|
523
|
+
commandError = error;
|
|
257
524
|
} finally {
|
|
258
525
|
// A killed or failed run still has a partial line worth reading.
|
|
259
526
|
emit(renderer.end());
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
527
|
+
await Promise.all(pendingLogWrites);
|
|
528
|
+
}
|
|
529
|
+
const usage = renderer.usage?.() ?? adapter.usage?.() ?? null;
|
|
530
|
+
|
|
531
|
+
const manifest = manifestEntry(phase, ctx, {
|
|
532
|
+
inputSha,
|
|
533
|
+
outputSha: await ctx.git.revParse(),
|
|
534
|
+
promptHash,
|
|
535
|
+
artifacts: [`${phase.name}.log`],
|
|
536
|
+
engine: engineRecord(agent),
|
|
537
|
+
startedAt,
|
|
538
|
+
completedAt: new Date().toISOString(),
|
|
539
|
+
durationMs: Math.round(performance.now() - started),
|
|
540
|
+
...(usage?.tokens !== undefined ? { tokens: usage.tokens } : {}),
|
|
541
|
+
...(usage?.cost !== undefined ? { cost: usage.cost } : {}),
|
|
542
|
+
...(commandError ? {
|
|
543
|
+
status: 'stalled',
|
|
544
|
+
failure: {
|
|
545
|
+
command,
|
|
546
|
+
code: commandError.code ?? null,
|
|
547
|
+
timedOut: Boolean(commandError.timedOut),
|
|
548
|
+
},
|
|
549
|
+
} : {}),
|
|
550
|
+
execution: execution.policy,
|
|
551
|
+
...(!commandError && phase.name === 'pr-description' && await fileExists(join(ctx.state.dir, 'pr.md'))
|
|
552
|
+
? { artifacts: [`${phase.name}.log`, 'pr.md'] }
|
|
553
|
+
: {}),
|
|
554
|
+
});
|
|
555
|
+
if (commandError) {
|
|
556
|
+
await recordManifest(ctx, manifest);
|
|
557
|
+
throw commandError;
|
|
276
558
|
}
|
|
277
559
|
|
|
278
|
-
const previousRound = state.data.rounds?.[phase.name] ?? 0;
|
|
279
|
-
const sinceSha = state.data.reviewedShas?.[phase.name]?.[previousRound] ?? null;
|
|
280
|
-
if (!sinceSha) return { round: 1, sinceSha: null };
|
|
281
|
-
|
|
282
560
|
return {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
561
|
+
agent,
|
|
562
|
+
bytesEmitted,
|
|
563
|
+
execution: execution.policy,
|
|
564
|
+
manifest,
|
|
287
565
|
};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
async function clearPendingRepair(phase, state) {
|
|
291
|
-
const pendingRepairs = { ...state.data.pendingRepairs };
|
|
292
|
-
delete pendingRepairs[phase.name];
|
|
293
|
-
await state.record({ pendingRepairs });
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async function runRepairs(phase, ctx, baseVariables, pending) {
|
|
297
|
-
let gateOk = true;
|
|
298
|
-
let gateFailure = null;
|
|
299
|
-
for (let index = pending.nextRepair; index < phase.repair.length; index += 1) {
|
|
300
|
-
const repair = phase.repair[index];
|
|
301
|
-
banner(`${repair.name} (round ${pending.round}${ctx.resume ? ' resume' : ''})`);
|
|
302
|
-
if (repair.kind === 'gate') {
|
|
303
|
-
if (ctx.dryRun) {
|
|
304
|
-
log(` would run: ${(repair.commands ?? ctx.config.gate).join(' && ')}`);
|
|
305
|
-
gateOk = true;
|
|
306
|
-
} else {
|
|
307
|
-
const result = await runGate(repair, ctx);
|
|
308
|
-
gateOk = result.ok;
|
|
309
|
-
gateFailure = result.ok ? null : `${result.command}\n${result.output}`;
|
|
310
|
-
}
|
|
311
|
-
} else {
|
|
312
|
-
const headBefore = isCodePhase(repair) && !ctx.dryRun ? await ctx.git.revParse() : null;
|
|
313
|
-
const result = await runAgent(repair, ctx, {
|
|
314
|
-
...baseVariables,
|
|
315
|
-
ROUND: pending.round,
|
|
316
|
-
MAX_ROUNDS: phase.maxRounds,
|
|
317
|
-
VERDICT_FILE: ctx.state.verdictPath(pending.round),
|
|
318
|
-
FINDINGS: formatFindings(pending.verdict.blocking),
|
|
319
|
-
SINCE_SHA: pending.reviewedSha,
|
|
320
|
-
GATE_STATUS: gateOk ? 'passing' : `FAILING\n${gateFailure}`,
|
|
321
|
-
});
|
|
322
|
-
const stalledReason = await noOpReason(repair, result, ctx, {
|
|
323
|
-
hasFindings: pending.verdict?.blocking?.length > 0,
|
|
324
|
-
headBefore,
|
|
325
|
-
});
|
|
326
|
-
if (stalledReason) return { gateOk, gateFailure, stalled: stalledReason, stalledPhase: repair.name };
|
|
327
|
-
}
|
|
328
|
-
await ctx.state.record({
|
|
329
|
-
pendingRepairs: {
|
|
330
|
-
...ctx.state.data.pendingRepairs,
|
|
331
|
-
[phase.name]: { ...pending, nextRepair: index + 1 },
|
|
332
|
-
},
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
await clearPendingRepair(phase, ctx.state);
|
|
336
|
-
return { gateOk, gateFailure };
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Run a verdict phase and its repair phases until the verdict clears.
|
|
341
|
-
*
|
|
342
|
-
* Two guards keep this from spinning: a hard round cap, and a refusal to exit
|
|
343
|
-
* on approval while the last gate re-check was red. Without the second one an
|
|
344
|
-
* agreeable reviewer can wave through a tree that does not build.
|
|
345
|
-
*/
|
|
346
|
-
async function runVerdictLoop(phase, ctx, baseVariables) {
|
|
347
|
-
const resumePoint = reviewResumePoint(ctx.state, phase, ctx.resume);
|
|
348
|
-
if (resumePoint.atCap) {
|
|
349
|
-
return { verdict: CHANGES_REQUESTED, rounds: phase.maxRounds, stalled: 'round cap reached' };
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
let round = resumePoint.round;
|
|
353
|
-
let gateOk = true;
|
|
354
|
-
let gateFailure = null;
|
|
355
|
-
let sinceSha = resumePoint.sinceSha;
|
|
356
|
-
|
|
357
|
-
if (resumePoint.pending) {
|
|
358
|
-
const result = await runRepairs(phase, ctx, baseVariables, resumePoint.pending);
|
|
359
|
-
gateOk = result.gateOk;
|
|
360
|
-
gateFailure = result.gateFailure;
|
|
361
|
-
if (result.stalled) {
|
|
362
|
-
return { verdict: CHANGES_REQUESTED, rounds: resumePoint.pending.round, stalled: result.stalled, stalledPhase: result.stalledPhase };
|
|
363
|
-
}
|
|
364
|
-
round += 1;
|
|
365
|
-
} else if (resumePoint.resumed) {
|
|
366
|
-
// Runs created before repair checkpoints existed cannot tell whether address
|
|
367
|
-
// completed, so fail closed by rechecking before asking for another verdict.
|
|
368
|
-
const legacyGate = phase.repair.find((repair) => repair.kind === 'gate' && repair.recheck);
|
|
369
|
-
if (legacyGate) {
|
|
370
|
-
const result = await runRepairs(phase, ctx, baseVariables, {
|
|
371
|
-
round: round - 1,
|
|
372
|
-
reviewedSha: sinceSha,
|
|
373
|
-
verdict: { blocking: [] },
|
|
374
|
-
nextRepair: phase.repair.indexOf(legacyGate),
|
|
375
|
-
});
|
|
376
|
-
gateOk = result.gateOk;
|
|
377
|
-
gateFailure = result.gateFailure;
|
|
378
|
-
if (result.stalled) {
|
|
379
|
-
return { verdict: CHANGES_REQUESTED, rounds: round, stalled: result.stalled, stalledPhase: result.stalledPhase };
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
for (;;) {
|
|
385
|
-
const verdictPath = ctx.state.verdictPath(round);
|
|
386
|
-
banner(`${phase.name} (round ${round}/${phase.maxRounds})`);
|
|
387
|
-
await runAgent(phase, ctx, {
|
|
388
|
-
...baseVariables,
|
|
389
|
-
ROUND: round,
|
|
390
|
-
MAX_ROUNDS: phase.maxRounds,
|
|
391
|
-
VERDICT_FILE: verdictPath,
|
|
392
|
-
SINCE_SHA: sinceSha ?? '(none — review the cumulative branch diff)',
|
|
393
|
-
GATE_STATUS: gateOk ? 'passing' : `FAILING\n${gateFailure}`,
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
if (ctx.dryRun) return { verdict: APPROVED, rounds: round, dryRun: true };
|
|
397
|
-
|
|
398
|
-
const reviewedSha = await ctx.git.revParse();
|
|
399
|
-
const verdict = await readVerdict(verdictPath);
|
|
400
|
-
const reviewState = {
|
|
401
|
-
rounds: { ...ctx.state.data.rounds, [phase.name]: round },
|
|
402
|
-
reviewedShas: {
|
|
403
|
-
...ctx.state.data.reviewedShas,
|
|
404
|
-
[phase.name]: {
|
|
405
|
-
...(ctx.state.data.reviewedShas?.[phase.name] ?? {}),
|
|
406
|
-
[round]: reviewedSha,
|
|
407
|
-
},
|
|
408
|
-
},
|
|
409
|
-
};
|
|
410
|
-
// Checkpoint the owed repair even when this is the final allowed round.
|
|
411
|
-
// The cap stops *this* process below, but the last review's findings are
|
|
412
|
-
// still unaddressed; recording them lets a resume (with a raised cap) run
|
|
413
|
-
// the address phase before re-reviewing, instead of burning a fresh review
|
|
414
|
-
// round on the identical tree. Resuming without raising the cap stays a
|
|
415
|
-
// no-op: reviewResumePoint reports atCap while pending.round >= maxRounds.
|
|
416
|
-
if (verdict.verdict === CHANGES_REQUESTED && phase.repair.length) {
|
|
417
|
-
reviewState.pendingRepairs = {
|
|
418
|
-
...ctx.state.data.pendingRepairs,
|
|
419
|
-
[phase.name]: { round, reviewedSha, verdict, nextRepair: 0 },
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
await ctx.state.record(reviewState);
|
|
423
|
-
sinceSha = reviewedSha;
|
|
424
|
-
log(`\n verdict: ${verdict.verdict}${verdict.summary ? ` — ${verdict.summary}` : ''}`);
|
|
425
|
-
|
|
426
|
-
if (verdict.verdict === APPROVED && gateOk) return { verdict: APPROVED, rounds: round };
|
|
427
|
-
if (verdict.verdict === APPROVED && !gateOk) {
|
|
428
|
-
log(' approval withheld: the gate is still failing.');
|
|
429
|
-
return {
|
|
430
|
-
verdict: CHANGES_REQUESTED,
|
|
431
|
-
rounds: round,
|
|
432
|
-
stalled: 'approval withheld: repair gate remains failing',
|
|
433
|
-
output: gateFailure,
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
if (!phase.repair.length) {
|
|
437
|
-
return { verdict: verdict.verdict, rounds: round, stalled: 'no repair phase configured' };
|
|
438
|
-
}
|
|
439
|
-
if (round >= phase.maxRounds) {
|
|
440
|
-
return { verdict: verdict.verdict, rounds: round, stalled: 'round cap reached', findings: verdict.blocking };
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const result = await runRepairs(phase, ctx, baseVariables, ctx.state.data.pendingRepairs[phase.name]);
|
|
444
|
-
gateOk = result.gateOk;
|
|
445
|
-
gateFailure = result.gateFailure;
|
|
446
|
-
if (result.stalled) {
|
|
447
|
-
return { verdict: CHANGES_REQUESTED, rounds: round, stalled: result.stalled, stalledPhase: result.stalledPhase };
|
|
448
|
-
}
|
|
449
|
-
round += 1;
|
|
566
|
+
} finally {
|
|
567
|
+
await sourceSnapshot?.cleanup();
|
|
450
568
|
}
|
|
451
569
|
}
|
|
452
570
|
|
|
453
|
-
async function setupWorktree(git, config, slug, branch, repoRoot, baseBranch) {
|
|
454
|
-
if (!config.worktrees) return { worktree: repoRoot, created: false, worktreeCreated: false };
|
|
455
|
-
const root = config.worktreeRoot
|
|
456
|
-
? resolve(repoRoot, config.worktreeRoot)
|
|
457
|
-
: join(dirname(repoRoot), '.loop-worktrees');
|
|
458
|
-
await mkdir(root, { recursive: true });
|
|
459
|
-
const existing = await git.worktreePath(branch);
|
|
460
|
-
if (existing) return { worktree: existing, created: false, worktreeCreated: false };
|
|
461
|
-
const path = join(root, slug);
|
|
462
|
-
const result = await git.addWorktree(path, branch, baseBranch);
|
|
463
|
-
return { worktree: path, created: result.created, worktreeCreated: true };
|
|
464
|
-
}
|
|
465
|
-
|
|
466
571
|
export async function runLoop(options = {}) {
|
|
467
572
|
const { args, confirmInput = null, terminalOpener = openTerminalInput } = options;
|
|
468
573
|
const cwd = args.cwd ?? process.cwd();
|
|
@@ -507,9 +612,11 @@ export async function runLoop(options = {}) {
|
|
|
507
612
|
runsDir,
|
|
508
613
|
slug,
|
|
509
614
|
{ task, taskFile: taskFile === '-' ? null : taskFile, name, branch: configuredBranch, repoRoot },
|
|
510
|
-
{ readOnly: Boolean(args.dryRun) },
|
|
615
|
+
{ readOnly: Boolean(args.dryRun), resume: Boolean(args.resume), lock: !args.dryRun },
|
|
511
616
|
);
|
|
512
617
|
|
|
618
|
+
let runStarted = false;
|
|
619
|
+
try {
|
|
513
620
|
const persistedName = args.resume ? state.data.name : null;
|
|
514
621
|
if (persistedName && args.name && slugFor(args.name) !== persistedName) {
|
|
515
622
|
throw new Error(
|
|
@@ -576,6 +683,8 @@ export async function runLoop(options = {}) {
|
|
|
576
683
|
await state.record({ taskFile });
|
|
577
684
|
}
|
|
578
685
|
}
|
|
686
|
+
await state.record({ status: 'running' });
|
|
687
|
+
runStarted = true;
|
|
579
688
|
const configuredAgentSettings = Object.fromEntries(
|
|
580
689
|
config.resolvedPhases
|
|
581
690
|
.flatMap((phase) => [phase, ...(phase.repair ?? [])])
|
|
@@ -625,9 +734,28 @@ export async function runLoop(options = {}) {
|
|
|
625
734
|
: null;
|
|
626
735
|
const { worktree, created, worktreeCreated } = savedWorktree
|
|
627
736
|
? { worktree: savedWorktree, created: false, worktreeCreated: false }
|
|
628
|
-
:
|
|
737
|
+
: args.dryRun
|
|
738
|
+
? { ...(await planWorktree(rootGit, config, slug, branch, repoRoot)), created: false, worktreeCreated: false }
|
|
739
|
+
: await setupWorktree(rootGit, config, slug, branch, repoRoot, baseBranch);
|
|
629
740
|
const worktreeGit = new GitFacade(worktree);
|
|
741
|
+
const hermeticPhaseExists = config.resolvedPhases
|
|
742
|
+
.flatMap((phase) => [phase, ...(phase.repair ?? [])])
|
|
743
|
+
.some((phase) => phase.hermetic);
|
|
744
|
+
const gitCommonDir = args.dryRun || !hermeticPhaseExists
|
|
745
|
+
? null
|
|
746
|
+
: resolve(worktree, await worktreeGit.commonDir());
|
|
630
747
|
await state.record({ worktree, branch, baseBranch });
|
|
748
|
+
await state.saveManifest({
|
|
749
|
+
runId: state.data.runId,
|
|
750
|
+
name: state.data.name,
|
|
751
|
+
task: task ?? null,
|
|
752
|
+
taskFile,
|
|
753
|
+
branch,
|
|
754
|
+
baseBranch,
|
|
755
|
+
repoRoot,
|
|
756
|
+
worktree,
|
|
757
|
+
snapshot: 'snapshot.json',
|
|
758
|
+
});
|
|
631
759
|
if (args.baseBranch && !created) {
|
|
632
760
|
log(` note: branch "${branch}" already has a worktree or exists; --base-branch has no effect on this run.`);
|
|
633
761
|
}
|
|
@@ -640,8 +768,8 @@ export async function runLoop(options = {}) {
|
|
|
640
768
|
const taskFileRepo = taskFileGit ? await taskFileGit.toplevel().catch(() => null) : null;
|
|
641
769
|
const taskFileAccessDir = taskFileRepo === repoRoot ? dirname(taskFile) : taskFileRepo;
|
|
642
770
|
|
|
643
|
-
const
|
|
644
|
-
if (taskFileAccessDir && !
|
|
771
|
+
const artifactDirs = [state.dir];
|
|
772
|
+
if (taskFileAccessDir && !artifactDirs.includes(taskFileAccessDir)) artifactDirs.push(taskFileAccessDir);
|
|
645
773
|
|
|
646
774
|
const remote = config.remote ?? (await rootGit.defaultRemote()) ?? 'origin';
|
|
647
775
|
if (config.remote && !(await rootGit.remoteExists(config.remote))) {
|
|
@@ -650,14 +778,21 @@ export async function runLoop(options = {}) {
|
|
|
650
778
|
const ctx = {
|
|
651
779
|
config,
|
|
652
780
|
worktree,
|
|
653
|
-
|
|
781
|
+
gitCommonDir,
|
|
782
|
+
addDirs: [worktree, ...artifactDirs],
|
|
783
|
+
artifactDirs,
|
|
654
784
|
state,
|
|
655
785
|
git: worktreeGit,
|
|
656
786
|
agentSettings,
|
|
657
787
|
engineOverrides,
|
|
788
|
+
budgetWarnings: new Set(),
|
|
789
|
+
configHash: hashConfig(config),
|
|
658
790
|
resume: Boolean(args.resume),
|
|
659
791
|
dryRun: args.dryRun,
|
|
792
|
+
remote,
|
|
793
|
+
baseBranch,
|
|
660
794
|
promptDir: join(repoRoot, config.promptDir),
|
|
795
|
+
activeProcessPath: join(state.dir, 'active-command.json'),
|
|
661
796
|
};
|
|
662
797
|
|
|
663
798
|
if (worktreeCreated && !args.dryRun && config.setup.length) {
|
|
@@ -679,6 +814,19 @@ export async function runLoop(options = {}) {
|
|
|
679
814
|
GATE_COMMANDS: config.gate.join('\n') || '(none configured)',
|
|
680
815
|
};
|
|
681
816
|
|
|
817
|
+
if (!args.dryRun && !(await state.hasSnapshot())) {
|
|
818
|
+
await state.saveSnapshot(await captureRunSnapshot({
|
|
819
|
+
state,
|
|
820
|
+
rootGit,
|
|
821
|
+
baseBranch,
|
|
822
|
+
branch,
|
|
823
|
+
config,
|
|
824
|
+
agentSettings,
|
|
825
|
+
cwd,
|
|
826
|
+
worktree,
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
|
|
682
830
|
log(`task : ${name}`);
|
|
683
831
|
if (taskFile) log(`task file : ${taskFile}`);
|
|
684
832
|
log(`branch : ${branch}${created ? ' (created)' : ''}`);
|
|
@@ -687,127 +835,57 @@ export async function runLoop(options = {}) {
|
|
|
687
835
|
log(`run dir : ${state.dir}`);
|
|
688
836
|
log(`phases : ${config.resolvedPhases.map((phase) => phase.name).join(' → ')}`);
|
|
689
837
|
|
|
690
|
-
const summary = {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
if (!args.yes && !args.dryRun) {
|
|
704
|
-
const answer = await confirm(`\nRun phase "${phase.name}"?`, {
|
|
705
|
-
input: confirmInput,
|
|
706
|
-
terminalOpener,
|
|
707
|
-
});
|
|
708
|
-
if (answer === 'quit') {
|
|
709
|
-
summary.stalled = { phase: phase.name, reason: 'stopped by user' };
|
|
710
|
-
break;
|
|
711
|
-
}
|
|
712
|
-
if (answer === 'skip') {
|
|
713
|
-
log(` skipped ${phase.name}`);
|
|
714
|
-
continue;
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// A verdict phase prints its own per-round banner.
|
|
719
|
-
if (!phase.verdict) banner(phase.name);
|
|
720
|
-
|
|
721
|
-
// A publishing phase must run on a clean tree. This is a deterministic
|
|
722
|
-
// guard, not the agent's job: an earlier phase failing to commit its work
|
|
723
|
-
// leaves the tree dirty, and without this stop the git agent — told the
|
|
724
|
-
// tree is already clean but forbidden from committing — improvises an
|
|
725
|
-
// uncommittable "fix" and spins until its timeout. Unresolved review
|
|
726
|
-
// findings deliberately do NOT block here; a clean tree with open findings
|
|
727
|
-
// is a valid state to resume through docs and git.
|
|
728
|
-
if (phase.requiresCleanTree && !ctx.dryRun) {
|
|
729
|
-
const pending = await ctx.git.status();
|
|
730
|
-
if (pending) {
|
|
731
|
-
log(' worktree is not clean; refusing to run this phase over uncommitted changes.');
|
|
732
|
-
summary.stalled = {
|
|
733
|
-
phase: phase.name,
|
|
734
|
-
reason: 'worktree has uncommitted changes; a publishing phase must run on a clean tree',
|
|
735
|
-
output: pending,
|
|
736
|
-
};
|
|
737
|
-
break;
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
if (phase.kind === 'gate') {
|
|
742
|
-
if (ctx.dryRun) {
|
|
743
|
-
log(` would run: ${(phase.commands ?? config.gate).join(' && ')}`);
|
|
744
|
-
continue;
|
|
745
|
-
}
|
|
746
|
-
const result = await runGate(phase, ctx);
|
|
747
|
-
if (!result.ok) {
|
|
748
|
-
summary.stalled = { phase: phase.name, reason: `gate failed: ${result.command}`, output: result.output };
|
|
749
|
-
break;
|
|
750
|
-
}
|
|
751
|
-
await state.markComplete(phase.name);
|
|
752
|
-
summary.phases.push({ name: phase.name, ok: true });
|
|
753
|
-
continue;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
if (phase.verdict) {
|
|
757
|
-
const result = await runVerdictLoop(phase, ctx, variables);
|
|
758
|
-
summary.phases.push({ name: phase.name, rounds: result.rounds, verdict: result.verdict });
|
|
759
|
-
if (result.stalled) {
|
|
760
|
-
summary.stalled = {
|
|
761
|
-
phase: result.stalledPhase ?? phase.name,
|
|
762
|
-
reason: result.stalled,
|
|
763
|
-
findings: result.findings,
|
|
764
|
-
rounds: result.rounds,
|
|
765
|
-
output: result.output,
|
|
766
|
-
};
|
|
767
|
-
break;
|
|
768
|
-
}
|
|
769
|
-
await state.markComplete(phase.name, { rounds: result.rounds });
|
|
770
|
-
continue;
|
|
771
|
-
}
|
|
838
|
+
const summary = {
|
|
839
|
+
phases: [],
|
|
840
|
+
stalled: null,
|
|
841
|
+
branch,
|
|
842
|
+
baseBranch,
|
|
843
|
+
remote,
|
|
844
|
+
worktree,
|
|
845
|
+
runDir: state.dir,
|
|
846
|
+
task: name,
|
|
847
|
+
taskFile,
|
|
848
|
+
prUrl: state.data.prUrl ?? state.manifestMetadata.prUrl ?? null,
|
|
849
|
+
pullRequest: state.data.pullRequest ?? state.manifestMetadata.pullRequest ?? null,
|
|
850
|
+
};
|
|
772
851
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
852
|
+
await runPhases({
|
|
853
|
+
args,
|
|
854
|
+
config,
|
|
855
|
+
state,
|
|
856
|
+
ctx,
|
|
857
|
+
variables,
|
|
858
|
+
summary,
|
|
859
|
+
remote,
|
|
860
|
+
branch,
|
|
861
|
+
baseBranch,
|
|
862
|
+
confirmInput,
|
|
863
|
+
terminalOpener,
|
|
864
|
+
operations: {
|
|
865
|
+
budgetStatus,
|
|
866
|
+
markBudgetExhaustedComplete,
|
|
867
|
+
manifestEntry,
|
|
868
|
+
markStalledManifest,
|
|
869
|
+
recordBudgetStall,
|
|
870
|
+
recordManifest,
|
|
871
|
+
runAgent,
|
|
872
|
+
runGate,
|
|
873
|
+
runPublish,
|
|
874
|
+
withRetries,
|
|
875
|
+
},
|
|
876
|
+
});
|
|
784
877
|
|
|
785
|
-
|
|
878
|
+
await state.record({
|
|
879
|
+
status: summary.stalled ? 'stalled' : 'completed',
|
|
880
|
+
...(summary.stalled ? { stalled: summary.stalled } : {}),
|
|
881
|
+
});
|
|
882
|
+
report(summary, formatFindings);
|
|
786
883
|
return summary;
|
|
787
|
-
}
|
|
788
884
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
}
|
|
795
|
-
if (!summary.stalled) {
|
|
796
|
-
log(`\nPipeline finished. Branch ${summary.branch} is ready.`);
|
|
797
|
-
log(`Logs: ${summary.runDir}`);
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
log(`\n ✗ stalled in "${summary.stalled.phase}": ${summary.stalled.reason}`);
|
|
801
|
-
if (summary.stalled.findings?.length) {
|
|
802
|
-
log(`\nOutstanding blocking findings:\n${formatFindings(summary.stalled.findings)}`);
|
|
803
|
-
}
|
|
804
|
-
if (summary.stalled.output) log(`\n${summary.stalled.output.trim().split('\n').slice(-20).join('\n')}`);
|
|
805
|
-
log(`\nWorktree: ${summary.worktree}`);
|
|
806
|
-
log(`Logs: ${summary.runDir}`);
|
|
807
|
-
const resumeArgs = [`--name ${summary.task}`];
|
|
808
|
-
if (summary.taskFile && summary.taskFile !== join(summary.runDir, 'task.md')) {
|
|
809
|
-
resumeArgs.push(`--task-file ${summary.taskFile}`);
|
|
885
|
+
} catch (error) {
|
|
886
|
+
if (runStarted && !args.dryRun) await state.record({ status: 'stalled' }).catch(() => {});
|
|
887
|
+
throw error;
|
|
888
|
+
} finally {
|
|
889
|
+
await state.release();
|
|
810
890
|
}
|
|
811
|
-
log(`Resume: aloop ${resumeArgs.join(' ')} --resume`);
|
|
812
|
-
process.exitCode = 1;
|
|
813
891
|
}
|