orcareplay 0.2.2 → 0.2.4
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/args.d.ts +33 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +41 -5
- package/dist/args.js.map +1 -1
- package/dist/commands/attach.js +15 -4
- package/dist/commands/attach.js.map +1 -1
- package/dist/commands/inspect.d.ts +11 -0
- package/dist/commands/inspect.d.ts.map +1 -1
- package/dist/commands/inspect.js +43 -0
- package/dist/commands/inspect.js.map +1 -1
- package/dist/commands/quickstart.d.ts +48 -0
- package/dist/commands/quickstart.d.ts.map +1 -0
- package/dist/commands/quickstart.js +373 -0
- package/dist/commands/quickstart.js.map +1 -0
- package/dist/commands/record.d.ts +13 -0
- package/dist/commands/record.d.ts.map +1 -1
- package/dist/commands/record.js +119 -8
- package/dist/commands/record.js.map +1 -1
- package/dist/commands/replay.d.ts.map +1 -1
- package/dist/commands/replay.js +439 -283
- package/dist/commands/replay.js.map +1 -1
- package/dist/commands/scrub.d.ts.map +1 -1
- package/dist/commands/scrub.js +182 -47
- package/dist/commands/scrub.js.map +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +67 -4
- package/dist/commands/setup.js.map +1 -1
- package/dist/exchange-events.d.ts.map +1 -1
- package/dist/exchange-events.js +23 -0
- package/dist/exchange-events.js.map +1 -1
- package/dist/flags.d.ts.map +1 -1
- package/dist/flags.js +68 -2
- package/dist/flags.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +32 -0
- package/dist/main.js.map +1 -1
- package/dist/tls-capture.d.ts +10 -2
- package/dist/tls-capture.d.ts.map +1 -1
- package/dist/tls-capture.js +41 -3
- package/dist/tls-capture.js.map +1 -1
- package/dist/upstream.d.ts.map +1 -1
- package/dist/upstream.js +17 -0
- package/dist/upstream.js.map +1 -1
- package/package.json +13 -12
- package/quickstart/project/agent.mjs +128 -0
- package/quickstart/project/package.json +8 -0
- package/quickstart/project/src/schedule.js +41 -0
- package/quickstart/project/test/schedule.test.js +25 -0
- package/quickstart/trace/blobs/f7/f7c1592306093f65f441d2afd5f496b7635b7e18f5c15f55b11f71f0b552a118 +1 -0
- package/quickstart/trace/events.jsonl +19 -0
- package/quickstart/trace/fs/HEAD +1 -0
- package/quickstart/trace/fs/config +11 -0
- package/quickstart/trace/fs/description +1 -0
- package/quickstart/trace/fs/index +0 -0
- package/quickstart/trace/fs/info/exclude +11 -0
- package/quickstart/trace/fs/objects/1b/1f956f306fe2c66611dd9e280e7fd2dce1ecda +0 -0
- package/quickstart/trace/fs/objects/1c/c77eaef0608e088283cb62876b80be9473edce +0 -0
- package/quickstart/trace/fs/objects/20/0c1f7a4be93b037ff27cea57853a738fcbf744 +0 -0
- package/quickstart/trace/fs/objects/20/28a902b70fff80fe02a8aaaf30005fd91bafb7 +0 -0
- package/quickstart/trace/fs/objects/34/522daa46d0e3f8db09ec7d874fa6f05cbcdabf +0 -0
- package/quickstart/trace/fs/objects/48/a7bdafdaae1c53a354c7bd06c740719e1cb255 +0 -0
- package/quickstart/trace/fs/objects/80/13b71377c59b728861dd8eec39a919387a4b81 +0 -0
- package/quickstart/trace/fs/objects/9f/db6ace4a4410e535a2aef50e653e46a4c3694a +0 -0
- package/quickstart/trace/fs/objects/a4/371e26fb48bece62891a54e4374895fba69f0d +0 -0
- package/quickstart/trace/fs/objects/d3/3088476a7db5aef2dfd6c839d16d2b2ccaca40 +0 -0
- package/quickstart/trace/manifest.json +43 -0
- package/quickstart/trace/redactions.json +4 -0
- package/quickstart/trace/shell-frames.jsonl +0 -0
package/dist/commands/replay.js
CHANGED
|
@@ -12,7 +12,7 @@ import { ExchangeEventDeriver, appendDerivedEvents } from '../exchange-events.js
|
|
|
12
12
|
import { SerialQueue } from '../serial.js';
|
|
13
13
|
import { appendSnapshot } from '../fs-events.js';
|
|
14
14
|
import { drainMcpFrames, mcpForReplay, pointAtMcpConfig } from '../mcp.js';
|
|
15
|
-
import { recordedTlsHosts, setupTlsCapture, trustRunCa } from '../tls-capture.js';
|
|
15
|
+
import { planTlsCapture, recordedTlsHosts, setupTlsCapture, trustRunCa } from '../tls-capture.js';
|
|
16
16
|
import { upstreamPlan } from '../upstream.js';
|
|
17
17
|
import { ORCA_VERSION } from '../version.js';
|
|
18
18
|
/**
|
|
@@ -229,176 +229,80 @@ async function openViewer(runDir, args, out) {
|
|
|
229
229
|
* rather than letting the agent edit it a second time.
|
|
230
230
|
*/
|
|
231
231
|
async function replayExact(args, out, ctx) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
232
|
+
// Before `replayWorkspace`, because these refuse and `replayWorkspace` replaces the working
|
|
233
|
+
// tree. Resolved after it, a refusal threw with the checkout holding the recorded run's files
|
|
234
|
+
// and the operator's own tree left behind in an `orca-safety-*` scratch whose path nothing
|
|
235
|
+
// prints — the outcome the release exists to prevent, escaping it because it only ran at the
|
|
236
|
+
// child's launch. An invocation that cannot work should decide so before it touches anything,
|
|
237
|
+
// which is the rule `attach` already follows where it resolves the advertised URL before making
|
|
238
|
+
// a run directory.
|
|
239
|
+
//
|
|
240
|
+
// Two refusals, not one. `upstreamPlan` rejects an upstream that is not an origin;
|
|
241
|
+
// `planTlsCapture` rejects a `--tls-hosts` list that names `*` or contradicts itself, and an
|
|
242
|
+
// `ORCA_TLS_UPSTREAM_CA` that cannot be read. It is the refusing half of `setupTlsCapture` on
|
|
243
|
+
// its own — it mints nothing, so the certificate authority is still created down where the run
|
|
244
|
+
// is, after the workspace exists.
|
|
239
245
|
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
246
|
// A run recorded through interception is reproduced through it, without the operator having to
|
|
244
247
|
// remember which hosts they named. See `setupTlsCapture`.
|
|
245
248
|
const interceptedHosts = recordedTlsHosts(ctx.events);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
turn: () => 0,
|
|
254
|
-
});
|
|
255
|
-
const proxy = await createProxy({
|
|
256
|
-
mode: 'replay',
|
|
257
|
-
exchanges: ctx.exchanges,
|
|
258
|
-
loose: args.bool('loose'),
|
|
259
|
-
upstream: plan.upstream,
|
|
260
|
-
upstreamHeaders: plan.headers,
|
|
261
|
-
upstreamHeadersOrigin: plan.headersOrigin,
|
|
262
|
-
...tls.proxyOptions,
|
|
263
|
-
onDivergence: (d) => {
|
|
264
|
-
divergences.push(d);
|
|
265
|
-
if (!trace)
|
|
266
|
-
return;
|
|
267
|
-
writes.push(async () => {
|
|
268
|
-
await trace.append({
|
|
269
|
-
type: 'divergence',
|
|
270
|
-
actor: 'orca',
|
|
271
|
-
// Turn 0 for everything in this trace, and deliberately not a running count. An exact
|
|
272
|
-
// match produces no callback, so a counter here would number the third divergence as
|
|
273
|
-
// turn 1 and claim a position in the conversation that it does not have. `source_seq`
|
|
274
|
-
// is the honest coordinate: it points straight at the parent's own event.
|
|
275
|
-
turn: 0,
|
|
276
|
-
attrs: { level: d.level, rung: d.rung, detail: d.detail, source_seq: d.seq },
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
},
|
|
280
|
-
// Printed as it happens rather than tallied at the end. A halted replay stops the agent, so
|
|
281
|
-
// the count in `replay.done` arrives after the operator has already seen the run die — and a
|
|
282
|
-
// bare `unmatched=1` with no reason is indistinguishable from a bug in orca itself.
|
|
283
|
-
//
|
|
284
|
-
// The two directories are on the line because a distance in the hundreds of thousands is true
|
|
285
|
-
// and unactionable. Harnesses put absolute paths in their tool calls, so a replay running
|
|
286
|
-
// anywhere but the recording's own directory gets a permission refusal where the recording has
|
|
287
|
-
// file contents — by far the most common cause of a halt, and invisible from the number alone.
|
|
288
|
-
onUnmatched: (u) => {
|
|
289
|
-
unmatched.push(u);
|
|
290
|
-
// A halt inside a delegation is not a corrupt trace and not a bug, and looks like both. The
|
|
291
|
-
// harness writes the delegate's prompt itself, fresh on every run, so the request really is
|
|
292
|
-
// a different question — which is exactly what the matcher refuses to serve from a
|
|
293
|
-
// recording. Without this the operator sees `distance 54` and goes looking for the fault.
|
|
294
|
-
const delegation = enclosingDelegation(ctx.events, u.seq);
|
|
295
|
-
out.warn('replay.unmatched', {
|
|
296
|
-
seq: u.seq,
|
|
297
|
-
index: u.index,
|
|
298
|
-
reason: u.reason,
|
|
299
|
-
...(delegation === undefined
|
|
300
|
-
? {}
|
|
301
|
-
: {
|
|
302
|
-
inside: `${delegation.subagent} delegated at seq ${delegation.seq}`,
|
|
303
|
-
why: 'the harness writes a delegate prompt of its own each run, so this request is a different question rather than a drifted one',
|
|
304
|
-
}),
|
|
305
|
-
recorded_in: ctx.manifest.cwd,
|
|
306
|
-
replayed_in: workspace.dir,
|
|
307
|
-
next: workspace.dir === ctx.manifest.cwd
|
|
308
|
-
? 'orca replay <run> --loose'
|
|
309
|
-
: `cd ${ctx.manifest.cwd} && orca replay <run> --in-place # or --loose to continue live`,
|
|
310
|
-
});
|
|
311
|
-
if (!trace)
|
|
312
|
-
return;
|
|
313
|
-
// `error`, not `divergence`: nothing was served and the run is over, so calling it an
|
|
314
|
-
// inexact match would put a rung on a ladder the request never climbed. It is also the one
|
|
315
|
-
// finding here that exists nowhere else — a matched exchange is already in the parent, but
|
|
316
|
-
// "the agent asked for something this recording cannot answer" is new, and until now it
|
|
317
|
-
// lived only in the operator's scrollback.
|
|
318
|
-
writes.push(async () => {
|
|
319
|
-
await trace.append({
|
|
320
|
-
type: 'error',
|
|
321
|
-
actor: 'orca',
|
|
322
|
-
turn: 0,
|
|
323
|
-
attrs: {
|
|
324
|
-
rule: 'replay_unmatched',
|
|
325
|
-
rung: 4,
|
|
326
|
-
reason: u.reason,
|
|
327
|
-
index: u.index,
|
|
328
|
-
source_seq: u.seq,
|
|
329
|
-
recorded_in: ctx.manifest.cwd,
|
|
330
|
-
replayed_in: workspace.dir,
|
|
331
|
-
},
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
},
|
|
335
|
-
});
|
|
336
|
-
await trace?.append({
|
|
337
|
-
type: 'run.start',
|
|
338
|
-
actor: 'orca',
|
|
339
|
-
turn: 0,
|
|
340
|
-
attrs: {
|
|
341
|
-
adapter: ctx.manifest.adapter.id,
|
|
342
|
-
cwd: workspace.dir,
|
|
343
|
-
proxy: proxy.url,
|
|
344
|
-
mode: 'replay',
|
|
345
|
-
// Also on the manifest, which is what an out-of-process reader sees first. Here as well
|
|
346
|
-
// because a trace read on its own should say what it is a replay of.
|
|
347
|
-
parent_run: ctx.manifest.run_id,
|
|
348
|
-
exchanges: ctx.exchanges.length,
|
|
349
|
-
},
|
|
350
|
-
});
|
|
351
|
-
out.phase('replaying', {
|
|
352
|
-
run: ctx.manifest.run_id,
|
|
353
|
-
exchanges: ctx.exchanges.length,
|
|
354
|
-
egress: 'blocked',
|
|
355
|
-
proxy: proxy.url,
|
|
356
|
-
cwd: workspace.dir,
|
|
357
|
-
});
|
|
358
|
-
// The agent runs live for everything that is not a model call, MCP included. Without a config it
|
|
359
|
-
// talks to servers orca cannot see — or, for a harness that requires the variable, does not start
|
|
360
|
-
// at all, which is how a replay of a working recording exits non-zero for a reason that has
|
|
361
|
-
// nothing to do with the recording.
|
|
362
|
-
const mcp = trace === undefined
|
|
363
|
-
? undefined
|
|
364
|
-
: await mcpForReplay(args, ctx.events, trace, out, join(ctx.runDir, 'mcp-frames.jsonl'));
|
|
365
|
-
const adapter = defaultAdapters().get(ctx.manifest.adapter.id);
|
|
366
|
-
const launch = await adapter.prepare({
|
|
367
|
-
runId: ctx.manifest.run_id,
|
|
368
|
-
cwd: workspace.dir,
|
|
369
|
-
proxyUrl: proxy.url,
|
|
370
|
-
runDir: ctx.runDir,
|
|
371
|
-
userArgs: driveArgs(adapter, ctx, out),
|
|
372
|
-
env: process.env,
|
|
373
|
-
});
|
|
374
|
-
if (proxy.tls)
|
|
375
|
-
await trustRunCa(trace, proxy.tls, proxy.url, launch.env, out);
|
|
376
|
-
if (mcp) {
|
|
377
|
-
pointAtMcpConfig(launch.env, mcp.configPath);
|
|
378
|
-
// Same as record: the path has to reach the harness the way the harness actually reads it, or
|
|
379
|
-
// a replay re-instruments a config nothing opens and every recorded MCP call goes unmatched.
|
|
380
|
-
const mcpArgs = adapter.mcpConfigArgs?.(mcp.configPath);
|
|
381
|
-
if (mcpArgs !== undefined)
|
|
382
|
-
launch.args = [...mcpArgs, ...launch.args];
|
|
383
|
-
}
|
|
384
|
-
let exitCode;
|
|
249
|
+
await planTlsCapture(args, interceptedHosts);
|
|
250
|
+
// Armed on the line after the call, which is only sound because `replayWorkspace` owns its own
|
|
251
|
+
// destructive step: it either returns with the tree replaced and a `release` that undoes that,
|
|
252
|
+
// or it throws having already put the tree back. Arming here without that guarantee would have
|
|
253
|
+
// read as covering the restore while missing it entirely — the restore is inside the call, so a
|
|
254
|
+
// `materialize` that failed part-way rejected before `workspace` was ever assigned.
|
|
255
|
+
const workspace = await replayWorkspace(args, out, ctx);
|
|
385
256
|
try {
|
|
386
|
-
|
|
257
|
+
return await replayRestored(args, out, ctx, { plan, interceptedHosts, workspace });
|
|
387
258
|
}
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
259
|
+
finally {
|
|
260
|
+
// The outermost of three, and the one that needs no list to be right. Hoisting a refusal is
|
|
261
|
+
// only ever as complete as the list of refusals somebody thought of, and that list has been
|
|
262
|
+
// wrong twice; `replayRestored` has its own `finally` for the span it owns; this covers what
|
|
263
|
+
// is left, which is `openReplayTrace` throwing before that span begins. `release` is
|
|
264
|
+
// idempotent, so the ordinary path still puts the tree back where it always did — before the
|
|
265
|
+
// run reports itself done — and this is a no-op behind it.
|
|
266
|
+
await workspace.release();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The replay itself, once the filesystem it needs is in place.
|
|
271
|
+
*
|
|
272
|
+
* Split from `replayExact` for one reason: everything in here happens with the recording's files
|
|
273
|
+
* over the operator's checkout, so it has to run inside something that puts them back.
|
|
274
|
+
*/
|
|
275
|
+
async function replayRestored(args, out, ctx, prepared) {
|
|
276
|
+
const { plan, interceptedHosts, workspace } = prepared;
|
|
277
|
+
const divergences = [];
|
|
278
|
+
const unmatched = [];
|
|
279
|
+
const trace = await openReplayTrace(args, ctx, workspace.dir);
|
|
280
|
+
// Serial for the same reason the recorder's is: the callbacks fire from the proxy's request
|
|
281
|
+
// handler, and two overlapping appends would interleave lines in events.jsonl.
|
|
282
|
+
const writes = new SerialQueue();
|
|
283
|
+
/**
|
|
284
|
+
* The two things a failed replay would otherwise leave running or on disk.
|
|
285
|
+
*
|
|
286
|
+
* Filled as each is built, because what needs cleaning up depends on how far the run got:
|
|
287
|
+
* `createProxy` throwing has a key to remove and no socket, and everything after it has both.
|
|
288
|
+
*/
|
|
289
|
+
let mintedCa;
|
|
290
|
+
let openProxy;
|
|
291
|
+
/**
|
|
292
|
+
* Give up on a replay that has already built something, then re-throw. `orca record`'s
|
|
293
|
+
* `abandon` is the same function for the same two reasons.
|
|
294
|
+
*
|
|
295
|
+
* A listening proxy keeps Node's event loop alive, so a throw printed the error and then hung
|
|
296
|
+
* forever — `orca replay` was the one command of the three that had to be killed. And an
|
|
297
|
+
* unsealed trace has no `ended_at` or `integrity`, so `verifyIntegrity` reports the run as
|
|
298
|
+
* *tampered* rather than as unfinished.
|
|
299
|
+
*
|
|
300
|
+
* Sealing rather than discarding, for the reason the fork gives: a run that failed to launch is
|
|
301
|
+
* still a run someone will want to read. The CA is not disposed here because the `finally`
|
|
302
|
+
* below does it on both paths.
|
|
303
|
+
*/
|
|
304
|
+
async function abandon(err) {
|
|
305
|
+
await openProxy?.close().catch(() => undefined);
|
|
402
306
|
await writes.drain().catch(() => undefined);
|
|
403
307
|
if (trace) {
|
|
404
308
|
await trace
|
|
@@ -408,66 +312,254 @@ async function replayExact(args, out, ctx) {
|
|
|
408
312
|
}
|
|
409
313
|
throw err;
|
|
410
314
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
315
|
+
/*
|
|
316
|
+
* Everything from here to the child's exit mints a key and then opens a socket, so everything
|
|
317
|
+
* from here to the child's exit has to come back through `abandon` — the rule `orca record`
|
|
318
|
+
* states in the same words, having learned it the same way.
|
|
319
|
+
*
|
|
320
|
+
* The guard used to start at the spawn, which covered the failure it was written for and missed
|
|
321
|
+
* the four that happen first. `RunCa.create` writes `tls/ca.key`; `createProxy` binds; then
|
|
322
|
+
* `mcpForReplay` rewrites a config and `adapter.prepare` runs a harness's own validation, which
|
|
323
|
+
* is where `orca replay` on a trace naming an adapter this build does not have ended up — the
|
|
324
|
+
* right error message, printed, and then a process that never exited with a private key left
|
|
325
|
+
* behind in the run directory.
|
|
326
|
+
*/
|
|
327
|
+
try {
|
|
328
|
+
// A subscription-backed harness does not use the ordinary base URL, so exact replay needs the
|
|
329
|
+
// same per-run CA and HTTPS proxy as recording. The proxy's TLS hook then answers Codex's
|
|
330
|
+
// model request from the trace before it can open an origin connection.
|
|
331
|
+
// The hosts were resolved before the workspace was restored, where anything refusable about
|
|
332
|
+
// them was refused; this is the half that mints.
|
|
333
|
+
const tls = await setupTlsCapture({
|
|
334
|
+
args,
|
|
335
|
+
out,
|
|
336
|
+
...(interceptedHosts ? { recordedHosts: interceptedHosts } : {}),
|
|
337
|
+
writer: trace,
|
|
338
|
+
runDir: ctx.runDir,
|
|
339
|
+
writes,
|
|
340
|
+
turn: () => 0,
|
|
341
|
+
});
|
|
342
|
+
// Handed to `abandon` the moment it exists, not once the run is under way: `createProxy` on
|
|
343
|
+
// the next line is one of the throws this key has to survive being disposed by.
|
|
344
|
+
mintedCa = tls.ca;
|
|
345
|
+
const proxy = await createProxy({
|
|
346
|
+
mode: 'replay',
|
|
347
|
+
exchanges: ctx.exchanges,
|
|
348
|
+
loose: args.bool('loose'),
|
|
349
|
+
upstream: plan.upstream,
|
|
350
|
+
upstreamHeaders: plan.headers,
|
|
351
|
+
upstreamHeadersOrigin: plan.headersOrigin,
|
|
352
|
+
...tls.proxyOptions,
|
|
353
|
+
onDivergence: (d) => {
|
|
354
|
+
divergences.push(d);
|
|
355
|
+
if (!trace)
|
|
356
|
+
return;
|
|
357
|
+
writes.push(async () => {
|
|
358
|
+
await trace.append({
|
|
359
|
+
type: 'divergence',
|
|
360
|
+
actor: 'orca',
|
|
361
|
+
// Turn 0 for everything in this trace, and deliberately not a running count. An exact
|
|
362
|
+
// match produces no callback, so a counter here would number the third divergence as
|
|
363
|
+
// turn 1 and claim a position in the conversation that it does not have. `source_seq`
|
|
364
|
+
// is the honest coordinate: it points straight at the parent's own event.
|
|
365
|
+
turn: 0,
|
|
366
|
+
attrs: { level: d.level, rung: d.rung, detail: d.detail, source_seq: d.seq },
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
// Printed as it happens rather than tallied at the end. A halted replay stops the agent, so
|
|
371
|
+
// the count in `replay.done` arrives after the operator has already seen the run die — and a
|
|
372
|
+
// bare `unmatched=1` with no reason is indistinguishable from a bug in orca itself.
|
|
373
|
+
//
|
|
374
|
+
// The two directories are on the line because a distance in the hundreds of thousands is true
|
|
375
|
+
// and unactionable. Harnesses put absolute paths in their tool calls, so a replay running
|
|
376
|
+
// anywhere but the recording's own directory gets a permission refusal where the recording has
|
|
377
|
+
// file contents — by far the most common cause of a halt, and invisible from the number alone.
|
|
378
|
+
onUnmatched: (u) => {
|
|
379
|
+
unmatched.push(u);
|
|
380
|
+
// A halt inside a delegation is not a corrupt trace and not a bug, and looks like both. The
|
|
381
|
+
// harness writes the delegate's prompt itself, fresh on every run, so the request really is
|
|
382
|
+
// a different question — which is exactly what the matcher refuses to serve from a
|
|
383
|
+
// recording. Without this the operator sees `distance 54` and goes looking for the fault.
|
|
384
|
+
const delegation = enclosingDelegation(ctx.events, u.seq);
|
|
385
|
+
out.warn('replay.unmatched', {
|
|
386
|
+
seq: u.seq,
|
|
387
|
+
index: u.index,
|
|
388
|
+
reason: u.reason,
|
|
389
|
+
...(delegation === undefined
|
|
390
|
+
? {}
|
|
391
|
+
: {
|
|
392
|
+
inside: `${delegation.subagent} delegated at seq ${delegation.seq}`,
|
|
393
|
+
why: 'the harness writes a delegate prompt of its own each run, so this request is a different question rather than a drifted one',
|
|
394
|
+
}),
|
|
395
|
+
recorded_in: ctx.manifest.cwd,
|
|
396
|
+
replayed_in: workspace.dir,
|
|
397
|
+
next: workspace.dir === ctx.manifest.cwd
|
|
398
|
+
? 'orca replay <run> --loose'
|
|
399
|
+
: `cd ${ctx.manifest.cwd} && orca replay <run> --in-place # or --loose to continue live`,
|
|
400
|
+
});
|
|
401
|
+
if (!trace)
|
|
402
|
+
return;
|
|
403
|
+
// `error`, not `divergence`: nothing was served and the run is over, so calling it an
|
|
404
|
+
// inexact match would put a rung on a ladder the request never climbed. It is also the one
|
|
405
|
+
// finding here that exists nowhere else — a matched exchange is already in the parent, but
|
|
406
|
+
// "the agent asked for something this recording cannot answer" is new, and until now it
|
|
407
|
+
// lived only in the operator's scrollback.
|
|
408
|
+
writes.push(async () => {
|
|
409
|
+
await trace.append({
|
|
410
|
+
type: 'error',
|
|
411
|
+
actor: 'orca',
|
|
412
|
+
turn: 0,
|
|
413
|
+
attrs: {
|
|
414
|
+
rule: 'replay_unmatched',
|
|
415
|
+
rung: 4,
|
|
416
|
+
reason: u.reason,
|
|
417
|
+
index: u.index,
|
|
418
|
+
source_seq: u.seq,
|
|
419
|
+
recorded_in: ctx.manifest.cwd,
|
|
420
|
+
replayed_in: workspace.dir,
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
// Listening from here on, so from here on a throw that does not close it is a process that
|
|
427
|
+
// does not exit.
|
|
428
|
+
openProxy = proxy;
|
|
429
|
+
await trace?.append({
|
|
430
|
+
type: 'run.start',
|
|
435
431
|
actor: 'orca',
|
|
436
432
|
turn: 0,
|
|
437
433
|
attrs: {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
434
|
+
adapter: ctx.manifest.adapter.id,
|
|
435
|
+
cwd: workspace.dir,
|
|
436
|
+
proxy: proxy.url,
|
|
437
|
+
mode: 'replay',
|
|
438
|
+
// Also on the manifest, which is what an out-of-process reader sees first. Here as well
|
|
439
|
+
// because a trace read on its own should say what it is a replay of.
|
|
440
|
+
parent_run: ctx.manifest.run_id,
|
|
441
|
+
exchanges: ctx.exchanges.length,
|
|
443
442
|
},
|
|
444
443
|
});
|
|
445
|
-
|
|
444
|
+
out.phase('replaying', {
|
|
445
|
+
run: ctx.manifest.run_id,
|
|
446
|
+
exchanges: ctx.exchanges.length,
|
|
447
|
+
// Read from the flag, because `--loose` is exactly the run where it is not blocked. It was a
|
|
448
|
+
// literal, so `orca replay <run> --loose` announced `egress=blocked` and then answered the
|
|
449
|
+
// first unmatched request from the provider — the one line a reader checks to know whether a
|
|
450
|
+
// run can spend money, saying the opposite of what the run was about to do. Worse than an
|
|
451
|
+
// inaccuracy, because `replay halted` recommends `--loose` by name: the reader is following
|
|
452
|
+
// orca's own advice when the label stops being true.
|
|
453
|
+
egress: args.bool('loose') ? 'live-on-unmatched' : 'blocked',
|
|
454
|
+
proxy: proxy.url,
|
|
455
|
+
cwd: workspace.dir,
|
|
456
|
+
});
|
|
457
|
+
// The agent runs live for everything that is not a model call, MCP included. Without a config it
|
|
458
|
+
// talks to servers orca cannot see — or, for a harness that requires the variable, does not start
|
|
459
|
+
// at all, which is how a replay of a working recording exits non-zero for a reason that has
|
|
460
|
+
// nothing to do with the recording.
|
|
461
|
+
const mcp = trace === undefined
|
|
462
|
+
? undefined
|
|
463
|
+
: await mcpForReplay(args, ctx.events, trace, out, join(ctx.runDir, 'mcp-frames.jsonl'));
|
|
464
|
+
const adapter = defaultAdapters().get(ctx.manifest.adapter.id);
|
|
465
|
+
const launch = await adapter.prepare({
|
|
466
|
+
runId: ctx.manifest.run_id,
|
|
467
|
+
cwd: workspace.dir,
|
|
468
|
+
proxyUrl: proxy.url,
|
|
469
|
+
runDir: ctx.runDir,
|
|
470
|
+
userArgs: driveArgs(adapter, ctx, out),
|
|
471
|
+
env: process.env,
|
|
472
|
+
});
|
|
473
|
+
// Replaying must not introduce a catalog request before the capture plugin is installed.
|
|
474
|
+
if (adapter.id === 'opencode')
|
|
475
|
+
launch.env.OPENCODE_DISABLE_MODELS_FETCH = '1';
|
|
476
|
+
if (proxy.tls)
|
|
477
|
+
await trustRunCa(trace, proxy.tls, proxy.url, launch.env, out);
|
|
478
|
+
if (mcp) {
|
|
479
|
+
pointAtMcpConfig(launch.env, mcp.configPath);
|
|
480
|
+
// Same as record: the path has to reach the harness the way the harness actually reads it, or
|
|
481
|
+
// a replay re-instruments a config nothing opens and every recorded MCP call goes unmatched.
|
|
482
|
+
const mcpArgs = adapter.mcpConfigArgs?.(mcp.configPath);
|
|
483
|
+
if (mcpArgs !== undefined)
|
|
484
|
+
launch.args = [...mcpArgs, ...launch.args];
|
|
485
|
+
}
|
|
486
|
+
const exitCode = await runChild(launch.command, launch.args, { ...process.env, ...launch.env }, workspace.dir, agentStdoutFor(args));
|
|
487
|
+
await writes.drain();
|
|
488
|
+
const stats = proxy.stats();
|
|
489
|
+
await proxy.close();
|
|
490
|
+
for (const d of divergences) {
|
|
491
|
+
out.warn('divergence', { seq: d.seq, level: d.level, detail: d.detail });
|
|
492
|
+
}
|
|
493
|
+
// A halted replay is a failed replay even if the harness chose to exit 0 on the error. The exit
|
|
494
|
+
// code is what a script reads, so it has to reflect what happened rather than what the agent
|
|
495
|
+
// decided to do about it — and the trace records the same verdict for the same reason.
|
|
496
|
+
const verdict = exitCode === 0 && unmatched.length > 0 ? 1 : exitCode;
|
|
497
|
+
if (trace) {
|
|
498
|
+
// What the replay discovered rather than repeated: these calls really happened just now, and
|
|
499
|
+
// exist nowhere in the parent.
|
|
500
|
+
if (mcp)
|
|
501
|
+
await drainMcpFrames(mcp, trace, () => 0, 0);
|
|
502
|
+
await trace.append({
|
|
503
|
+
type: 'run.end',
|
|
504
|
+
actor: 'orca',
|
|
505
|
+
turn: 0,
|
|
506
|
+
attrs: {
|
|
507
|
+
exit_code: verdict,
|
|
508
|
+
agent_exit_code: exitCode,
|
|
509
|
+
matched: stats.matchedExact,
|
|
510
|
+
divergences: stats.divergences,
|
|
511
|
+
unmatched: stats.unmatched,
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
await trace.close(verdict);
|
|
515
|
+
}
|
|
516
|
+
out.phase('replay.done', {
|
|
517
|
+
// `matched=1 total=13` was the old shape, and on a healthy replay of a real harness it read as
|
|
518
|
+
// a failure: rung 1 is only reachable when nothing in the request was redacted, so a run whose
|
|
519
|
+
// every request was served from disk still reported one match. What someone wants to know here
|
|
520
|
+
// is how much of the recording was reused, and how much of that reuse was exact.
|
|
521
|
+
reused: `${stats.matchedExact + stats.matchedInexact}/${ctx.exchanges.length}`,
|
|
522
|
+
exact: stats.matchedExact,
|
|
523
|
+
divergences: stats.divergences,
|
|
524
|
+
unmatched: stats.unmatched,
|
|
525
|
+
exit: exitCode,
|
|
526
|
+
// Omitted entirely under --no-trace: `Output` drops undefined fields, so the line stays the
|
|
527
|
+
// shape it has always been for anyone who opted out.
|
|
528
|
+
trace: trace?.runId,
|
|
529
|
+
});
|
|
530
|
+
return {
|
|
531
|
+
runId: ctx.manifest.run_id,
|
|
532
|
+
mode: 'exact',
|
|
533
|
+
...(trace === undefined ? {} : { traceRunId: trace.runId }),
|
|
534
|
+
matchedExact: stats.matchedExact,
|
|
535
|
+
divergences: stats.divergences,
|
|
536
|
+
unmatched: stats.unmatched,
|
|
537
|
+
liveCalls: stats.liveCalls,
|
|
538
|
+
exitCode: verdict,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
// `return await`, not the bare `return abandon(err)` that `orca record` can afford. The
|
|
543
|
+
// difference is the `finally` below: a promise returned out of a `catch` is not adopted by
|
|
544
|
+
// the caller until the `finally` has finished, and this one awaits real I/O, so the
|
|
545
|
+
// rejection sits with no handler attached for as long as that takes. Node reports it as an
|
|
546
|
+
// unhandled rejection and kills the process on the spot — the operator got a raw stack trace
|
|
547
|
+
// where `orca replay` had always printed `error replay.failed`, and `main`'s catch never ran
|
|
548
|
+
// at all. Awaiting here turns it back into a throw *inside* the catch, before the finally,
|
|
549
|
+
// which is what every frame above expects.
|
|
550
|
+
return await abandon(err);
|
|
551
|
+
}
|
|
552
|
+
finally {
|
|
553
|
+
// Both paths, which is why these two are here and the proxy is not: a replay that ran
|
|
554
|
+
// closes its own proxy after reading `stats()`, and one that did not comes through
|
|
555
|
+
// `abandon`. These do not divide that way. The whole justification for restoring over
|
|
556
|
+
// the working tree is that it is put back, and `tls-capture.ts` asks the caller to own
|
|
557
|
+
// the run CA on every exit path — `replayCommand` had no equivalent of the `minted`
|
|
558
|
+
// wrapper `recordCommand` and `attachCommand` use, so a key outlived every failure
|
|
559
|
+
// between the mint and the launch.
|
|
560
|
+
await workspace.release();
|
|
561
|
+
await mintedCa?.dispose();
|
|
446
562
|
}
|
|
447
|
-
out.phase('replay.done', {
|
|
448
|
-
// `matched=1 total=13` was the old shape, and on a healthy replay of a real harness it read as
|
|
449
|
-
// a failure: rung 1 is only reachable when nothing in the request was redacted, so a run whose
|
|
450
|
-
// every request was served from disk still reported one match. What someone wants to know here
|
|
451
|
-
// is how much of the recording was reused, and how much of that reuse was exact.
|
|
452
|
-
reused: `${stats.matchedExact + stats.matchedInexact}/${ctx.exchanges.length}`,
|
|
453
|
-
exact: stats.matchedExact,
|
|
454
|
-
divergences: stats.divergences,
|
|
455
|
-
unmatched: stats.unmatched,
|
|
456
|
-
exit: exitCode,
|
|
457
|
-
// Omitted entirely under --no-trace: `Output` drops undefined fields, so the line stays the
|
|
458
|
-
// shape it has always been for anyone who opted out.
|
|
459
|
-
trace: trace?.runId,
|
|
460
|
-
});
|
|
461
|
-
return {
|
|
462
|
-
runId: ctx.manifest.run_id,
|
|
463
|
-
mode: 'exact',
|
|
464
|
-
...(trace === undefined ? {} : { traceRunId: trace.runId }),
|
|
465
|
-
matchedExact: stats.matchedExact,
|
|
466
|
-
divergences: stats.divergences,
|
|
467
|
-
unmatched: stats.unmatched,
|
|
468
|
-
liveCalls: stats.liveCalls,
|
|
469
|
-
exitCode: verdict,
|
|
470
|
-
};
|
|
471
563
|
}
|
|
472
564
|
/**
|
|
473
565
|
* The run an exact replay writes about itself, or nothing under `--no-trace`.
|
|
@@ -532,6 +624,17 @@ async function replayFork(args, out, ctx) {
|
|
|
532
624
|
// Exchanges are indexed by position, so the fork point in exchange terms is how many model
|
|
533
625
|
// requests happened at or before the checkpoint.
|
|
534
626
|
const forkAt = ctx.exchanges.filter((e) => e.seq <= checkpoint.seq).length;
|
|
627
|
+
// Before the worktree and the fork's own run directory, because both of these refuse:
|
|
628
|
+
// `upstreamPlan` an upstream that is not an origin, `planTlsCapture` a `--tls-hosts` list that
|
|
629
|
+
// names `*` or contradicts itself and an `ORCA_TLS_UPSTREAM_CA` that cannot be read. Resolved
|
|
630
|
+
// after them, a typo in any of them left a restored temp tree in `$TMPDIR` that `orca gc` will
|
|
631
|
+
// not reclaim — no manifest points at it — and an empty fork in `orca list`, reading
|
|
632
|
+
// `FROM <parent>@<n>` as though it had run.
|
|
633
|
+
const plan = await upstreamPlan(args);
|
|
634
|
+
// A fork continues a recorded conversation, so it is intercepted on the same terms the
|
|
635
|
+
// recording was; see `setupTlsCapture` below, which this is the refusing half of.
|
|
636
|
+
const forkInterceptedHosts = recordedTlsHosts(ctx.events);
|
|
637
|
+
await planTlsCapture(args, forkInterceptedHosts);
|
|
535
638
|
const worktree = await mkdtemp(join(tmpdir(), `orca-${checkpoint.seq}-`));
|
|
536
639
|
if (checkpoint.fsTree) {
|
|
537
640
|
// Restore from the ORIGINAL run's shadow store: that is the only place the tree object
|
|
@@ -588,14 +691,36 @@ async function replayFork(args, out, ctx) {
|
|
|
588
691
|
out.warn('fs.unavailable', { reason: String(err) });
|
|
589
692
|
}
|
|
590
693
|
}
|
|
591
|
-
const plan = await upstreamPlan(args);
|
|
592
694
|
// A fork runs a real agent live, so it has exactly the same blind spot `orca record` does: a
|
|
593
695
|
// harness that talks to its own backend over TLS reads no base-URL variable and is invisible
|
|
594
696
|
// without interception. The flag was parsed here and silently discarded, which is the worse
|
|
595
697
|
// half — the operator believes they captured that traffic.
|
|
596
698
|
// Same for a fork: it continues a recorded conversation, so its prefix is replayed and the
|
|
597
699
|
// requests carrying it arrive by the same intercepted transport they were recorded on.
|
|
598
|
-
|
|
700
|
+
/** Filled as each is built; see the exact path's pair, which this mirrors. */
|
|
701
|
+
let mintedCa;
|
|
702
|
+
let openProxy;
|
|
703
|
+
/**
|
|
704
|
+
* The same two failures `orca record` had. A listening proxy keeps Node's event loop alive, so
|
|
705
|
+
* a throw printed the error and then hung; and a fork that mints a certificate authority must
|
|
706
|
+
* not leave the private key on disk when it dies. The trace is sealed either way, because a
|
|
707
|
+
* fork that failed to launch is still a fork someone will want to read.
|
|
708
|
+
*
|
|
709
|
+
* This used to guard the spawn alone, which is one throw out of five: `RunCa.create` writes the
|
|
710
|
+
* key, `mcpForReplay` rewrites a config, `createProxy` binds, and `adapter.prepare` runs a
|
|
711
|
+
* harness's own validation — all of them before the child, all of them leaving the key, the
|
|
712
|
+
* socket, or both.
|
|
713
|
+
*/
|
|
714
|
+
async function abandon(err) {
|
|
715
|
+
await openProxy?.close().catch(() => undefined);
|
|
716
|
+
await writes.drain().catch(() => undefined);
|
|
717
|
+
await mintedCa?.dispose().catch(() => undefined);
|
|
718
|
+
await writer
|
|
719
|
+
.append({ type: 'run.end', actor: 'orca', turn, attrs: { error: String(err) } })
|
|
720
|
+
.catch(() => undefined);
|
|
721
|
+
await writer.close().catch(() => undefined);
|
|
722
|
+
throw err;
|
|
723
|
+
}
|
|
599
724
|
const tls = await setupTlsCapture({
|
|
600
725
|
args,
|
|
601
726
|
out,
|
|
@@ -603,10 +728,11 @@ async function replayFork(args, out, ctx) {
|
|
|
603
728
|
writer,
|
|
604
729
|
writes,
|
|
605
730
|
turn: () => turn,
|
|
606
|
-
});
|
|
731
|
+
}).catch(abandon);
|
|
732
|
+
mintedCa = tls.ca;
|
|
607
733
|
// A fork continues the run live past the checkpoint, so its MCP traffic is new and belongs in the
|
|
608
734
|
// fork's own trace. Without this the layer simply stopped at the fork point.
|
|
609
|
-
const mcp = await mcpForReplay(args, ctx.events, writer, out);
|
|
735
|
+
const mcp = await mcpForReplay(args, ctx.events, writer, out).catch(abandon);
|
|
610
736
|
const proxy = await createProxy({
|
|
611
737
|
mode: 'hybrid',
|
|
612
738
|
forkAt,
|
|
@@ -647,6 +773,8 @@ async function replayFork(args, out, ctx) {
|
|
|
647
773
|
});
|
|
648
774
|
},
|
|
649
775
|
});
|
|
776
|
+
// Listening from here on — see the exact path.
|
|
777
|
+
openProxy = proxy;
|
|
650
778
|
await writer.append({
|
|
651
779
|
type: 'fork',
|
|
652
780
|
actor: 'orca',
|
|
@@ -669,44 +797,34 @@ async function replayFork(args, out, ctx) {
|
|
|
669
797
|
model: ctx.model ?? '(unchanged)',
|
|
670
798
|
worktree,
|
|
671
799
|
});
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
// a fork that failed to launch is still a fork someone will want to read.
|
|
701
|
-
await proxy.close().catch(() => undefined);
|
|
702
|
-
await writes.drain().catch(() => undefined);
|
|
703
|
-
await tls.ca?.dispose().catch(() => undefined);
|
|
704
|
-
await writer
|
|
705
|
-
.append({ type: 'run.end', actor: 'orca', turn, attrs: { error: String(err) } })
|
|
706
|
-
.catch(() => undefined);
|
|
707
|
-
await writer.close().catch(() => undefined);
|
|
708
|
-
throw err;
|
|
709
|
-
}
|
|
800
|
+
// `defaultAdapters().get` refuses a manifest naming an adapter this build does not have, which
|
|
801
|
+
// is what replaying a newer orca's recording looks like from here, and `prepare` is where a
|
|
802
|
+
// harness validates its own invocation. Both are throws with the proxy already listening.
|
|
803
|
+
const launch = await (async () => {
|
|
804
|
+
const adapter = defaultAdapters().get(ctx.manifest.adapter.id);
|
|
805
|
+
const prepared = await adapter.prepare({
|
|
806
|
+
runId: writer.runId,
|
|
807
|
+
cwd: worktree,
|
|
808
|
+
proxyUrl: proxy.url,
|
|
809
|
+
runDir: writer.runDir,
|
|
810
|
+
userArgs: driveArgs(adapter, ctx, out),
|
|
811
|
+
env: process.env,
|
|
812
|
+
});
|
|
813
|
+
if (proxy.tls) {
|
|
814
|
+
await trustRunCa(writer, proxy.tls, proxy.url, prepared.env, out);
|
|
815
|
+
}
|
|
816
|
+
if (mcp) {
|
|
817
|
+
pointAtMcpConfig(prepared.env, mcp.configPath);
|
|
818
|
+
// Same as record: the path has to reach the harness the way the harness actually reads it,
|
|
819
|
+
// or a replay re-instruments a config nothing opens and every recorded MCP call goes
|
|
820
|
+
// unmatched.
|
|
821
|
+
const mcpArgs = adapter.mcpConfigArgs?.(mcp.configPath);
|
|
822
|
+
if (mcpArgs !== undefined)
|
|
823
|
+
prepared.args = [...mcpArgs, ...prepared.args];
|
|
824
|
+
}
|
|
825
|
+
return prepared;
|
|
826
|
+
})().catch(abandon);
|
|
827
|
+
const exitCode = await runChild(launch.command, launch.args, { ...process.env, ...launch.env }, worktree, agentStdoutFor(args)).catch(abandon);
|
|
710
828
|
await writes.drain();
|
|
711
829
|
if (mcp)
|
|
712
830
|
await drainMcpFrames(mcp, writer, turnAtFork, turn);
|
|
@@ -796,32 +914,67 @@ async function replayWorkspace(args, out, ctx) {
|
|
|
796
914
|
your_tree: before.tree,
|
|
797
915
|
note: 'your files are restored when the replay ends',
|
|
798
916
|
});
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
917
|
+
// Two callers, on purpose — see `Workspace.release`. Restoring twice would be wrong rather than
|
|
918
|
+
// merely wasteful: the second pass would write the pre-replay tree back over whatever the
|
|
919
|
+
// caller has done since, and the `rm` would take the scratch with it.
|
|
920
|
+
let released = false;
|
|
921
|
+
const release = async () => {
|
|
922
|
+
if (released)
|
|
923
|
+
return;
|
|
924
|
+
released = true;
|
|
925
|
+
await safety.restore(before.tree, ctx.cwd);
|
|
926
|
+
// Only after the restore succeeded. This store holds the only copy of your working tree as
|
|
927
|
+
// it was before the replay overwrote it, so removing it on the failure path would delete the
|
|
928
|
+
// thing the failure means you still need — better a directory to clean up by hand than the
|
|
929
|
+
// one that had your uncommitted work in it.
|
|
930
|
+
//
|
|
931
|
+
// Left behind on every run until now: a whole workspace per `orca replay`, in a directory
|
|
932
|
+
// `orca gc` deliberately will not touch because it only reclaims scratch worktrees belonging
|
|
933
|
+
// to forks. Owning its lifetime here is the fix; teaching gc to delete unknown temp
|
|
934
|
+
// directories is how gc ends up removing someone's work.
|
|
935
|
+
await rm(scratch, { recursive: true, force: true });
|
|
815
936
|
};
|
|
937
|
+
// The destructive step, inside the thing that undoes it.
|
|
938
|
+
//
|
|
939
|
+
// A caller cannot guard this one: it happens here, and the `release` that undoes it does not
|
|
940
|
+
// exist outside this function until this function returns. So a `materialize` that failed
|
|
941
|
+
// part-way — it is `read-tree` + `checkout-index -a -f` with no rollback of its own, and a
|
|
942
|
+
// path conflict, EACCES or ENOSPC stops it mid-tree — left the operator holding a mixture of
|
|
943
|
+
// their files and the recording's, with the only copy of the original in a scratch directory
|
|
944
|
+
// whose path nothing prints, and no release attempted anywhere.
|
|
945
|
+
//
|
|
946
|
+
// With this, the function has one postcondition either way: it returns with the tree replaced
|
|
947
|
+
// and a `release` that puts it back, or it throws with the tree as it found it. That is what
|
|
948
|
+
// lets the caller arm its own guard on the line after the call rather than before it.
|
|
949
|
+
try {
|
|
950
|
+
await recorded.restore(initial.fsTree, ctx.cwd);
|
|
951
|
+
}
|
|
952
|
+
catch (err) {
|
|
953
|
+
await release();
|
|
954
|
+
throw err;
|
|
955
|
+
}
|
|
956
|
+
return { dir: ctx.cwd, release };
|
|
957
|
+
}
|
|
958
|
+
/** Where the replayed agent's own stdout should go, given the flags. */
|
|
959
|
+
function agentStdoutFor(args) {
|
|
960
|
+
if (args.bool('quiet'))
|
|
961
|
+
return 'ignore';
|
|
962
|
+
return args.bool('json') ? 'stderr' : 'inherit';
|
|
816
963
|
}
|
|
817
964
|
/**
|
|
818
965
|
* Launch the agent for a replay or a fork.
|
|
819
966
|
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
967
|
+
* Where the replayed agent's own stdout goes:
|
|
968
|
+
*
|
|
969
|
+
* inherit the default — you asked to watch the run happen again, so you watch it
|
|
970
|
+
* stderr `--json`: orca's stdout is the result document, so the agent's output moves aside
|
|
971
|
+
* rather than landing in the middle of it
|
|
972
|
+
* ignore `--quiet`: the run is being replayed for its verdict, not for its narration
|
|
973
|
+
*
|
|
974
|
+
* stdin and stderr stay inherited in every case, so a harness that prompts still can and a
|
|
975
|
+
* harness that fails still says why.
|
|
823
976
|
*/
|
|
824
|
-
async function runChild(command, argv, env, cwd = process.cwd(),
|
|
977
|
+
async function runChild(command, argv, env, cwd = process.cwd(), agentStdout = 'inherit') {
|
|
825
978
|
// Same resolution as record and as detection; see resolveLaunch.
|
|
826
979
|
const target = await resolveLaunch(command, argv);
|
|
827
980
|
return new Promise((resolve, reject) => {
|
|
@@ -829,9 +982,12 @@ async function runChild(command, argv, env, cwd = process.cwd(), quietStdout = f
|
|
|
829
982
|
env,
|
|
830
983
|
cwd,
|
|
831
984
|
shell: target.shell,
|
|
832
|
-
stdio:
|
|
985
|
+
stdio: agentStdout === 'inherit'
|
|
986
|
+
? 'inherit'
|
|
987
|
+
: ['inherit', agentStdout === 'ignore' ? 'ignore' : 'pipe', 'inherit'],
|
|
833
988
|
});
|
|
834
|
-
|
|
989
|
+
if (agentStdout === 'stderr')
|
|
990
|
+
child.stdout?.pipe(process.stderr);
|
|
835
991
|
child.on('error', (err) => reject(new Error(`could not launch "${command}": ${String(err)}\n is it on your PATH?`)));
|
|
836
992
|
child.on('close', (code) => resolve(code ?? 0));
|
|
837
993
|
});
|