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.
Files changed (68) hide show
  1. package/dist/args.d.ts +33 -0
  2. package/dist/args.d.ts.map +1 -1
  3. package/dist/args.js +41 -5
  4. package/dist/args.js.map +1 -1
  5. package/dist/commands/attach.js +15 -4
  6. package/dist/commands/attach.js.map +1 -1
  7. package/dist/commands/inspect.d.ts +11 -0
  8. package/dist/commands/inspect.d.ts.map +1 -1
  9. package/dist/commands/inspect.js +43 -0
  10. package/dist/commands/inspect.js.map +1 -1
  11. package/dist/commands/quickstart.d.ts +48 -0
  12. package/dist/commands/quickstart.d.ts.map +1 -0
  13. package/dist/commands/quickstart.js +373 -0
  14. package/dist/commands/quickstart.js.map +1 -0
  15. package/dist/commands/record.d.ts +13 -0
  16. package/dist/commands/record.d.ts.map +1 -1
  17. package/dist/commands/record.js +119 -8
  18. package/dist/commands/record.js.map +1 -1
  19. package/dist/commands/replay.d.ts.map +1 -1
  20. package/dist/commands/replay.js +439 -283
  21. package/dist/commands/replay.js.map +1 -1
  22. package/dist/commands/scrub.d.ts.map +1 -1
  23. package/dist/commands/scrub.js +182 -47
  24. package/dist/commands/scrub.js.map +1 -1
  25. package/dist/commands/setup.d.ts.map +1 -1
  26. package/dist/commands/setup.js +67 -4
  27. package/dist/commands/setup.js.map +1 -1
  28. package/dist/exchange-events.d.ts.map +1 -1
  29. package/dist/exchange-events.js +23 -0
  30. package/dist/exchange-events.js.map +1 -1
  31. package/dist/flags.d.ts.map +1 -1
  32. package/dist/flags.js +68 -2
  33. package/dist/flags.js.map +1 -1
  34. package/dist/main.d.ts.map +1 -1
  35. package/dist/main.js +32 -0
  36. package/dist/main.js.map +1 -1
  37. package/dist/tls-capture.d.ts +10 -2
  38. package/dist/tls-capture.d.ts.map +1 -1
  39. package/dist/tls-capture.js +41 -3
  40. package/dist/tls-capture.js.map +1 -1
  41. package/dist/upstream.d.ts.map +1 -1
  42. package/dist/upstream.js +17 -0
  43. package/dist/upstream.js.map +1 -1
  44. package/package.json +13 -12
  45. package/quickstart/project/agent.mjs +128 -0
  46. package/quickstart/project/package.json +8 -0
  47. package/quickstart/project/src/schedule.js +41 -0
  48. package/quickstart/project/test/schedule.test.js +25 -0
  49. package/quickstart/trace/blobs/f7/f7c1592306093f65f441d2afd5f496b7635b7e18f5c15f55b11f71f0b552a118 +1 -0
  50. package/quickstart/trace/events.jsonl +19 -0
  51. package/quickstart/trace/fs/HEAD +1 -0
  52. package/quickstart/trace/fs/config +11 -0
  53. package/quickstart/trace/fs/description +1 -0
  54. package/quickstart/trace/fs/index +0 -0
  55. package/quickstart/trace/fs/info/exclude +11 -0
  56. package/quickstart/trace/fs/objects/1b/1f956f306fe2c66611dd9e280e7fd2dce1ecda +0 -0
  57. package/quickstart/trace/fs/objects/1c/c77eaef0608e088283cb62876b80be9473edce +0 -0
  58. package/quickstart/trace/fs/objects/20/0c1f7a4be93b037ff27cea57853a738fcbf744 +0 -0
  59. package/quickstart/trace/fs/objects/20/28a902b70fff80fe02a8aaaf30005fd91bafb7 +0 -0
  60. package/quickstart/trace/fs/objects/34/522daa46d0e3f8db09ec7d874fa6f05cbcdabf +0 -0
  61. package/quickstart/trace/fs/objects/48/a7bdafdaae1c53a354c7bd06c740719e1cb255 +0 -0
  62. package/quickstart/trace/fs/objects/80/13b71377c59b728861dd8eec39a919387a4b81 +0 -0
  63. package/quickstart/trace/fs/objects/9f/db6ace4a4410e535a2aef50e653e46a4c3694a +0 -0
  64. package/quickstart/trace/fs/objects/a4/371e26fb48bece62891a54e4374895fba69f0d +0 -0
  65. package/quickstart/trace/fs/objects/d3/3088476a7db5aef2dfd6c839d16d2b2ccaca40 +0 -0
  66. package/quickstart/trace/manifest.json +43 -0
  67. package/quickstart/trace/redactions.json +4 -0
  68. package/quickstart/trace/shell-frames.jsonl +0 -0
