singleton-pipeline 0.4.0-beta.1 → 0.4.0-beta.13

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.
@@ -0,0 +1,459 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { parseAgentFileDetailed } from '../parser.js';
5
+ import { getRunner } from '../runners/index.js';
6
+ import { discoverCodexProjectInstructions } from '../runners/codex-instructions.js';
7
+ import {
8
+ assertWriteAllowed,
9
+ resolveSecurityPolicyWithConfig,
10
+ validateSecurityPolicy,
11
+ } from '../security/policy.js';
12
+ import { parsePipeRef, resolveFileGlob } from './inputs.js';
13
+ import { isSingletonInternalPath } from './outputs.js';
14
+
15
+ export function resolveProvider(step, agent) {
16
+ return step.provider || agent.provider || 'claude';
17
+ }
18
+
19
+ export function resolveModel(step, agent) {
20
+ return step.model || agent.model || null;
21
+ }
22
+
23
+ export function resolveRunnerAgent(step, agent) {
24
+ return step.runner_agent || step.opencode_agent || agent.runner_agent || agent.opencode_agent || null;
25
+ }
26
+
27
+ export function resolvePermissionMode(step, agent) {
28
+ return step.permission_mode || agent.permission_mode || '';
29
+ }
30
+
31
+ function runCommand(cmd, args, { cwd }) {
32
+ return new Promise((resolve, reject) => {
33
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
34
+ let stdout = '';
35
+ let stderr = '';
36
+ child.stdout.on('data', (d) => (stdout += d.toString()));
37
+ child.stderr.on('data', (d) => (stderr += d.toString()));
38
+ child.on('error', reject);
39
+ child.on('close', (code) => {
40
+ if (code !== 0) {
41
+ reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
42
+ return;
43
+ }
44
+ resolve({ stdout, stderr });
45
+ });
46
+ });
47
+ }
48
+
49
+ async function resolveCopilotProjectRoot(cwd) {
50
+ try {
51
+ const { stdout } = await runCommand('git', ['rev-parse', '--show-toplevel'], { cwd });
52
+ return stdout.trim() || cwd;
53
+ } catch {
54
+ return cwd;
55
+ }
56
+ }
57
+
58
+ async function findCopilotRepoAgentProfile(cwd, runnerAgent) {
59
+ const name = String(runnerAgent || '').trim();
60
+ if (!name || name.includes('/') || name.includes('\\')) return null;
61
+ const projectRoot = await resolveCopilotProjectRoot(cwd);
62
+ const file = path.join(projectRoot, '.github', 'agents', `${name}.agent.md`);
63
+ try {
64
+ await fs.access(file);
65
+ const raw = await fs.readFile(file, 'utf8');
66
+ return { file, projectRoot, tools: parseCopilotAgentTools(raw) };
67
+ } catch {
68
+ const singletonRootFile = path.join(cwd, '.github', 'agents', `${name}.agent.md`);
69
+ try {
70
+ await fs.access(singletonRootFile);
71
+ const raw = await fs.readFile(singletonRootFile, 'utf8');
72
+ return {
73
+ file: singletonRootFile,
74
+ projectRoot,
75
+ notVisibleFromGitRoot: projectRoot !== cwd,
76
+ tools: parseCopilotAgentTools(raw),
77
+ };
78
+ } catch {
79
+ return { file: null, projectRoot };
80
+ }
81
+ }
82
+ }
83
+
84
+ function parseCopilotAgentTools(raw) {
85
+ const match = String(raw || '').match(/^---\n([\s\S]*?)\n---/);
86
+ if (!match) return [];
87
+
88
+ for (const line of match[1].split('\n')) {
89
+ const m = line.match(/^\s*tools\s*:\s*\[([^\]]*)\]\s*$/);
90
+ if (!m) continue;
91
+ return m[1]
92
+ .split(',')
93
+ .map((token) => token.trim().replace(/^["']|["']$/g, ''))
94
+ .filter(Boolean);
95
+ }
96
+ return [];
97
+ }
98
+
99
+ function validateCopilotAgentTools({ label, runnerAgent, securityPolicy, tools }) {
100
+ const errors = [];
101
+ const warnings = [];
102
+ const list = Array.isArray(tools) ? tools : [];
103
+ const writeEnabled = list.includes('write') || list.includes('edit');
104
+ const shellEnabled = list.includes('shell') || list.includes('bash');
105
+
106
+ if (securityPolicy.profile === 'restricted-write' || securityPolicy.profile === 'workspace-write') {
107
+ if (list.length && !writeEnabled) {
108
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" declares tools without write/edit; the step may be unable to modify allowed_paths.`);
109
+ }
110
+ if (shellEnabled) {
111
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" enables shell tools; Singleton cannot sandbox external side effects from shell commands.`);
112
+ }
113
+ }
114
+
115
+ if (securityPolicy.profile === 'read-only' && writeEnabled) {
116
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" enables write/edit tools; Singleton will override them with --deny-tool=write for security_profile "read-only".`);
117
+ }
118
+
119
+ return { errors, warnings };
120
+ }
121
+
122
+ async function findOpenCodeProjectAgentProfile(cwd, runnerAgent) {
123
+ const name = String(runnerAgent || '').trim();
124
+ if (!name || name.includes('/') || name.includes('\\')) return null;
125
+
126
+ const file = path.join(cwd, '.opencode', 'agents', `${name}.md`);
127
+ try {
128
+ await fs.access(file);
129
+ const raw = await fs.readFile(file, 'utf8');
130
+ return { file, tools: parseOpenCodeAgentTools(raw) };
131
+ } catch {
132
+ return { file: null };
133
+ }
134
+ }
135
+
136
+ function parseOpenCodeAgentTools(raw) {
137
+ const match = String(raw || '').match(/^---\n([\s\S]*?)\n---/);
138
+ if (!match) return {};
139
+
140
+ const tools = {};
141
+ let inTools = false;
142
+ for (const line of match[1].split('\n')) {
143
+ if (/^\s*tools\s*:\s*$/.test(line)) {
144
+ inTools = true;
145
+ continue;
146
+ }
147
+ if (inTools && /^\S/.test(line)) break;
148
+ const toolMatch = line.match(/^\s{2,}([A-Za-z0-9_-]+)\s*:\s*(true|false)\s*$/);
149
+ if (inTools && toolMatch) {
150
+ tools[toolMatch[1]] = toolMatch[2] === 'true';
151
+ }
152
+ }
153
+
154
+ return tools;
155
+ }
156
+
157
+ function validateOpenCodeAgentTools({ label, runnerAgent, securityPolicy, tools }) {
158
+ const errors = [];
159
+ const warnings = [];
160
+ const writeEnabled = tools.write === true || tools.edit === true;
161
+ const bashEnabled = tools.bash === true;
162
+
163
+ if (securityPolicy.profile === 'read-only') {
164
+ const enabled = ['write', 'edit', 'bash'].filter((name) => tools[name] === true);
165
+ if (enabled.length) {
166
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" enables legacy ${enabled.join(', ')} tools; Singleton will override them with native OpenCode permissions for security_profile "read-only".`);
167
+ }
168
+ }
169
+
170
+ if (securityPolicy.profile === 'restricted-write') {
171
+ warnings.push(`${label} uses OpenCode with security_profile "restricted-write"; Singleton will inject native OpenCode edit permissions for allowed_paths and still validate post-run changes.`);
172
+ if (!writeEnabled) {
173
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" does not enable write/edit tools; the step may be unable to modify allowed_paths.`);
174
+ }
175
+ if (bashEnabled) {
176
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" enables bash; Singleton cannot sandbox external side effects from shell commands.`);
177
+ }
178
+ }
179
+
180
+ if (securityPolicy.profile === 'workspace-write') {
181
+ if (!writeEnabled) {
182
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" does not enable write/edit tools; the step may behave as read-only.`);
183
+ }
184
+ if (bashEnabled) {
185
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" enables bash; keep workspace-write steps scoped and review post-run changes.`);
186
+ }
187
+ }
188
+
189
+ return { errors, warnings };
190
+ }
191
+
192
+ function formatSecurityHighlight({ label, provider, permissionMode, securityPolicy }) {
193
+ const parts = [`${label}: security_profile "${securityPolicy.profile}"`];
194
+ if (provider === 'claude' && permissionMode) {
195
+ parts.push(`permission_mode "${permissionMode}"`);
196
+ }
197
+ if (securityPolicy.profile === 'restricted-write') {
198
+ parts.push(`allowed_paths ${securityPolicy.allowedPaths.join(', ') || '—'}`);
199
+ }
200
+ return parts.join(' · ');
201
+ }
202
+
203
+ function shouldHighlightSecurity({ provider, permissionMode, securityPolicy }) {
204
+ return securityPolicy.profile !== 'workspace-write' || (provider === 'claude' && Boolean(permissionMode));
205
+ }
206
+
207
+ function commandExists(command) {
208
+ return new Promise((resolve) => {
209
+ const lookup = process.platform === 'win32' ? 'where' : 'which';
210
+ const child = spawn(lookup, [command], { stdio: 'ignore' });
211
+ child.on('error', () => resolve(false));
212
+ child.on('close', (code) => resolve(code === 0));
213
+ });
214
+ }
215
+
216
+ export async function runPreflightChecks({ pipeline, cwd, inputDefs, inputValues, dryRun, securityConfig }) {
217
+ const errors = [];
218
+ const warnings = [];
219
+ const infos = [];
220
+ const securityHighlights = [];
221
+ const stepAgents = new Map();
222
+ const availablePipeOutputs = new Set();
223
+
224
+ if (securityConfig) {
225
+ const relConfig = path.relative(cwd, securityConfig.file);
226
+ infos.push(`Project security config: ${relConfig} · default_profile "${securityConfig.defaultProfile}".`);
227
+ }
228
+
229
+ for (const def of inputDefs) {
230
+ const value = inputValues[def.id];
231
+ if (!dryRun && !String(value || '').trim()) {
232
+ errors.push(`Missing input "${def.id}".`);
233
+ continue;
234
+ }
235
+ if (!dryRun && def.subtype === 'file' && String(value || '').trim()) {
236
+ const files = await resolveFileGlob(`$FILE:${value}`, cwd);
237
+ if (files.length === 0) {
238
+ errors.push(`Input file "${def.id}" does not resolve to any file: ${value}`);
239
+ }
240
+ }
241
+ }
242
+
243
+ const parsedAgents = [];
244
+ for (let i = 0; i < pipeline.steps.length; i += 1) {
245
+ const step = pipeline.steps[i];
246
+ const label = `Step ${i + 1} "${step.agent}"`;
247
+
248
+ if (!step.agent_file) {
249
+ errors.push(`${label} is missing agent_file.`);
250
+ continue;
251
+ }
252
+
253
+ const agentFilePath = path.isAbsolute(step.agent_file)
254
+ ? step.agent_file
255
+ : path.resolve(cwd, step.agent_file);
256
+
257
+ let raw;
258
+ try {
259
+ raw = await fs.readFile(agentFilePath, 'utf8');
260
+ } catch {
261
+ errors.push(`${label} agent file not found: ${step.agent_file}`);
262
+ continue;
263
+ }
264
+
265
+ const { agent, error } = parseAgentFileDetailed(raw, agentFilePath);
266
+ if (!agent) {
267
+ errors.push(`${label} agent file is invalid: ${step.agent_file}${error ? ` (${error})` : ''}`);
268
+ continue;
269
+ }
270
+
271
+ parsedAgents.push({ step, agent });
272
+ stepAgents.set(step.agent, agent);
273
+ const securityPolicy = resolveSecurityPolicyWithConfig(step, agent, securityConfig);
274
+ for (const error of validateSecurityPolicy(securityPolicy)) {
275
+ errors.push(`${label} ${error}.`);
276
+ }
277
+
278
+ let provider;
279
+ try {
280
+ provider = resolveProvider(step, agent);
281
+ getRunner(provider);
282
+ } catch (err) {
283
+ errors.push(`${label} uses unknown provider "${step.provider || agent.provider || ''}".`);
284
+ continue;
285
+ }
286
+
287
+ const model = resolveModel(step, agent);
288
+ if (!model) warnings.push(`${label} has no model configured for provider "${provider}".`);
289
+ const runnerAgent = resolveRunnerAgent(step, agent);
290
+ if (provider === 'copilot' && !runnerAgent) {
291
+ warnings.push(`${label} uses provider "copilot" without runner_agent; Copilot will use its default agent.`);
292
+ }
293
+ if (provider === 'opencode' && !runnerAgent) {
294
+ warnings.push(`${label} uses provider "opencode" without runner_agent; OpenCode will use its default agent.`);
295
+ }
296
+ const permissionMode = resolvePermissionMode(step, agent);
297
+ if (provider === 'claude' && permissionMode && permissionMode !== 'bypassPermissions') {
298
+ errors.push(`${label} uses unsupported Claude permission_mode "${permissionMode}".`);
299
+ }
300
+ if (provider !== 'claude' && permissionMode) {
301
+ warnings.push(`${label} defines permission_mode "${permissionMode}", but provider "${provider}" ignores it.`);
302
+ }
303
+ if (provider === 'claude' && permissionMode === 'bypassPermissions') {
304
+ infos.push(`${label} runs Claude with permission_mode "${permissionMode}".`);
305
+ }
306
+ if (provider === 'claude' && !permissionMode) {
307
+ if (securityPolicy.profile === 'read-only') {
308
+ infos.push(`${label} runs Claude in read-only mode (Write/Edit/Bash disabled via --disallowedTools).`);
309
+ } else if (securityPolicy.profile === 'restricted-write') {
310
+ warnings.push(`${label} uses Claude with security_profile "restricted-write"; Claude has no per-path tool filter, so Singleton relies on its post-run snapshot diff to reject writes outside allowed_paths.`);
311
+ } else if (securityPolicy.profile === 'dangerous') {
312
+ warnings.push(`${label} uses Claude with security_profile "dangerous"; Singleton will pass --permission-mode bypassPermissions.`);
313
+ }
314
+ }
315
+ if (provider === 'codex') {
316
+ if (securityPolicy.profile === 'read-only') {
317
+ infos.push(`${label} runs Codex in --sandbox read-only.`);
318
+ } else if (securityPolicy.profile === 'restricted-write') {
319
+ warnings.push(`${label} uses Codex with security_profile "restricted-write"; Codex has no per-path sandbox filter, so Singleton relies on its post-run snapshot diff to reject writes outside allowed_paths.`);
320
+ } else if (securityPolicy.profile === 'workspace-write') {
321
+ infos.push(`${label} runs Codex in --sandbox workspace-write.`);
322
+ } else if (securityPolicy.profile === 'dangerous') {
323
+ warnings.push(`${label} uses Codex with security_profile "dangerous"; Singleton will pass --sandbox danger-full-access.`);
324
+ }
325
+ }
326
+ if (provider === 'copilot' && runnerAgent) {
327
+ infos.push(`${label} runs Copilot with runner_agent "${runnerAgent}".`);
328
+ const repoAgentProfile = await findCopilotRepoAgentProfile(cwd, runnerAgent);
329
+ if (repoAgentProfile?.file && !repoAgentProfile.notVisibleFromGitRoot) {
330
+ infos.push(`${label} Copilot repo agent profile: ${path.relative(cwd, repoAgentProfile.file)}.`);
331
+ } else if (repoAgentProfile?.file && repoAgentProfile.notVisibleFromGitRoot) {
332
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" exists at ${path.relative(cwd, repoAgentProfile.file)}, but Copilot will use git root ${repoAgentProfile.projectRoot}. Move the profile to ${path.relative(cwd, path.join(repoAgentProfile.projectRoot, '.github', 'agents'))} or run inside a standalone git repo.`);
333
+ } else {
334
+ warnings.push(`${label} Copilot runner_agent "${runnerAgent}" was not found in .github/agents; Copilot may still resolve a user-level or organization-level agent.`);
335
+ }
336
+ if (repoAgentProfile?.file) {
337
+ const toolValidation = validateCopilotAgentTools({
338
+ label,
339
+ runnerAgent,
340
+ securityPolicy,
341
+ tools: repoAgentProfile.tools || [],
342
+ });
343
+ errors.push(...toolValidation.errors);
344
+ warnings.push(...toolValidation.warnings);
345
+ }
346
+ }
347
+ if (provider === 'opencode') {
348
+ const opencodeRuntime = [
349
+ model ? `model "${model}"` : null,
350
+ runnerAgent ? `runner_agent "${runnerAgent}"` : 'default agent',
351
+ ].filter(Boolean).join(' · ');
352
+ infos.push(`${label} runs OpenCode${opencodeRuntime ? ` with ${opencodeRuntime}` : ''}.`);
353
+ if (runnerAgent) {
354
+ const projectAgentProfile = await findOpenCodeProjectAgentProfile(cwd, runnerAgent);
355
+ if (projectAgentProfile?.file) {
356
+ infos.push(`${label} OpenCode project agent profile: ${path.relative(cwd, projectAgentProfile.file)}.`);
357
+ const toolValidation = validateOpenCodeAgentTools({
358
+ label,
359
+ runnerAgent,
360
+ securityPolicy,
361
+ tools: projectAgentProfile.tools || {},
362
+ });
363
+ errors.push(...toolValidation.errors);
364
+ warnings.push(...toolValidation.warnings);
365
+ } else if (projectAgentProfile === null) {
366
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" cannot be validated as a local project agent name.`);
367
+ } else {
368
+ warnings.push(`${label} OpenCode runner_agent "${runnerAgent}" was not found in .opencode/agents; OpenCode may still resolve a user-level agent.`);
369
+ }
370
+ }
371
+ if (securityPolicy.profile === 'dangerous') {
372
+ warnings.push(`${label} uses provider "opencode" with security_profile "dangerous"; Singleton will pass --dangerously-skip-permissions.`);
373
+ } else if (securityPolicy.profile !== 'restricted-write') {
374
+ warnings.push(`${label} uses experimental provider "opencode"; Singleton enforces the security policy with write-time and post-run validation.`);
375
+ }
376
+ }
377
+ if (shouldHighlightSecurity({ provider, permissionMode, securityPolicy })) {
378
+ securityHighlights.push(formatSecurityHighlight({ label, provider, permissionMode, securityPolicy }));
379
+ }
380
+
381
+ for (const [name, spec] of Object.entries(step.inputs || {})) {
382
+ if (typeof spec !== 'string') continue;
383
+
384
+ if (spec.startsWith('$INPUT:')) {
385
+ const id = spec.slice('$INPUT:'.length).trim();
386
+ if (!inputDefs.some((def) => def.id === id)) {
387
+ errors.push(`${label} input "${name}" references unknown $INPUT:${id}.`);
388
+ }
389
+ } else if (spec.startsWith('$PIPE:')) {
390
+ const { ref, agentId, outName } = parsePipeRef(spec);
391
+ if (!stepAgents.has(agentId)) {
392
+ errors.push(`${label} input "${name}" references future or unknown $PIPE:${ref}.`);
393
+ } else if (outName && !availablePipeOutputs.has(`${agentId}.${outName}`)) {
394
+ errors.push(`${label} input "${name}" references missing $PIPE output: ${ref}.`);
395
+ }
396
+ } else if (spec.startsWith('$FILE:')) {
397
+ const files = await resolveFileGlob(spec, cwd);
398
+ if (files.length === 0) {
399
+ errors.push(`${label} input "${name}" matched no files for ${spec}.`);
400
+ }
401
+ }
402
+ }
403
+
404
+ for (const [outputName, rawSink] of Object.entries(step.outputs || {})) {
405
+ availablePipeOutputs.add(`${step.agent}.${outputName}`);
406
+
407
+ if (typeof rawSink !== 'string') continue;
408
+ if (!rawSink.startsWith('$FILE:') && !rawSink.startsWith('$FILES:')) continue;
409
+
410
+ let sink = rawSink;
411
+ for (const [id, val] of Object.entries(inputValues)) {
412
+ sink = sink.replaceAll(`$INPUT:${id}`, val);
413
+ }
414
+ const prefix = sink.startsWith('$FILE:') ? '$FILE:' : '$FILES:';
415
+ const rawPath = sink.slice(prefix.length).trim();
416
+ const absOut = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
417
+ if (isSingletonInternalPath(absOut, cwd)) continue;
418
+ try {
419
+ assertWriteAllowed(absOut, {
420
+ root: cwd,
421
+ agentName: step.agent,
422
+ outputName,
423
+ policy: securityPolicy,
424
+ });
425
+ } catch (err) {
426
+ errors.push(err.message);
427
+ }
428
+ }
429
+ }
430
+
431
+ const usedProviders = [...new Set(parsedAgents.map(({ step, agent }) => resolveProvider(step, agent)))];
432
+ for (const provider of usedProviders) {
433
+ try {
434
+ const runner = getRunner(provider);
435
+ if (runner.command) {
436
+ const exists = await commandExists(runner.command);
437
+ if (!exists) errors.push(`Provider "${provider}" requires missing CLI binary: ${runner.command}`);
438
+ }
439
+ } catch {
440
+ // already captured above
441
+ }
442
+ }
443
+
444
+ if (usedProviders.includes('codex')) {
445
+ const projectInstructions = await discoverCodexProjectInstructions(cwd, cwd);
446
+ infos.push(
447
+ `Codex project instructions: ${projectInstructions.files.length} file${projectInstructions.files.length !== 1 ? 's' : ''} detected.`
448
+ );
449
+ }
450
+
451
+ return {
452
+ ok: errors.length === 0,
453
+ errors,
454
+ warnings,
455
+ infos,
456
+ securityHighlights,
457
+ providerCount: usedProviders.length,
458
+ };
459
+ }
@@ -0,0 +1,172 @@
1
+ import {
2
+ buildUserMessage,
3
+ resolveDebugInputOverridesFromEdit,
4
+ } from './inputs.js';
5
+ import {
6
+ debugToken,
7
+ editDebugInputs,
8
+ formatDebugList,
9
+ logDebugPromptPreview,
10
+ pushDebugEvent,
11
+ } from './debug-loop.js';
12
+
13
+ export async function prepareReplayAttempt({
14
+ attempt,
15
+ finalAttempt,
16
+ stepSnapshot,
17
+ snapshotManager,
18
+ stepOriginalPaths,
19
+ stepRegistrySnapshot,
20
+ registry,
21
+ replayInputs,
22
+ replayInputOverride,
23
+ step,
24
+ debugEvents,
25
+ inputDefs,
26
+ timeline,
27
+ shell,
28
+ outputNames,
29
+ workspaceInfoForAttempt,
30
+ systemPrompt,
31
+ securityPolicy,
32
+ debugInputOverrides,
33
+ currentSnapshot,
34
+ stats,
35
+ provider,
36
+ model,
37
+ runnerAgent,
38
+ permissionMode,
39
+ totalAttemptSeconds,
40
+ totalAttemptTurns,
41
+ totalAttemptCost,
42
+ timelineIndex,
43
+ failStep,
44
+ }) {
45
+ attempt += 1;
46
+ if (finalAttempt?.stepChanges?.length || finalAttempt?.stepWrites?.length) {
47
+ timeline.logMuted(`${debugToken.policy('Replay is restoring project files touched by the previous attempt. Previous run artifacts are kept under their attempt folder.')}`);
48
+ timeline.logMuted(`${debugToken.key('pending restore')} ${formatDebugList((finalAttempt.stepChanges || []).map((entry) => entry.relPath))}`);
49
+ timeline.logMuted(`${debugToken.key('previous artifacts')} ${formatDebugList((finalAttempt.stepWrites || []).map((entry) => entry.relPath))}`);
50
+ }
51
+
52
+ if (stepSnapshot && finalAttempt?.stepChanges?.length) {
53
+ try {
54
+ const result = await snapshotManager.restore({
55
+ snapshot: stepSnapshot,
56
+ originalPaths: stepOriginalPaths,
57
+ changes: finalAttempt.stepChanges,
58
+ });
59
+ timeline.logMuted(`${debugToken.key('restore result')} ` +
60
+ `${debugToken.key('restored')} ${formatDebugList(result.restored)} ` +
61
+ `${debugToken.muted('·')} ${debugToken.key('removed')} ${formatDebugList(result.removed)} ` +
62
+ `${debugToken.muted('·')} ${debugToken.key('skipped')} ${formatDebugList(result.skipped)}`);
63
+ if (result.skipped.length) {
64
+ timeline.logMuted(`${debugToken.policy('Could not restore (filtered out of snapshot):')} ${formatDebugList(result.skipped)}`);
65
+ stats.push({
66
+ agent: step.agent,
67
+ provider,
68
+ model: model || '—',
69
+ runnerAgent: runnerAgent || '—',
70
+ securityProfile: securityPolicy.profile,
71
+ permissionMode: permissionMode || '—',
72
+ status: 'failed',
73
+ seconds: totalAttemptSeconds,
74
+ turns: totalAttemptTurns,
75
+ cost: totalAttemptCost,
76
+ attempts: attempt,
77
+ });
78
+ failStep(
79
+ timeline,
80
+ timelineIndex,
81
+ 'replay restore incomplete',
82
+ `Replay restore incomplete before step "${step.agent}" attempt ${attempt}. These changed files were excluded from the snapshot:\n- ${result.skipped.join('\n- ')}`
83
+ );
84
+ }
85
+ currentSnapshot = await snapshotManager.captureState();
86
+ } catch (err) {
87
+ stats.push({
88
+ agent: step.agent,
89
+ provider,
90
+ model: model || '—',
91
+ runnerAgent: runnerAgent || '—',
92
+ securityProfile: securityPolicy.profile,
93
+ permissionMode: permissionMode || '—',
94
+ status: 'failed',
95
+ seconds: totalAttemptSeconds,
96
+ turns: totalAttemptTurns,
97
+ cost: totalAttemptCost,
98
+ attempts: attempt,
99
+ });
100
+ failStep(
101
+ timeline,
102
+ timelineIndex,
103
+ 'replay restore failed',
104
+ `Replay restore failed before step "${step.agent}" attempt ${attempt}: ${err.message}`
105
+ );
106
+ }
107
+ }
108
+
109
+ for (const [key, previousValue] of stepRegistrySnapshot) {
110
+ if (previousValue === undefined) delete registry[key];
111
+ else registry[key] = previousValue;
112
+ }
113
+
114
+ const editedInputs = new Set();
115
+ const replayBaseInputs = replayInputs;
116
+ if (replayInputOverride) {
117
+ const nextInputs = { ...replayInputs };
118
+ for (const [name, value] of Object.entries(replayInputOverride)) {
119
+ if (Object.prototype.hasOwnProperty.call(nextInputs, name)) {
120
+ nextInputs[name] = value;
121
+ editedInputs.add(name);
122
+ }
123
+ }
124
+ replayInputs = nextInputs;
125
+ replayInputOverride = null;
126
+ } else {
127
+ replayInputs = await editDebugInputs({
128
+ resolvedInputs: replayInputs,
129
+ shell,
130
+ timeline,
131
+ step,
132
+ debugEvents,
133
+ editedInputs,
134
+ });
135
+ }
136
+
137
+ const runtimeOverrides = resolveDebugInputOverridesFromEdit(step, replayBaseInputs, replayInputs, inputDefs);
138
+ for (const [id, value] of Object.entries(runtimeOverrides)) {
139
+ debugInputOverrides[id] = value;
140
+ }
141
+ if (Object.keys(runtimeOverrides).length) {
142
+ pushDebugEvent(debugEvents, {
143
+ step: step.agent,
144
+ phase: 'post-step',
145
+ action: 'set-runtime-input-overrides',
146
+ inputIds: Object.keys(runtimeOverrides),
147
+ attempt,
148
+ });
149
+ }
150
+ if (editedInputs.size) {
151
+ logDebugPromptPreview({
152
+ systemPrompt,
153
+ userMessage: buildUserMessage(replayInputs, outputNames, workspaceInfoForAttempt(attempt), securityPolicy),
154
+ timeline,
155
+ editedInputs,
156
+ });
157
+ }
158
+ pushDebugEvent(debugEvents, {
159
+ step: step.agent,
160
+ phase: 'post-step',
161
+ action: 'replay-start',
162
+ attempt,
163
+ editedInputs: [...editedInputs],
164
+ });
165
+
166
+ return {
167
+ attempt,
168
+ replayInputs,
169
+ replayInputOverride,
170
+ currentSnapshot,
171
+ };
172
+ }