@syntax-syllogism/aloop 0.5.2 → 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/src/pipeline.mjs CHANGED
@@ -1,20 +1,21 @@
1
- import { createReadStream } from 'node:fs';
2
- import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises';
3
- import { createInterface } from 'node:readline/promises';
4
- import { dirname, isAbsolute, join, resolve } from 'node:path';
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 { APPROVED, CHANGES_REQUESTED, formatFindings, readVerdict } from './verdict.mjs';
12
-
13
- const CODE_PHASES = new Set(['implement', 'address']);
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 directoryExists(path) {
64
+ async function fileExists(path) {
72
65
  try {
73
- return (await stat(path)).isDirectory();
66
+ return (await stat(path)).isFile();
74
67
  } catch {
75
68
  return false;
76
69
  }
77
70
  }
78
71
 
79
- async function fileExists(path) {
72
+ async function createSourceSnapshot(ctx) {
73
+ const temporaryRoot = await mkdtemp(join(tmpdir(), 'aloop-source-'));
74
+ const snapshot = join(temporaryRoot, 'repo');
80
75
  try {
81
- return (await stat(path)).isFile();
82
- } catch {
83
- return false;
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
- async function resumedWorktree(git, repoRoot, branch, savedPath) {
88
- if (!savedPath) return null;
89
- if (resolve(savedPath) === resolve(repoRoot)) {
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 openTerminalInput() {
101
- return new Promise((resolveInput, rejectInput) => {
102
- const input = createReadStream('/dev/tty');
103
- const onOpen = () => {
104
- input.off('error', onError);
105
- resolveInput(input);
106
- };
107
- const onError = (error) => {
108
- input.off('open', onOpen);
109
- input.destroy();
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
- async function ensureConfirmationAvailable(input, terminalOpener) {
118
- if (input || process.stdin.isTTY) return;
119
- let terminalInput;
120
- try {
121
- terminalInput = await terminalOpener();
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
- async function confirm(question, { input = null, terminalOpener = openTerminalInput } = {}) {
129
- let confirmationInput = input;
130
- let closeInput = false;
131
- if (!confirmationInput) {
132
- if (process.stdin.isTTY) {
133
- confirmationInput = process.stdin;
134
- } else {
135
- try {
136
- confirmationInput = await terminalOpener();
137
- } catch {
138
- throw new Error('no terminal available to confirm phases; re-run with --yes to run unattended');
139
- }
140
- closeInput = true;
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
- const rl = createInterface({ input: confirmationInput, output: process.stdout });
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
- async function tee(path, text) {
156
- await appendFile(path, text);
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
- await runCommand('sh', ['-c', command], {
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
- return { ok: false, command, output: error.output ?? error.message };
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('sh', ['-c', command], {
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 isCodePhase(phase) {
205
- return CODE_PHASES.has(phase.name);
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 noOpReason(phase, result, ctx, { hasFindings = true, headBefore = null } = {}) {
209
- if (ctx.dryRun || !isCodePhase(phase) || (phase.name === 'address' && !hasFindings)) return null;
210
- const pending = await ctx.git.status();
211
- const headChanged = headBefore !== null && headBefore !== (await ctx.git.revParse());
212
- if (pending || headChanged || (result.bytesEmitted ?? 0) > 0) return null;
213
- return `${phase.name} produced no changes and no output — the engine likely did nothing. Check ${ctx.state.logPath(phase.name)}.`;
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 { prompt, path } = await renderPrompt(phase.prompt, variables, {
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: ctx.worktree,
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
- void tee(logFile, text);
511
+ pendingLogWrites.push(tee(logFile, text));
250
512
  };
513
+ let commandError;
251
514
  try {
252
- await runCommand(command, args, {
253
- cwd: ctx.worktree,
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
- return { agent, bytesEmitted };
262
- }
263
-
264
- function reviewResumePoint(state, phase, resume) {
265
- if (!resume) return { round: 1, sinceSha: null };
266
-
267
- const pending = state.data.pendingRepairs?.[phase.name];
268
- if (pending) {
269
- return {
270
- atCap: pending.round >= phase.maxRounds,
271
- pending,
272
- resumed: true,
273
- round: pending.round,
274
- sinceSha: pending.reviewedSha,
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
- atCap: previousRound >= phase.maxRounds,
284
- resumed: true,
285
- round: previousRound + 1,
286
- sinceSha,
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
- : await setupWorktree(rootGit, config, slug, branch, repoRoot, baseBranch);
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 addDirs = [state.dir];
644
- if (taskFileAccessDir && !addDirs.includes(taskFileAccessDir)) addDirs.push(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
- addDirs,
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 = { phases: [], stalled: null, branch, worktree, runDir: state.dir, task: name, taskFile };
691
-
692
- let startIndex = 0;
693
- if (args.from) {
694
- startIndex = config.resolvedPhases.findIndex((phase) => phase.name === args.from);
695
- if (startIndex < 0) throw new Error(`--from names a phase that is not in the pipeline: ${args.from}`);
696
- }
697
-
698
- for (const phase of config.resolvedPhases.slice(startIndex)) {
699
- if (args.resume && state.isComplete(phase.name)) {
700
- log(`\n── ${phase.name}: already complete, skipping`);
701
- continue;
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
- const headBefore = isCodePhase(phase) && !ctx.dryRun ? await ctx.git.revParse() : null;
774
- const result = await runAgent(phase, ctx, variables);
775
- const stalledReason = await noOpReason(phase, result, ctx, { headBefore });
776
- if (stalledReason) {
777
- log(` ${stalledReason}`);
778
- summary.stalled = { phase: phase.name, reason: stalledReason };
779
- break;
780
- }
781
- await state.markComplete(phase.name);
782
- summary.phases.push({ name: phase.name, ok: true });
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
- report(summary);
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
- function report(summary) {
790
- banner('summary');
791
- for (const phase of summary.phases) {
792
- const detail = phase.rounds ? ` (${phase.rounds} round${phase.rounds === 1 ? '' : 's'}, ${phase.verdict})` : '';
793
- log(` ✓ ${phase.name}${detail}`);
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
  }