@@ -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
- 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();
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
- const tls = await setupTlsCapture({
247
- args,
248
- out,
249
- ...(interceptedHosts ? { recordedHosts: interceptedHosts } : {}),
250
- writer: trace,
251
- runDir: ctx.runDir,
252
- writes,
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
- exitCode = await runChild(launch.command, launch.args, { ...process.env, ...launch.env }, workspace.dir, args.bool('json'));
257
+ return await replayRestored(args, out, ctx, { plan, interceptedHosts, workspace });
387
258
  }
388
- catch (err) {
389
- // A listening proxy keeps node's event loop alive, so a throw here printed the error and then
390
- // hung forever. The agent that was recorded is not always installed where the recording is
391
- // replayed that is half the point of a trace and `spawn <agent> ENOENT` is what that looks
392
- // like from in here.
393
- //
394
- // The fork path below already does this, and `orca record` does it too. This was the third of
395
- // the three and the one still missing it, which is visible from the outside as `orca replay`
396
- // being the one command of the three that has to be killed.
397
- //
398
- // The replay's own trace is sealed rather than abandoned, for the reason the fork gives: a run
399
- // that failed to launch is still a run someone will want to read, and an unsealed trace has no
400
- // `ended_at` or `integrity`, so `verifyIntegrity` calls it tampered with rather than unfinished.
401
- await proxy.close().catch(() => undefined);
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
- finally {
412
- // In a finally because the whole justification for restoring over the working tree is that it
413
- // is put back a throw between here and there would leave someone's checkout holding a
414
- // recorded run's files.
415
- await workspace.release();
416
- await tls.ca?.dispose();
417
- }
418
- await writes.drain();
419
- const stats = proxy.stats();
420
- await proxy.close();
421
- for (const d of divergences) {
422
- out.warn('divergence', { seq: d.seq, level: d.level, detail: d.detail });
423
- }
424
- // A halted replay is a failed replay even if the harness chose to exit 0 on the error. The exit
425
- // code is what a script reads, so it has to reflect what happened rather than what the agent
426
- // decided to do about it — and the trace records the same verdict for the same reason.
427
- const verdict = exitCode === 0 && unmatched.length > 0 ? 1 : exitCode;
428
- if (trace) {
429
- // What the replay discovered rather than repeated: these calls really happened just now, and
430
- // exist nowhere in the parent.
431
- if (mcp)
432
- await drainMcpFrames(mcp, trace, () => 0, 0);
433
- await trace.append({
434
- type: 'run.end',
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
- exit_code: verdict,
439
- agent_exit_code: exitCode,
440
- matched: stats.matchedExact,
441
- divergences: stats.divergences,
442
- unmatched: stats.unmatched,
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
- await trace.close(verdict);
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
- const forkInterceptedHosts = recordedTlsHosts(ctx.events);
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
- const adapter = defaultAdapters().get(ctx.manifest.adapter.id);
673
- const launch = await adapter.prepare({
674
- runId: writer.runId,
675
- cwd: worktree,
676
- proxyUrl: proxy.url,
677
- runDir: writer.runDir,
678
- userArgs: driveArgs(adapter, ctx, out),
679
- env: process.env,
680
- });
681
- if (proxy.tls) {
682
- await trustRunCa(writer, proxy.tls, proxy.url, launch.env, out);
683
- }
684
- if (mcp) {
685
- pointAtMcpConfig(launch.env, mcp.configPath);
686
- // Same as record: the path has to reach the harness the way the harness actually reads it, or
687
- // a replay re-instruments a config nothing opens and every recorded MCP call goes unmatched.
688
- const mcpArgs = adapter.mcpConfigArgs?.(mcp.configPath);
689
- if (mcpArgs !== undefined)
690
- launch.args = [...mcpArgs, ...launch.args];
691
- }
692
- let exitCode;
693
- try {
694
- exitCode = await runChild(launch.command, launch.args, { ...process.env, ...launch.env }, worktree, args.bool('json'));
695
- }
696
- catch (err) {
697
- // The same two failures `orca record` had. A listening proxy keeps Node's event loop alive, so
698
- // a throw here printed the error and then hung; and a fork that mints a certificate authority
699
- // must not leave the private key on disk when it dies. The trace is sealed either way, because
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
- await recorded.restore(initial.fsTree, ctx.cwd);
800
- return {
801
- dir: ctx.cwd,
802
- release: async () => {
803
- await safety.restore(before.tree, ctx.cwd);
804
- // Only after the restore succeeded. This store holds the only copy of your working tree as
805
- // it was before the replay overwrote it, so removing it on the failure path would delete the
806
- // thing the failure means you still need — better a directory to clean up by hand than the
807
- // one that had your uncommitted work in it.
808
- //
809
- // Left behind on every run until now: a whole workspace per `orca replay`, in a directory
810
- // `orca gc` deliberately will not touch because it only reclaims scratch worktrees belonging
811
- // to forks. Owning its lifetime here is the fix; teaching gc to delete unknown temp
812
- // directories is how gc ends up removing someone's work.
813
- await rm(scratch, { recursive: true, force: true });
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
- * `quietStdout` is for `--json`: orca's stdout is the result document there, so the replayed
821
- * agent's own output moves to stderr rather than landing in the middle of it. stdin and stderr
822
- * stay inherited, so a harness that prompts still can.
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(), quietStdout = false) {
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: quietStdout ? ['inherit', 'pipe', 'inherit'] : 'inherit',
985
+ stdio: agentStdout === 'inherit'
986
+ ? 'inherit'
987
+ : ['inherit', agentStdout === 'ignore' ? 'ignore' : 'pipe', 'inherit'],
833
988
  });
834
- child.stdout?.pipe(process.stderr);
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
  });