orcareplay 0.1.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/dist/api.d.ts +127 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +159 -0
- package/dist/api.js.map +1 -0
- package/dist/args.d.ts +24 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +124 -0
- package/dist/args.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/compare.d.ts +27 -0
- package/dist/commands/compare.d.ts.map +1 -0
- package/dist/commands/compare.js +182 -0
- package/dist/commands/compare.js.map +1 -0
- package/dist/commands/doctor.d.ts +24 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +358 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/gc.d.ts +24 -0
- package/dist/commands/gc.d.ts.map +1 -0
- package/dist/commands/gc.js +326 -0
- package/dist/commands/gc.js.map +1 -0
- package/dist/commands/inspect.d.ts +24 -0
- package/dist/commands/inspect.d.ts.map +1 -0
- package/dist/commands/inspect.js +262 -0
- package/dist/commands/inspect.js.map +1 -0
- package/dist/commands/record.d.ts +17 -0
- package/dist/commands/record.d.ts.map +1 -0
- package/dist/commands/record.js +497 -0
- package/dist/commands/record.js.map +1 -0
- package/dist/commands/replay.d.ts +43 -0
- package/dist/commands/replay.d.ts.map +1 -0
- package/dist/commands/replay.js +800 -0
- package/dist/commands/replay.js.map +1 -0
- package/dist/commands/scrub.d.ts +34 -0
- package/dist/commands/scrub.d.ts.map +1 -0
- package/dist/commands/scrub.js +452 -0
- package/dist/commands/scrub.js.map +1 -0
- package/dist/commands/setup.d.ts +23 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +177 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/config.d.ts +80 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +117 -0
- package/dist/config.js.map +1 -0
- package/dist/exchange-events.d.ts +72 -0
- package/dist/exchange-events.d.ts.map +1 -0
- package/dist/exchange-events.js +170 -0
- package/dist/exchange-events.js.map +1 -0
- package/dist/flags.d.ts +10 -0
- package/dist/flags.d.ts.map +1 -0
- package/dist/flags.js +83 -0
- package/dist/flags.js.map +1 -0
- package/dist/fs-events.d.ts +19 -0
- package/dist/fs-events.d.ts.map +1 -0
- package/dist/fs-events.js +53 -0
- package/dist/fs-events.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +282 -0
- package/dist/main.js.map +1 -0
- package/dist/mcp-server.d.ts +44 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +245 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/mcp.d.ts +111 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +221 -0
- package/dist/mcp.js.map +1 -0
- package/dist/out.d.ts +69 -0
- package/dist/out.d.ts.map +1 -0
- package/dist/out.js +138 -0
- package/dist/out.js.map +1 -0
- package/dist/rasterize.d.ts +62 -0
- package/dist/rasterize.d.ts.map +1 -0
- package/dist/rasterize.js +154 -0
- package/dist/rasterize.js.map +1 -0
- package/dist/serial.d.ts +19 -0
- package/dist/serial.d.ts.map +1 -0
- package/dist/serial.js +34 -0
- package/dist/serial.js.map +1 -0
- package/dist/share-card.d.ts +73 -0
- package/dist/share-card.d.ts.map +1 -0
- package/dist/share-card.js +413 -0
- package/dist/share-card.js.map +1 -0
- package/dist/snapshot.d.ts +30 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +43 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/tls-capture.d.ts +57 -0
- package/dist/tls-capture.d.ts.map +1 -0
- package/dist/tls-capture.js +198 -0
- package/dist/tls-capture.js.map +1 -0
- package/dist/upstream.d.ts +20 -0
- package/dist/upstream.d.ts.map +1 -0
- package/dist/upstream.js +31 -0
- package/dist/upstream.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { enclosingDelegation, TraceReader, deriveCheckpoints, resolveRunSelector, ensureRunsDir, snapToCheckpoint, TraceWriter, } from '@orcareplay/core';
|
|
6
|
+
import { FsCapture } from '@orcareplay/fs-capture';
|
|
7
|
+
import { createProxy, defaultDialects } from '@orcareplay/proxy';
|
|
8
|
+
import { defaultAdapters, resolveLaunch } from '@orcareplay/adapters';
|
|
9
|
+
import { serveViewer } from '@orcareplay/viewer';
|
|
10
|
+
import { isBlobRef } from '@orcareplay/schema';
|
|
11
|
+
import { ExchangeEventDeriver, appendDerivedEvents } from '../exchange-events.js';
|
|
12
|
+
import { SerialQueue } from '../serial.js';
|
|
13
|
+
import { appendSnapshot } from '../fs-events.js';
|
|
14
|
+
import { drainMcpFrames, mcpForReplay, pointAtMcpConfig } from '../mcp.js';
|
|
15
|
+
import { setupTlsCapture, trustRunCa } from '../tls-capture.js';
|
|
16
|
+
import { upstreamPlan } from '../upstream.js';
|
|
17
|
+
import { ORCA_VERSION } from '../version.js';
|
|
18
|
+
/**
|
|
19
|
+
* Rebuild the recorded exchanges from a trace.
|
|
20
|
+
*
|
|
21
|
+
* The raw request and response bodies are the authoritative record — they are what makes exact
|
|
22
|
+
* replay exact — so they come back verbatim from the blob store rather than being regenerated
|
|
23
|
+
* from the canonical form.
|
|
24
|
+
*/
|
|
25
|
+
export async function loadExchanges(reader) {
|
|
26
|
+
const events = await reader.events();
|
|
27
|
+
const requests = events.filter((e) => e.type === 'model.request');
|
|
28
|
+
const responses = events.filter((e) => e.type === 'model.response');
|
|
29
|
+
const dialects = defaultDialects();
|
|
30
|
+
const exchanges = [];
|
|
31
|
+
for (const [i, req] of requests.entries()) {
|
|
32
|
+
const res = responses[i];
|
|
33
|
+
const rawRequest = await rawBodyOf(reader, req);
|
|
34
|
+
const rawResponse = res ? await rawBodyOf(reader, res) : '{}';
|
|
35
|
+
const dialectId = String(req.attrs?.dialect ?? 'anthropic');
|
|
36
|
+
const dialect = dialects.find((d) => d.id === dialectId) ?? dialects[0];
|
|
37
|
+
exchanges.push({
|
|
38
|
+
seq: req.seq,
|
|
39
|
+
dialect: dialectId,
|
|
40
|
+
path: String(req.attrs?.path ??
|
|
41
|
+
(dialectId === 'anthropic'
|
|
42
|
+
? '/v1/messages'
|
|
43
|
+
: dialectId === 'codex'
|
|
44
|
+
? '/backend-api/codex/responses'
|
|
45
|
+
: '/v1/chat/completions')),
|
|
46
|
+
rawRequest,
|
|
47
|
+
rawResponse,
|
|
48
|
+
status: Number(res?.attrs?.status ?? 200),
|
|
49
|
+
streamed: Boolean(res?.attrs?.streamed ?? false),
|
|
50
|
+
// The stored body is the provider's wire format, not the canonical form. Canonicalizing it
|
|
51
|
+
// with the same translator the proxy uses at replay time is what makes rung-1 matching
|
|
52
|
+
// possible at all — parsing it directly makes every request look like a major divergence.
|
|
53
|
+
canonicalRequest: dialect.toCanonicalRequest(JSON.parse(rawRequest)),
|
|
54
|
+
durationMs: Number(res?.attrs?.duration_ms ?? 0),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return exchanges;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read a recorded body back as the exact bytes that were sent.
|
|
61
|
+
*
|
|
62
|
+
* The subtlety is the spill boundary. The writer stores `JSON.stringify(payload)` in a blob once a
|
|
63
|
+
* payload crosses `INLINE_PAYLOAD_LIMIT`, and every wire body is a *string* — so the same body is
|
|
64
|
+
* kept as itself while it fits inline and as a quoted, backslash-escaped JSON string literal once
|
|
65
|
+
* it spills. Reading the blob bytes straight back therefore returns an escaped copy: canonicalizing
|
|
66
|
+
* it yields `model: ''` and `messages: []`, every request falls to rung 4, and replay against any
|
|
67
|
+
* real harness matches nothing at all. Undoing the writer's encoding is what makes the two forms
|
|
68
|
+
* identical again.
|
|
69
|
+
*
|
|
70
|
+
* Not `resolvePayload`, which would hand back a parsed object for a body that was recorded as one;
|
|
71
|
+
* exact replay means the agent gets the same bytes, so anything that is not a JSON string is
|
|
72
|
+
* returned verbatim.
|
|
73
|
+
*/
|
|
74
|
+
async function rawBodyOf(reader, event) {
|
|
75
|
+
const payload = event.payload;
|
|
76
|
+
if (isBlobRef(payload)) {
|
|
77
|
+
const text = new TextDecoder().decode(await reader.blob(payload));
|
|
78
|
+
try {
|
|
79
|
+
const decoded = JSON.parse(text);
|
|
80
|
+
return typeof decoded === 'string' ? decoded : text;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Not JSON at all — a blob written by something else, or a future media type. The bytes are
|
|
84
|
+
// still the best answer available.
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (typeof payload === 'string')
|
|
89
|
+
return payload;
|
|
90
|
+
return JSON.stringify(payload ?? {});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The arguments that will make the agent ask again.
|
|
94
|
+
*
|
|
95
|
+
* A run recorded as `orca record claude -- -p "..."` carries its prompt in argv, and replaying it
|
|
96
|
+
* is simply a matter of passing it back. A run driven by hand carries nothing: the prompt went
|
|
97
|
+
* into a terminal, and argv is just `["claude"]`. Replaying that launched an agent with no reason
|
|
98
|
+
* to call anything, so the recorded exchanges were never requested and the replay came back empty.
|
|
99
|
+
*
|
|
100
|
+
* The prompts recorded from the harness's own transcript are what closes that gap. Only the first
|
|
101
|
+
* turn is driven — the harnesses take one prompt per non-interactive invocation — so a multi-turn
|
|
102
|
+
* conversation says how much of itself it is reproducing instead of quietly reproducing one turn
|
|
103
|
+
* and calling it a replay.
|
|
104
|
+
*/
|
|
105
|
+
function driveArgs(adapter, ctx, out) {
|
|
106
|
+
const recorded = ctx.manifest.argv.slice(1);
|
|
107
|
+
const prompts = ctx.prompts;
|
|
108
|
+
if (prompts.length === 0)
|
|
109
|
+
return recorded;
|
|
110
|
+
// Whether argv already drives the run, decided by comparing it against the prompts the harness
|
|
111
|
+
// recorded rather than by looking for a flag. `codex exec` with the prompt on stdin has a
|
|
112
|
+
// non-empty argv that carries no prompt at all, so "argv is non-empty" answers the wrong
|
|
113
|
+
// question; "argv contains what the person actually asked" answers the right one.
|
|
114
|
+
if (prompts.some((prompt) => recorded.includes(prompt)))
|
|
115
|
+
return recorded;
|
|
116
|
+
const driven = adapter.driveArgs?.(prompts, recorded);
|
|
117
|
+
if (driven === undefined)
|
|
118
|
+
return recorded;
|
|
119
|
+
// Said before the run, not after, because it reframes every divergence that follows. A harness
|
|
120
|
+
// driven without a terminal does not send byte-identical requests to the ones it sent with one —
|
|
121
|
+
// Claude Code splices an extra reminder turn into an interactive conversation, and the calls it
|
|
122
|
+
// makes for itself, like naming the session, have no counterpart at all. Those show up as
|
|
123
|
+
// unmatched, and without this line they read as a corrupt trace rather than as the cost of
|
|
124
|
+
// reproducing a conversation nobody recorded the keystrokes of.
|
|
125
|
+
out.info('replay.driven', {
|
|
126
|
+
source: 'harness transcript',
|
|
127
|
+
turns_recorded: prompts.length,
|
|
128
|
+
turns_driven: 1,
|
|
129
|
+
note: prompts.length > 1
|
|
130
|
+
? 'the harness takes one prompt per non-interactive run; later turns are not re-asked'
|
|
131
|
+
: 'requests the harness made for itself may not recur',
|
|
132
|
+
});
|
|
133
|
+
return driven;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Prompts off a `session.snapshot`.
|
|
137
|
+
*
|
|
138
|
+
* The transcript rides in the same payload, so this spills to a blob for any real session — which
|
|
139
|
+
* is why it takes the reader rather than reading the event alone.
|
|
140
|
+
*/
|
|
141
|
+
async function readPrompts(reader, events) {
|
|
142
|
+
const event = events.find((e) => e.type === 'session.snapshot');
|
|
143
|
+
if (event === undefined)
|
|
144
|
+
return [];
|
|
145
|
+
let payload = event.payload;
|
|
146
|
+
try {
|
|
147
|
+
if (isBlobRef(payload)) {
|
|
148
|
+
payload = JSON.parse(new TextDecoder().decode(await reader.blob(payload)));
|
|
149
|
+
}
|
|
150
|
+
if (typeof payload === 'string')
|
|
151
|
+
payload = JSON.parse(payload);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
if (payload === null || typeof payload !== 'object')
|
|
157
|
+
return [];
|
|
158
|
+
const prompts = payload.prompts;
|
|
159
|
+
return Array.isArray(prompts) ? prompts.filter((p) => typeof p === 'string') : [];
|
|
160
|
+
}
|
|
161
|
+
export async function replayCommand(args, out, cwd = process.cwd()) {
|
|
162
|
+
const selector = args.positionals[0] ?? 'last';
|
|
163
|
+
const runDir = (await resolveRunSelector(cwd, selector)).dir;
|
|
164
|
+
const reader = await TraceReader.open(runDir);
|
|
165
|
+
const manifest = reader.manifest();
|
|
166
|
+
const events = await reader.events();
|
|
167
|
+
const integrity = await reader.verifyIntegrity();
|
|
168
|
+
// Two different facts, and saying the wrong one costs trust in the right one. A recorder that was
|
|
169
|
+
// killed leaves a run with no digest at all, and telling that person their trace "changed since
|
|
170
|
+
// the run ended" is an accusation about a file nothing touched.
|
|
171
|
+
if (integrity.state === 'mismatch') {
|
|
172
|
+
out.warn('trace.integrity', {
|
|
173
|
+
expected: integrity.expected,
|
|
174
|
+
actual: integrity.actual,
|
|
175
|
+
note: 'events.jsonl changed since the run ended',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
else if (integrity.state === 'unsealed') {
|
|
179
|
+
out.warn('trace.unsealed', {
|
|
180
|
+
actual: integrity.actual,
|
|
181
|
+
note: 'the recorder never sealed this run, so there is nothing to verify it against',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const exchanges = await loadExchanges(reader);
|
|
185
|
+
const prompts = await readPrompts(reader, events);
|
|
186
|
+
const from = args.num('from');
|
|
187
|
+
const model = args.str('model');
|
|
188
|
+
const isFork = from !== undefined || model !== undefined;
|
|
189
|
+
const result = isFork
|
|
190
|
+
? await replayFork(args, out, {
|
|
191
|
+
manifest,
|
|
192
|
+
events,
|
|
193
|
+
exchanges,
|
|
194
|
+
runDir,
|
|
195
|
+
cwd,
|
|
196
|
+
prompts,
|
|
197
|
+
from,
|
|
198
|
+
model,
|
|
199
|
+
})
|
|
200
|
+
: await replayExact(args, out, { manifest, events, exchanges, runDir, cwd, prompts });
|
|
201
|
+
if (args.bool('ui')) {
|
|
202
|
+
// Show the run you just produced: after a fork that is the child, not the parent, because
|
|
203
|
+
// the child is the one carrying the outcome you asked the question about.
|
|
204
|
+
const target = result.forkRunId
|
|
205
|
+
? (await resolveRunSelector(cwd, result.forkRunId)).dir
|
|
206
|
+
: runDir;
|
|
207
|
+
await openViewer(target, args, out);
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
async function openViewer(runDir, args, out) {
|
|
212
|
+
const server = await serveViewer({ runDir, port: args.num('port') ?? 0 });
|
|
213
|
+
out.phase('viewer', { url: server.url });
|
|
214
|
+
out.plain(' ctrl-c to stop');
|
|
215
|
+
await new Promise((resolve) => {
|
|
216
|
+
process.once('SIGINT', () => void server.close().then(resolve));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Exact replay. Every response comes from the trace and egress is blocked — if the agent reaches
|
|
221
|
+
* for the network here, that is a bug we want to fail on, not paper over.
|
|
222
|
+
*
|
|
223
|
+
* It also restores the filesystem the run started from, and that is not a nicety. A harness reads
|
|
224
|
+
* files into the conversation, so the bytes on disk end up inside the recorded request; the run
|
|
225
|
+
* then edits those same files. Replaying in the directory you recorded in re-reads what the
|
|
226
|
+
* recording itself changed, the trailing message differs, and the replay halts at rung 4 —
|
|
227
|
+
* correctly, but uselessly, because nothing about the recording was wrong. Restoring first is
|
|
228
|
+
* what makes "exact" mean anything, and it has the second virtue of leaving your checkout alone
|
|
229
|
+
* rather than letting the agent edit it a second time.
|
|
230
|
+
*/
|
|
231
|
+
async function replayExact(args, out, ctx) {
|
|
232
|
+
const divergences = [];
|
|
233
|
+
const unmatched = [];
|
|
234
|
+
const workspace = await replayWorkspace(args, out, ctx);
|
|
235
|
+
const trace = await openReplayTrace(args, ctx, workspace.dir);
|
|
236
|
+
// Serial for the same reason the recorder's is: the callbacks fire from the proxy's request
|
|
237
|
+
// handler, and two overlapping appends would interleave lines in events.jsonl.
|
|
238
|
+
const writes = new SerialQueue();
|
|
239
|
+
const plan = await upstreamPlan(args);
|
|
240
|
+
// A subscription-backed harness does not use the ordinary base URL, so exact replay needs the
|
|
241
|
+
// same per-run CA and HTTPS proxy as recording. The proxy's TLS hook then answers Codex's model
|
|
242
|
+
// request from the trace before it can open an origin connection.
|
|
243
|
+
const tls = await setupTlsCapture({
|
|
244
|
+
args,
|
|
245
|
+
out,
|
|
246
|
+
writer: trace,
|
|
247
|
+
runDir: ctx.runDir,
|
|
248
|
+
writes,
|
|
249
|
+
turn: () => 0,
|
|
250
|
+
});
|
|
251
|
+
const proxy = await createProxy({
|
|
252
|
+
mode: 'replay',
|
|
253
|
+
exchanges: ctx.exchanges,
|
|
254
|
+
loose: args.bool('loose'),
|
|
255
|
+
upstream: plan.upstream,
|
|
256
|
+
upstreamHeaders: plan.headers,
|
|
257
|
+
...tls.proxyOptions,
|
|
258
|
+
onDivergence: (d) => {
|
|
259
|
+
divergences.push(d);
|
|
260
|
+
if (!trace)
|
|
261
|
+
return;
|
|
262
|
+
writes.push(async () => {
|
|
263
|
+
await trace.append({
|
|
264
|
+
type: 'divergence',
|
|
265
|
+
actor: 'orca',
|
|
266
|
+
// Turn 0 for everything in this trace, and deliberately not a running count. An exact
|
|
267
|
+
// match produces no callback, so a counter here would number the third divergence as
|
|
268
|
+
// turn 1 and claim a position in the conversation that it does not have. `source_seq`
|
|
269
|
+
// is the honest coordinate: it points straight at the parent's own event.
|
|
270
|
+
turn: 0,
|
|
271
|
+
attrs: { level: d.level, rung: d.rung, detail: d.detail, source_seq: d.seq },
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
// Printed as it happens rather than tallied at the end. A halted replay stops the agent, so
|
|
276
|
+
// the count in `replay.done` arrives after the operator has already seen the run die — and a
|
|
277
|
+
// bare `unmatched=1` with no reason is indistinguishable from a bug in orca itself.
|
|
278
|
+
//
|
|
279
|
+
// The two directories are on the line because a distance in the hundreds of thousands is true
|
|
280
|
+
// and unactionable. Harnesses put absolute paths in their tool calls, so a replay running
|
|
281
|
+
// anywhere but the recording's own directory gets a permission refusal where the recording has
|
|
282
|
+
// file contents — by far the most common cause of a halt, and invisible from the number alone.
|
|
283
|
+
onUnmatched: (u) => {
|
|
284
|
+
unmatched.push(u);
|
|
285
|
+
// A halt inside a delegation is not a corrupt trace and not a bug, and looks like both. The
|
|
286
|
+
// harness writes the delegate's prompt itself, fresh on every run, so the request really is
|
|
287
|
+
// a different question — which is exactly what the matcher refuses to serve from a
|
|
288
|
+
// recording. Without this the operator sees `distance 54` and goes looking for the fault.
|
|
289
|
+
const delegation = enclosingDelegation(ctx.events, u.seq);
|
|
290
|
+
out.warn('replay.unmatched', {
|
|
291
|
+
seq: u.seq,
|
|
292
|
+
index: u.index,
|
|
293
|
+
reason: u.reason,
|
|
294
|
+
...(delegation === undefined
|
|
295
|
+
? {}
|
|
296
|
+
: {
|
|
297
|
+
inside: `${delegation.subagent} delegated at seq ${delegation.seq}`,
|
|
298
|
+
why: 'the harness writes a delegate prompt of its own each run, so this request is a different question rather than a drifted one',
|
|
299
|
+
}),
|
|
300
|
+
recorded_in: ctx.manifest.cwd,
|
|
301
|
+
replayed_in: workspace.dir,
|
|
302
|
+
next: workspace.dir === ctx.manifest.cwd
|
|
303
|
+
? 'orca replay <run> --loose'
|
|
304
|
+
: `cd ${ctx.manifest.cwd} && orca replay <run> --in-place # or --loose to continue live`,
|
|
305
|
+
});
|
|
306
|
+
if (!trace)
|
|
307
|
+
return;
|
|
308
|
+
// `error`, not `divergence`: nothing was served and the run is over, so calling it an
|
|
309
|
+
// inexact match would put a rung on a ladder the request never climbed. It is also the one
|
|
310
|
+
// finding here that exists nowhere else — a matched exchange is already in the parent, but
|
|
311
|
+
// "the agent asked for something this recording cannot answer" is new, and until now it
|
|
312
|
+
// lived only in the operator's scrollback.
|
|
313
|
+
writes.push(async () => {
|
|
314
|
+
await trace.append({
|
|
315
|
+
type: 'error',
|
|
316
|
+
actor: 'orca',
|
|
317
|
+
turn: 0,
|
|
318
|
+
attrs: {
|
|
319
|
+
rule: 'replay_unmatched',
|
|
320
|
+
rung: 4,
|
|
321
|
+
reason: u.reason,
|
|
322
|
+
index: u.index,
|
|
323
|
+
source_seq: u.seq,
|
|
324
|
+
recorded_in: ctx.manifest.cwd,
|
|
325
|
+
replayed_in: workspace.dir,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
await trace?.append({
|
|
332
|
+
type: 'run.start',
|
|
333
|
+
actor: 'orca',
|
|
334
|
+
turn: 0,
|
|
335
|
+
attrs: {
|
|
336
|
+
adapter: ctx.manifest.adapter.id,
|
|
337
|
+
cwd: workspace.dir,
|
|
338
|
+
proxy: proxy.url,
|
|
339
|
+
mode: 'replay',
|
|
340
|
+
// Also on the manifest, which is what an out-of-process reader sees first. Here as well
|
|
341
|
+
// because a trace read on its own should say what it is a replay of.
|
|
342
|
+
parent_run: ctx.manifest.run_id,
|
|
343
|
+
exchanges: ctx.exchanges.length,
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
out.phase('replaying', {
|
|
347
|
+
run: ctx.manifest.run_id,
|
|
348
|
+
exchanges: ctx.exchanges.length,
|
|
349
|
+
egress: 'blocked',
|
|
350
|
+
proxy: proxy.url,
|
|
351
|
+
cwd: workspace.dir,
|
|
352
|
+
});
|
|
353
|
+
// The agent runs live for everything that is not a model call, MCP included. Without a config it
|
|
354
|
+
// talks to servers orca cannot see — or, for a harness that requires the variable, does not start
|
|
355
|
+
// at all, which is how a replay of a working recording exits non-zero for a reason that has
|
|
356
|
+
// nothing to do with the recording.
|
|
357
|
+
const mcp = trace === undefined
|
|
358
|
+
? undefined
|
|
359
|
+
: await mcpForReplay(args, ctx.events, trace, out, join(ctx.runDir, 'mcp-frames.jsonl'));
|
|
360
|
+
const adapter = defaultAdapters().get(ctx.manifest.adapter.id);
|
|
361
|
+
const launch = await adapter.prepare({
|
|
362
|
+
runId: ctx.manifest.run_id,
|
|
363
|
+
cwd: workspace.dir,
|
|
364
|
+
proxyUrl: proxy.url,
|
|
365
|
+
runDir: ctx.runDir,
|
|
366
|
+
userArgs: driveArgs(adapter, ctx, out),
|
|
367
|
+
env: process.env,
|
|
368
|
+
});
|
|
369
|
+
if (proxy.tls)
|
|
370
|
+
await trustRunCa(trace, proxy.tls, proxy.url, launch.env, out);
|
|
371
|
+
if (mcp) {
|
|
372
|
+
pointAtMcpConfig(launch.env, mcp.configPath);
|
|
373
|
+
// Same as record: the path has to reach the harness the way the harness actually reads it, or
|
|
374
|
+
// a replay re-instruments a config nothing opens and every recorded MCP call goes unmatched.
|
|
375
|
+
const mcpArgs = adapter.mcpConfigArgs?.(mcp.configPath);
|
|
376
|
+
if (mcpArgs !== undefined)
|
|
377
|
+
launch.args = [...mcpArgs, ...launch.args];
|
|
378
|
+
}
|
|
379
|
+
let exitCode;
|
|
380
|
+
try {
|
|
381
|
+
exitCode = await runChild(launch.command, launch.args, { ...process.env, ...launch.env }, workspace.dir, args.bool('json'));
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
// In a finally because the whole justification for restoring over the working tree is that it
|
|
385
|
+
// is put back — a throw between here and there would leave someone's checkout holding a
|
|
386
|
+
// recorded run's files.
|
|
387
|
+
await workspace.release();
|
|
388
|
+
await tls.ca?.dispose();
|
|
389
|
+
}
|
|
390
|
+
await writes.drain();
|
|
391
|
+
const stats = proxy.stats();
|
|
392
|
+
await proxy.close();
|
|
393
|
+
for (const d of divergences) {
|
|
394
|
+
out.warn('divergence', { seq: d.seq, level: d.level, detail: d.detail });
|
|
395
|
+
}
|
|
396
|
+
// A halted replay is a failed replay even if the harness chose to exit 0 on the error. The exit
|
|
397
|
+
// code is what a script reads, so it has to reflect what happened rather than what the agent
|
|
398
|
+
// decided to do about it — and the trace records the same verdict for the same reason.
|
|
399
|
+
const verdict = exitCode === 0 && unmatched.length > 0 ? 1 : exitCode;
|
|
400
|
+
if (trace) {
|
|
401
|
+
// What the replay discovered rather than repeated: these calls really happened just now, and
|
|
402
|
+
// exist nowhere in the parent.
|
|
403
|
+
if (mcp)
|
|
404
|
+
await drainMcpFrames(mcp, trace, () => 0, 0);
|
|
405
|
+
await trace.append({
|
|
406
|
+
type: 'run.end',
|
|
407
|
+
actor: 'orca',
|
|
408
|
+
turn: 0,
|
|
409
|
+
attrs: {
|
|
410
|
+
exit_code: verdict,
|
|
411
|
+
agent_exit_code: exitCode,
|
|
412
|
+
matched: stats.matchedExact,
|
|
413
|
+
divergences: stats.divergences,
|
|
414
|
+
unmatched: stats.unmatched,
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
await trace.close(verdict);
|
|
418
|
+
}
|
|
419
|
+
out.phase('replay.done', {
|
|
420
|
+
// `matched=1 total=13` was the old shape, and on a healthy replay of a real harness it read as
|
|
421
|
+
// a failure: rung 1 is only reachable when nothing in the request was redacted, so a run whose
|
|
422
|
+
// every request was served from disk still reported one match. What someone wants to know here
|
|
423
|
+
// is how much of the recording was reused, and how much of that reuse was exact.
|
|
424
|
+
reused: `${stats.matchedExact + stats.matchedInexact}/${ctx.exchanges.length}`,
|
|
425
|
+
exact: stats.matchedExact,
|
|
426
|
+
divergences: stats.divergences,
|
|
427
|
+
unmatched: stats.unmatched,
|
|
428
|
+
exit: exitCode,
|
|
429
|
+
// Omitted entirely under --no-trace: `Output` drops undefined fields, so the line stays the
|
|
430
|
+
// shape it has always been for anyone who opted out.
|
|
431
|
+
trace: trace?.runId,
|
|
432
|
+
});
|
|
433
|
+
return {
|
|
434
|
+
runId: ctx.manifest.run_id,
|
|
435
|
+
mode: 'exact',
|
|
436
|
+
...(trace === undefined ? {} : { traceRunId: trace.runId }),
|
|
437
|
+
matchedExact: stats.matchedExact,
|
|
438
|
+
divergences: stats.divergences,
|
|
439
|
+
unmatched: stats.unmatched,
|
|
440
|
+
liveCalls: stats.liveCalls,
|
|
441
|
+
exitCode: verdict,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The run an exact replay writes about itself, or nothing under `--no-trace`.
|
|
446
|
+
*
|
|
447
|
+
* Spec §4 says every inexact match is an event in the trace, and until now the exact path had no
|
|
448
|
+
* trace to put one in: divergences were printed and discarded with the scrollback. They cannot go
|
|
449
|
+
* into the run being replayed — it is append-only and its manifest carries a digest over
|
|
450
|
+
* events.jsonl, so a single line appended would invalidate every verification anyone had done of
|
|
451
|
+
* it — so the replay becomes a run of its own, pointing back at its subject through `parent_run`.
|
|
452
|
+
* That one field is what `orca list`, `orca show` and `orca gc` already read, which is why it is
|
|
453
|
+
* on the manifest and not only in an event: gc uses it to refuse to delete a run something else
|
|
454
|
+
* still points at.
|
|
455
|
+
*
|
|
456
|
+
* There is no `fork_point`. An exact replay does not branch anywhere, and a fabricated checkpoint
|
|
457
|
+
* would be a number `orca list` prints as though someone had chosen it.
|
|
458
|
+
*
|
|
459
|
+
* What it deliberately does NOT record is the exchanges it served. Every one of them was read out
|
|
460
|
+
* of the parent's own events.jsonl and handed back byte for byte, so copying them here would
|
|
461
|
+
* duplicate the trace's single largest cost — the conversation bodies, blobs and all — to store a
|
|
462
|
+
* second copy that is identical by construction, and would make `orca gc` report a store twice the
|
|
463
|
+
* size for one recording's worth of content. An unmatched request is the opposite case: it is
|
|
464
|
+
* something the agent asked that the recording never contained, so it exists nowhere else and is
|
|
465
|
+
* written as an `error` event above. The rule is that this trace holds what replaying *discovered*,
|
|
466
|
+
* and points at the parent for what replaying merely repeated.
|
|
467
|
+
*/
|
|
468
|
+
async function openReplayTrace(args, ctx, cwd) {
|
|
469
|
+
if (!args.bool('trace', true))
|
|
470
|
+
return undefined;
|
|
471
|
+
const dir = await ensureRunsDir(ctx.cwd);
|
|
472
|
+
return TraceWriter.create(dir, {
|
|
473
|
+
adapter: ctx.manifest.adapter,
|
|
474
|
+
argv: ctx.manifest.argv,
|
|
475
|
+
// Where the replay actually ran, which is not always where the recording did: `--worktree`
|
|
476
|
+
// puts it in a scratch copy, and `orca gc` reads exactly this to decide whether the directory
|
|
477
|
+
// is one of ours to reclaim.
|
|
478
|
+
cwd,
|
|
479
|
+
orcaVersion: ORCA_VERSION,
|
|
480
|
+
parentRun: ctx.manifest.run_id,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Fork replay. Replay deterministically to a checkpoint, materialize the filesystem as it was at
|
|
485
|
+
* that moment, then let the agent continue live — optionally on a different model.
|
|
486
|
+
*/
|
|
487
|
+
async function replayFork(args, out, ctx) {
|
|
488
|
+
const checkpoints = deriveCheckpoints(ctx.events);
|
|
489
|
+
if (checkpoints.length === 0) {
|
|
490
|
+
throw new Error('this run has no checkpoints, so there is nothing to fork from\n' +
|
|
491
|
+
' checkpoints need a filesystem snapshot; was the run recorded with --no-fs?');
|
|
492
|
+
}
|
|
493
|
+
const target = ctx.from ?? checkpoints[checkpoints.length - 1].seq;
|
|
494
|
+
const { checkpoint, snapped } = snapToCheckpoint(checkpoints, target);
|
|
495
|
+
if (snapped) {
|
|
496
|
+
// Saying so matters: silently forking from different state than the user asked for is the
|
|
497
|
+
// worst failure mode this tool has.
|
|
498
|
+
out.warn('fork.snapped', {
|
|
499
|
+
requested: target,
|
|
500
|
+
using: checkpoint.seq,
|
|
501
|
+
why: 'nearest preceding checkpoint',
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
// Exchanges are indexed by position, so the fork point in exchange terms is how many model
|
|
505
|
+
// requests happened at or before the checkpoint.
|
|
506
|
+
const forkAt = ctx.exchanges.filter((e) => e.seq <= checkpoint.seq).length;
|
|
507
|
+
const worktree = await mkdtemp(join(tmpdir(), `orca-${checkpoint.seq}-`));
|
|
508
|
+
if (checkpoint.fsTree) {
|
|
509
|
+
// Restore from the ORIGINAL run's shadow store: that is the only place the tree object
|
|
510
|
+
// exists. Pointing a fresh store at the tree id fails to unpack it, which is exactly the
|
|
511
|
+
// silent-wrong-state failure the checkpoint machinery is meant to prevent.
|
|
512
|
+
const fs = await FsCapture.start({ runDir: ctx.runDir, cwd: ctx.cwd });
|
|
513
|
+
await fs.restore(checkpoint.fsTree, worktree);
|
|
514
|
+
}
|
|
515
|
+
const dir = await ensureRunsDir(ctx.cwd);
|
|
516
|
+
const writer = await TraceWriter.create(dir, {
|
|
517
|
+
adapter: ctx.manifest.adapter,
|
|
518
|
+
argv: ctx.manifest.argv,
|
|
519
|
+
cwd: worktree,
|
|
520
|
+
orcaVersion: ORCA_VERSION,
|
|
521
|
+
// Also in the manifest, not only the fork event: the manifest is what an out-of-process
|
|
522
|
+
// reader sees first, and `orca gc` uses it to decide a parent run may not be deleted.
|
|
523
|
+
parentRun: ctx.manifest.run_id,
|
|
524
|
+
forkPoint: checkpoint.seq,
|
|
525
|
+
...(ctx.model === undefined ? {} : { forkModel: ctx.model }),
|
|
526
|
+
});
|
|
527
|
+
const deriver = new ExchangeEventDeriver();
|
|
528
|
+
let turn = 0;
|
|
529
|
+
// When each turn began, so an MCP frame drained after the agent exits is attributed to the turn
|
|
530
|
+
// it happened during rather than to whichever turn happened to be last.
|
|
531
|
+
const turnStartedAt = [];
|
|
532
|
+
const turnAtFork = (at) => {
|
|
533
|
+
let found = 0;
|
|
534
|
+
for (const mark of turnStartedAt) {
|
|
535
|
+
if (mark.at > at)
|
|
536
|
+
break;
|
|
537
|
+
found = mark.turn;
|
|
538
|
+
}
|
|
539
|
+
return found;
|
|
540
|
+
};
|
|
541
|
+
const writes = new SerialQueue();
|
|
542
|
+
/**
|
|
543
|
+
* The fork's own filesystem capture, over its worktree.
|
|
544
|
+
*
|
|
545
|
+
* A fork had none, so its trace carried no `fs.snapshot` — and a checkpoint is derived from a
|
|
546
|
+
* snapshot (spec §3), so a fork had no checkpoints and could not itself be forked. `orca compare
|
|
547
|
+
* last` immediately after a fork failed with "this run has no checkpoints", because `last` had
|
|
548
|
+
* resolved to the fork. The tool is pitched on iterative exploration; a branch you cannot branch
|
|
549
|
+
* again is a dead end one step in.
|
|
550
|
+
*
|
|
551
|
+
* Same posture as recording: a capture layer that will not start degrades the trace and never
|
|
552
|
+
* stops the run.
|
|
553
|
+
*/
|
|
554
|
+
let forkFs;
|
|
555
|
+
if (args.bool('fs', true)) {
|
|
556
|
+
try {
|
|
557
|
+
forkFs = await FsCapture.start({ runDir: writer.runDir, cwd: worktree });
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
out.warn('fs.unavailable', { reason: String(err) });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const plan = await upstreamPlan(args);
|
|
564
|
+
// A fork runs a real agent live, so it has exactly the same blind spot `orca record` does: a
|
|
565
|
+
// harness that talks to its own backend over TLS reads no base-URL variable and is invisible
|
|
566
|
+
// without interception. The flag was parsed here and silently discarded, which is the worse
|
|
567
|
+
// half — the operator believes they captured that traffic.
|
|
568
|
+
const tls = await setupTlsCapture({ args, out, writer, writes, turn: () => turn });
|
|
569
|
+
// A fork continues the run live past the checkpoint, so its MCP traffic is new and belongs in the
|
|
570
|
+
// fork's own trace. Without this the layer simply stopped at the fork point.
|
|
571
|
+
const mcp = await mcpForReplay(args, ctx.events, writer, out);
|
|
572
|
+
const proxy = await createProxy({
|
|
573
|
+
mode: 'hybrid',
|
|
574
|
+
forkAt,
|
|
575
|
+
forkModel: ctx.model,
|
|
576
|
+
exchanges: ctx.exchanges,
|
|
577
|
+
upstream: plan.upstream,
|
|
578
|
+
upstreamHeaders: plan.headers,
|
|
579
|
+
...tls.proxyOptions,
|
|
580
|
+
onExchange: (exchange) => {
|
|
581
|
+
writes.push(async () => {
|
|
582
|
+
turn += 1;
|
|
583
|
+
turnStartedAt.push({ turn, at: Date.now() });
|
|
584
|
+
await appendDerivedEvents(writer, deriver, exchange, turn);
|
|
585
|
+
if (forkFs)
|
|
586
|
+
await appendSnapshot(forkFs, writer, out, turn);
|
|
587
|
+
});
|
|
588
|
+
},
|
|
589
|
+
onRoute: (decision) => {
|
|
590
|
+
writes.push(async () => {
|
|
591
|
+
await writer.append({
|
|
592
|
+
type: 'route.decision',
|
|
593
|
+
// On a fork the gateway is orca: it substituted the model and picked what serves it.
|
|
594
|
+
actor: 'gateway',
|
|
595
|
+
turn,
|
|
596
|
+
attrs: { ...decision },
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
onDivergence: (d) => {
|
|
601
|
+
writes.push(async () => {
|
|
602
|
+
await writer.append({
|
|
603
|
+
type: 'divergence',
|
|
604
|
+
actor: 'orca',
|
|
605
|
+
turn,
|
|
606
|
+
attrs: { level: d.level, rung: d.rung, detail: d.detail, source_seq: d.seq },
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
await writer.append({
|
|
612
|
+
type: 'fork',
|
|
613
|
+
actor: 'orca',
|
|
614
|
+
turn: 0,
|
|
615
|
+
attrs: {
|
|
616
|
+
parent_run: ctx.manifest.run_id,
|
|
617
|
+
fork_point: checkpoint.seq,
|
|
618
|
+
model: ctx.model ?? ctx.manifest.adapter.id,
|
|
619
|
+
worktree,
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
// The state the fork starts from, so its first checkpoint is the checkpoint it branched at
|
|
623
|
+
// rather than whatever the first live turn happened to leave behind.
|
|
624
|
+
if (forkFs)
|
|
625
|
+
await appendSnapshot(forkFs, writer, out, 0, { initial: true });
|
|
626
|
+
out.phase('forked', {
|
|
627
|
+
from: ctx.manifest.run_id,
|
|
628
|
+
at: checkpoint.seq,
|
|
629
|
+
run: writer.runId,
|
|
630
|
+
model: ctx.model ?? '(unchanged)',
|
|
631
|
+
worktree,
|
|
632
|
+
});
|
|
633
|
+
const adapter = defaultAdapters().get(ctx.manifest.adapter.id);
|
|
634
|
+
const launch = await adapter.prepare({
|
|
635
|
+
runId: writer.runId,
|
|
636
|
+
cwd: worktree,
|
|
637
|
+
proxyUrl: proxy.url,
|
|
638
|
+
runDir: writer.runDir,
|
|
639
|
+
userArgs: driveArgs(adapter, ctx, out),
|
|
640
|
+
env: process.env,
|
|
641
|
+
});
|
|
642
|
+
if (proxy.tls) {
|
|
643
|
+
await trustRunCa(writer, proxy.tls, proxy.url, launch.env, out);
|
|
644
|
+
}
|
|
645
|
+
if (mcp) {
|
|
646
|
+
pointAtMcpConfig(launch.env, mcp.configPath);
|
|
647
|
+
// Same as record: the path has to reach the harness the way the harness actually reads it, or
|
|
648
|
+
// a replay re-instruments a config nothing opens and every recorded MCP call goes unmatched.
|
|
649
|
+
const mcpArgs = adapter.mcpConfigArgs?.(mcp.configPath);
|
|
650
|
+
if (mcpArgs !== undefined)
|
|
651
|
+
launch.args = [...mcpArgs, ...launch.args];
|
|
652
|
+
}
|
|
653
|
+
let exitCode;
|
|
654
|
+
try {
|
|
655
|
+
exitCode = await runChild(launch.command, launch.args, { ...process.env, ...launch.env }, worktree, args.bool('json'));
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
// The same two failures `orca record` had. A listening proxy keeps Node's event loop alive, so
|
|
659
|
+
// a throw here printed the error and then hung; and a fork that mints a certificate authority
|
|
660
|
+
// must not leave the private key on disk when it dies. The trace is sealed either way, because
|
|
661
|
+
// a fork that failed to launch is still a fork someone will want to read.
|
|
662
|
+
await proxy.close().catch(() => undefined);
|
|
663
|
+
await writes.drain().catch(() => undefined);
|
|
664
|
+
await tls.ca?.dispose().catch(() => undefined);
|
|
665
|
+
await writer
|
|
666
|
+
.append({ type: 'run.end', actor: 'orca', turn, attrs: { error: String(err) } })
|
|
667
|
+
.catch(() => undefined);
|
|
668
|
+
await writer.close().catch(() => undefined);
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
await writes.drain();
|
|
672
|
+
if (mcp)
|
|
673
|
+
await drainMcpFrames(mcp, writer, turnAtFork, turn);
|
|
674
|
+
const stats = proxy.stats();
|
|
675
|
+
await writer.append({ type: 'run.end', actor: 'orca', turn, attrs: { exit_code: exitCode } });
|
|
676
|
+
const manifest = await writer.close(exitCode);
|
|
677
|
+
await proxy.close();
|
|
678
|
+
await tls.ca?.dispose();
|
|
679
|
+
out.phase('fork.done', {
|
|
680
|
+
run: writer.runId,
|
|
681
|
+
replayed: forkAt,
|
|
682
|
+
live: stats.liveCalls,
|
|
683
|
+
divergences: stats.divergences,
|
|
684
|
+
events: manifest.counts?.events ?? writer.seq,
|
|
685
|
+
exit: exitCode,
|
|
686
|
+
});
|
|
687
|
+
return {
|
|
688
|
+
runId: ctx.manifest.run_id,
|
|
689
|
+
traceRunId: writer.runId,
|
|
690
|
+
forkRunId: writer.runId,
|
|
691
|
+
worktree,
|
|
692
|
+
mode: 'fork',
|
|
693
|
+
matchedExact: stats.matchedExact,
|
|
694
|
+
divergences: stats.divergences,
|
|
695
|
+
unmatched: stats.unmatched,
|
|
696
|
+
liveCalls: stats.liveCalls,
|
|
697
|
+
exitCode,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
const noRelease = async () => { };
|
|
701
|
+
/**
|
|
702
|
+
* Prepare the filesystem an exact replay needs.
|
|
703
|
+
*
|
|
704
|
+
* Two facts decide this, and they pull against each other. A harness reads files into the
|
|
705
|
+
* conversation, so the bytes on disk are inside the recorded request and replaying against a
|
|
706
|
+
* directory the recording itself edited produces a different request. And a harness writes
|
|
707
|
+
* *absolute* paths into its tool calls, so replaying a copy of that directory somewhere else makes
|
|
708
|
+
* the agent read outside its working directory — where it gets a permission refusal in place of
|
|
709
|
+
* the file, which diverges just as badly. Measured on a real Claude Code run: a scratch copy
|
|
710
|
+
* halted at the first tool result; the same trace restored at its own path replayed all six
|
|
711
|
+
* exchanges with nothing unmatched.
|
|
712
|
+
*
|
|
713
|
+
* So the default restores the recorded state *over the working tree*, at the path the run was
|
|
714
|
+
* recorded in. That is only defensible because it is reversible: the current tree is snapshotted
|
|
715
|
+
* into a scratch store first and put back in a `finally`, so a replay is observationally a no-op
|
|
716
|
+
* on your checkout, and the snapshot id is printed before anything is touched in case the process
|
|
717
|
+
* is killed in between.
|
|
718
|
+
*
|
|
719
|
+
* Three ways out. `--worktree` replays in a scratch copy and never touches your files, at the cost
|
|
720
|
+
* of the divergence above. `--in-place` uses the tree exactly as it stands, restoring nothing.
|
|
721
|
+
* And a replay invoked from somewhere other than the directory the run was recorded in never
|
|
722
|
+
* restores, because writing a recorded tree over an unrelated directory is not a thing to do by
|
|
723
|
+
* default.
|
|
724
|
+
*/
|
|
725
|
+
async function replayWorkspace(args, out, ctx) {
|
|
726
|
+
if (args.bool('in-place'))
|
|
727
|
+
return { dir: ctx.cwd, release: noRelease };
|
|
728
|
+
const initial = deriveCheckpoints(ctx.events).find((c) => c.fsTree !== undefined);
|
|
729
|
+
if (!initial?.fsTree) {
|
|
730
|
+
out.warn('replay.in-place', {
|
|
731
|
+
why: 'this run has no filesystem snapshot to restore',
|
|
732
|
+
note: 'recorded with --no-fs; a file the run read may since have changed',
|
|
733
|
+
});
|
|
734
|
+
return { dir: ctx.cwd, release: noRelease };
|
|
735
|
+
}
|
|
736
|
+
const recorded = await FsCapture.start({ runDir: ctx.runDir, cwd: ctx.cwd });
|
|
737
|
+
if (args.bool('worktree')) {
|
|
738
|
+
const worktree = await mkdtemp(join(tmpdir(), `orca-replay-${ctx.manifest.run_id}-`));
|
|
739
|
+
await recorded.restore(initial.fsTree, worktree);
|
|
740
|
+
return { dir: worktree, release: noRelease };
|
|
741
|
+
}
|
|
742
|
+
if (resolve(ctx.cwd) !== resolve(ctx.manifest.cwd)) {
|
|
743
|
+
out.warn('replay.elsewhere', {
|
|
744
|
+
recorded_in: ctx.manifest.cwd,
|
|
745
|
+
running_in: ctx.cwd,
|
|
746
|
+
note: 'not restoring over a directory the run was not recorded in; use --worktree for a copy',
|
|
747
|
+
});
|
|
748
|
+
return { dir: ctx.cwd, release: noRelease };
|
|
749
|
+
}
|
|
750
|
+
// A store of its own, under the OS temp dir: the safety snapshot is scratch, and writing it into
|
|
751
|
+
// the trace's shadow store would leave an object in a recorded run that nothing references.
|
|
752
|
+
const scratch = await mkdtemp(join(tmpdir(), 'orca-safety-'));
|
|
753
|
+
const safety = await FsCapture.start({ runDir: scratch, cwd: ctx.cwd });
|
|
754
|
+
const before = await safety.snapshotTurn(0);
|
|
755
|
+
out.info('replay.restored', {
|
|
756
|
+
to: initial.fsTree,
|
|
757
|
+
your_tree: before.tree,
|
|
758
|
+
note: 'your files are restored when the replay ends',
|
|
759
|
+
});
|
|
760
|
+
await recorded.restore(initial.fsTree, ctx.cwd);
|
|
761
|
+
return {
|
|
762
|
+
dir: ctx.cwd,
|
|
763
|
+
release: async () => {
|
|
764
|
+
await safety.restore(before.tree, ctx.cwd);
|
|
765
|
+
// Only after the restore succeeded. This store holds the only copy of your working tree as
|
|
766
|
+
// it was before the replay overwrote it, so removing it on the failure path would delete the
|
|
767
|
+
// thing the failure means you still need — better a directory to clean up by hand than the
|
|
768
|
+
// one that had your uncommitted work in it.
|
|
769
|
+
//
|
|
770
|
+
// Left behind on every run until now: a whole workspace per `orca replay`, in a directory
|
|
771
|
+
// `orca gc` deliberately will not touch because it only reclaims scratch worktrees belonging
|
|
772
|
+
// to forks. Owning its lifetime here is the fix; teaching gc to delete unknown temp
|
|
773
|
+
// directories is how gc ends up removing someone's work.
|
|
774
|
+
await rm(scratch, { recursive: true, force: true });
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Launch the agent for a replay or a fork.
|
|
780
|
+
*
|
|
781
|
+
* `quietStdout` is for `--json`: orca's stdout is the result document there, so the replayed
|
|
782
|
+
* agent's own output moves to stderr rather than landing in the middle of it. stdin and stderr
|
|
783
|
+
* stay inherited, so a harness that prompts still can.
|
|
784
|
+
*/
|
|
785
|
+
async function runChild(command, argv, env, cwd = process.cwd(), quietStdout = false) {
|
|
786
|
+
// Same resolution as record and as detection; see resolveLaunch.
|
|
787
|
+
const target = await resolveLaunch(command, argv);
|
|
788
|
+
return new Promise((resolve, reject) => {
|
|
789
|
+
const child = spawn(target.file, target.args, {
|
|
790
|
+
env,
|
|
791
|
+
cwd,
|
|
792
|
+
shell: target.shell,
|
|
793
|
+
stdio: quietStdout ? ['inherit', 'pipe', 'inherit'] : 'inherit',
|
|
794
|
+
});
|
|
795
|
+
child.stdout?.pipe(process.stderr);
|
|
796
|
+
child.on('error', (err) => reject(new Error(`could not launch "${command}": ${String(err)}\n is it on your PATH?`)));
|
|
797
|
+
child.on('close', (code) => resolve(code ?? 0));
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
//# sourceMappingURL=replay.js.map
|