@rivet-dev/agentos-core 0.2.22 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +9 -9
  2. package/dist/agent-os.d.ts +71 -198
  3. package/dist/agent-os.js +480 -1206
  4. package/dist/agentos-package.d.ts +15 -4
  5. package/dist/agentos-package.js +1 -22
  6. package/dist/cron/cron-manager.js +0 -27
  7. package/dist/cron/types.d.ts +0 -12
  8. package/dist/default-software/coreutils.aospkg +4 -0
  9. package/dist/default-software/default-software.json +10 -0
  10. package/dist/default-software/diffutils.aospkg +0 -0
  11. package/dist/default-software/findutils.aospkg +0 -0
  12. package/dist/default-software/gawk.aospkg +0 -0
  13. package/dist/default-software/grep.aospkg +0 -0
  14. package/dist/default-software/gzip.aospkg +0 -0
  15. package/dist/default-software/sed.aospkg +0 -0
  16. package/dist/default-software/tar.aospkg +0 -0
  17. package/dist/default-software.d.ts +6 -5
  18. package/dist/default-software.js +17 -6
  19. package/dist/generated/VmLimitsConfig.d.ts +0 -2
  20. package/dist/host-dir-mount.d.ts +1 -1
  21. package/dist/host-dir-mount.js +1 -1
  22. package/dist/language-execution.d.ts +6 -2
  23. package/dist/options-schema.d.ts +10 -48
  24. package/dist/options-schema.js +10 -47
  25. package/dist/output-replay.d.ts +18 -0
  26. package/dist/output-replay.js +75 -0
  27. package/dist/packages.d.ts +0 -9
  28. package/dist/packages.js +0 -39
  29. package/dist/process-forest.d.ts +7 -0
  30. package/dist/process-forest.js +24 -0
  31. package/dist/runtime-compat.d.ts +13 -0
  32. package/dist/runtime-compat.js +3 -9
  33. package/dist/sidecar/binary.d.ts +1 -1
  34. package/dist/sidecar/binary.js +4 -4
  35. package/dist/sidecar/native-process-client.d.ts +1 -1
  36. package/dist/sidecar/rpc-client.d.ts +12 -18
  37. package/dist/sidecar/rpc-client.js +385 -188
  38. package/dist/types.d.ts +3 -11
  39. package/package.json +9 -45
  40. package/dist/generated/AcpLimitsConfig.d.ts +0 -20
  41. package/dist/generated/AcpLimitsConfig.js +0 -2
  42. package/dist/json-rpc.d.ts +0 -50
  43. package/dist/json-rpc.js +0 -22
  44. package/dist/session-api.d.ts +0 -180
  45. package/dist/session-api.js +0 -1
  46. package/dist/sidecar/agentos-protocol.d.ts +0 -719
  47. package/dist/sidecar/agentos-protocol.js +0 -1331
@@ -1,12 +1,94 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants as osConstants } from "node:os";
3
3
  import { posix as posixPath } from "node:path";
4
+ import { SidecarRejectedError } from "@rivet-dev/agentos-runtime-core/sidecar-errors";
4
5
  const SYNTHETIC_PID_BASE = 1_000_000;
5
6
  const MISSING_EXIT_EVENT_GRACE_MS = 500;
6
7
  const PROTECTED_READ_ONLY_GUEST_ROOTS = ["/etc/agentos"];
7
8
  const TRAILING_OUTPUT_DRAIN_INTERVAL_MS = 10;
8
9
  const TRAILING_OUTPUT_DRAIN_MAX_MS = 250;
9
10
  const TRAILING_OUTPUT_DRAIN_QUIET_TURNS = 2;
11
+ const EXEC_TERMINATION_CONFIRMATION_MS = 30_000;
12
+ // Termination proof must remain observable even after an ordinary wait failed.
13
+ // Weak keys keep this private adapter from retaining completed public process handles.
14
+ const execTermination = new WeakMap();
15
+ async function killAndConfirmManagedProcess(proc) {
16
+ let confirmationTimer;
17
+ try {
18
+ const control = execTermination.get(proc);
19
+ const exit = control ? control.wait() : proc.wait();
20
+ // Normalize synchronous and asynchronous signal failures, without delaying exit
21
+ // observation behind a slow acknowledgement or an in-flight launch.
22
+ const kill = Promise.resolve().then(() => control ? control.kill() : proc.kill(9));
23
+ const confirmed = await Promise.race([
24
+ exit.then(() => true),
25
+ // A late deterministic rejection proves there was no admitted process.
26
+ kill
27
+ .then(() => (control?.needsCleanup() === false ? 0 : exit))
28
+ .then(() => true),
29
+ new Promise((resolve) => {
30
+ confirmationTimer = setTimeout(() => resolve(false), EXEC_TERMINATION_CONFIRMATION_MS);
31
+ }),
32
+ ]).catch((error) => {
33
+ throw new Error(`termination_failed: process ${proc.pid} could not confirm exit after SIGKILL: ${String(error)}`);
34
+ });
35
+ if (!confirmed) {
36
+ throw new Error(`termination_failed: process ${proc.pid} did not confirm exit within ${EXEC_TERMINATION_CONFIRMATION_MS}ms after SIGKILL`);
37
+ }
38
+ }
39
+ finally {
40
+ if (confirmationTimer !== undefined) {
41
+ clearTimeout(confirmationTimer);
42
+ }
43
+ }
44
+ }
45
+ /** @internal Shared completion policy for shell-string and structured-argv execution. */
46
+ export async function runManagedProcessToCompletion(proc, prepareStdin, deadlineMs) {
47
+ const run = async () => {
48
+ await prepareStdin();
49
+ return proc.wait();
50
+ };
51
+ if (deadlineMs === undefined) {
52
+ try {
53
+ return await run();
54
+ }
55
+ catch (error) {
56
+ if (proc.exitCode === null &&
57
+ execTermination.get(proc)?.needsCleanup() !== false) {
58
+ await killAndConfirmManagedProcess(proc);
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+ const timedOut = Symbol("execution timeout");
64
+ let timer;
65
+ const deadline = new Promise((resolve) => {
66
+ timer = setTimeout(() => resolve(timedOut), Math.max(0, deadlineMs - Date.now()));
67
+ });
68
+ try {
69
+ let result;
70
+ try {
71
+ result = await Promise.race([run(), deadline]);
72
+ }
73
+ catch (error) {
74
+ if (proc.exitCode === null &&
75
+ execTermination.get(proc)?.needsCleanup() !== false) {
76
+ await killAndConfirmManagedProcess(proc);
77
+ }
78
+ throw error;
79
+ }
80
+ if (result !== timedOut) {
81
+ return result;
82
+ }
83
+ await killAndConfirmManagedProcess(proc);
84
+ throw new Error(`timeout: process ${proc.pid} exceeded its execution deadline and was stopped`);
85
+ }
86
+ finally {
87
+ if (timer !== undefined) {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+ }
10
92
  function shouldLogStructuredSidecarEvent(name) {
11
93
  const normalized = name.toLowerCase();
12
94
  return (normalized === "limit_warning" ||
@@ -217,8 +299,8 @@ export class NativeSidecarKernelProxy {
217
299
  permissions;
218
300
  commandPermissions;
219
301
  loopbackExemptPorts;
220
- // Mutable: runtime `linkSoftware` appends via `registerLinkedPackage` so
221
- // later mount reconfigures resend linked packages too, not just boot ones.
302
+ // Boot packages are resent when mounts change. Runtime-linked packages are
303
+ // retained by the sidecar and are not mirrored in this client.
222
304
  packages;
223
305
  packagesMountAt;
224
306
  hostFunctionShimCommands;
@@ -284,15 +366,9 @@ export class NativeSidecarKernelProxy {
284
366
  this.commandDrivers.set(name, "wasmvm");
285
367
  }
286
368
  }
287
- /**
288
- * Record a runtime-linked package (`linkSoftware`) so mount reconfigures
289
- * resend it. Rust `configure_vm` rebuilds the whole VM configuration from
290
- * each payload, so a linked package omitted here would be silently
291
- * unprojected from `/opt/agentos` by the next `mountFs`/`unmountFs`.
292
- */
293
- registerLinkedPackage(descriptor) {
294
- if (!this.packages.some((pkg) => pkg.path === descriptor.path)) {
295
- this.packages.push({ path: descriptor.path });
369
+ unregisterCommandGuestPaths(commandNames) {
370
+ for (const name of commandNames) {
371
+ this.commandDrivers.delete(name);
296
372
  }
297
373
  }
298
374
  async dispose() {
@@ -301,27 +377,41 @@ export class NativeSidecarKernelProxy {
301
377
  }
302
378
  this.disposed = true;
303
379
  this.eventPumpAbortController.abort();
304
- await this.mountReconfigurePromise?.catch(() => { });
305
380
  const liveProcesses = [...this.trackedProcesses.values()].filter((entry) => entry.exitCode === null);
306
- await Promise.allSettled(liveProcesses.map((entry) => this.signalProcess(entry, 15)));
307
- await this.client.disposeVm(this.session, this.vm).catch(() => { });
381
+ // Wake ordinary waits before any teardown RPC can stall. Termination is
382
+ // still unconfirmed; disposing a VM cannot supply a guest exit status.
308
383
  for (const entry of liveProcesses) {
309
- if (entry.exitCode === null) {
310
- // The sidecar dispose path already performs TERM/KILL escalation for any
311
- // guest executions that are still live. Resolve local waiters eagerly so
312
- // VM teardown does not hang on killed ACP adapter processes that never
313
- // surface a terminal process_exited event back to the JS bridge.
314
- this.finishProcess(entry, 143);
315
- }
384
+ this.handleBackgroundProcessError(entry, new Error("VM disposed before process exit was observed"));
316
385
  }
386
+ await this.mountReconfigurePromise?.catch((error) => {
387
+ console.error("[agentOS] mount reconfiguration failed during disposal", error);
388
+ });
389
+ await Promise.all(liveProcesses
390
+ .filter((entry) => !entry.launchRejected)
391
+ .map((entry) => this.signalProcess(entry, 15).catch((error) => {
392
+ console.error(`[agentOS] process ${entry.pid} teardown signal failed`, error);
393
+ })));
394
+ let disposalFailed = false;
395
+ let disposalError;
396
+ await this.client.disposeVm(this.session, this.vm).catch((error) => {
397
+ disposalFailed = true;
398
+ disposalError = error;
399
+ console.error("[agentOS] VM disposal failed; guest termination is unconfirmed", error);
400
+ });
317
401
  // Only tear down the shared sidecar process when this proxy owns it. VMs
318
402
  // leased from an `AgentOsSidecar` handle share one process, which is
319
403
  // disposed when the handle is disposed.
320
404
  if (this.ownsClient) {
321
- await this.client.dispose().catch(() => { });
405
+ await this.client.dispose().catch((error) => {
406
+ console.error("[agentOS] owned sidecar disposal failed", error);
407
+ });
322
408
  }
323
- await this.eventPump.catch(() => { });
324
- await this.onDispose?.().catch(() => { });
409
+ await this.eventPump.catch((error) => {
410
+ console.error("[agentOS] process event pump teardown failed", error);
411
+ });
412
+ await this.onDispose?.().catch((error) => {
413
+ console.error("[agentOS] VM disposal callback failed", error);
414
+ });
325
415
  // Drop all per-VM tracking state so a disposed proxy retains nothing.
326
416
  for (const entry of this.trackedProcesses.values()) {
327
417
  entry.onStdout.clear();
@@ -332,6 +422,8 @@ export class NativeSidecarKernelProxy {
332
422
  this.signalStates.clear();
333
423
  this.signalRefreshes.clear();
334
424
  this.localMounts.length = 0;
425
+ if (disposalFailed)
426
+ throw disposalError;
335
427
  }
336
428
  /** Test-only snapshot of the per-VM tracking collection sizes. */
337
429
  __trackingSizesForTest() {
@@ -357,33 +449,24 @@ export class NativeSidecarKernelProxy {
357
449
  }
358
450
  const stdoutChunks = [];
359
451
  const stderrChunks = [];
452
+ const deadlineMs = typeof options?.timeout === "number" &&
453
+ Number.isFinite(options.timeout) &&
454
+ options.timeout >= 0
455
+ ? Date.now() + options.timeout
456
+ : undefined;
360
457
  const effectiveCwd = options?.cwd ?? this.defaultExecCwd ?? this.cwd;
361
458
  const parsedCommand = parseSimpleExecCommand(command);
362
459
  const runAndCapture = async (proc, stdinOverride, readExitCode) => {
363
- if (stdinOverride !== undefined) {
364
- await proc.writeStdin(stdinOverride);
365
- }
366
- else if (options?.stdin !== undefined) {
367
- await proc.writeStdin(options.stdin);
368
- }
369
- // `kernel.exec()` is a non-interactive run-to-completion API: when the
370
- // caller does not opt into a streaming stdin handle, the guest process
371
- // should observe EOF after any provided input so commands like
372
- // `node -e ...` do not linger behind an inherited open stdin pipe.
373
- await proc.closeStdin();
374
- const waitPromise = proc.wait();
375
- const shellExitCode = typeof options?.timeout === "number"
376
- ? await new Promise((resolve) => {
377
- const timer = setTimeout(() => {
378
- proc.kill(9);
379
- void proc.wait().then(resolve);
380
- }, options.timeout);
381
- void waitPromise.then((code) => {
382
- clearTimeout(timer);
383
- resolve(code);
384
- });
385
- })
386
- : await waitPromise;
460
+ const shellExitCode = await runManagedProcessToCompletion(proc, async () => {
461
+ if (stdinOverride !== undefined) {
462
+ await proc.writeStdin(stdinOverride);
463
+ }
464
+ else if (options?.stdin !== undefined) {
465
+ await proc.writeStdin(options.stdin);
466
+ }
467
+ // Non-interactive runs must observe EOF after any provided input.
468
+ await proc.closeStdin();
469
+ }, deadlineMs);
387
470
  const exitCode = readExitCode
388
471
  ? await readExitCode().catch(() => shellExitCode)
389
472
  : shellExitCode;
@@ -435,25 +518,19 @@ export class NativeSidecarKernelProxy {
435
518
  async execArgv(command, args = [], options) {
436
519
  const stdoutChunks = [];
437
520
  const stderrChunks = [];
521
+ const deadlineMs = typeof options?.timeout === "number" &&
522
+ Number.isFinite(options.timeout) &&
523
+ options.timeout >= 0
524
+ ? Date.now() + options.timeout
525
+ : undefined;
438
526
  const effectiveCwd = options?.cwd ?? this.defaultExecCwd ?? this.cwd;
439
527
  const runAndCapture = async (proc) => {
440
- if (options?.stdin !== undefined) {
441
- await proc.writeStdin(options.stdin);
442
- }
443
- await proc.closeStdin();
444
- const waitPromise = proc.wait();
445
- const exitCode = typeof options?.timeout === "number"
446
- ? await new Promise((resolve) => {
447
- const timer = setTimeout(() => {
448
- proc.kill(9);
449
- void proc.wait().then(resolve);
450
- }, options.timeout);
451
- void waitPromise.then((code) => {
452
- clearTimeout(timer);
453
- resolve(code);
454
- });
455
- })
456
- : await waitPromise;
528
+ const exitCode = await runManagedProcessToCompletion(proc, async () => {
529
+ if (options?.stdin !== undefined) {
530
+ await proc.writeStdin(options.stdin);
531
+ }
532
+ await proc.closeStdin();
533
+ }, deadlineMs);
457
534
  await drainTrailingProcessOutputTurn();
458
535
  return {
459
536
  exitCode,
@@ -478,6 +555,9 @@ export class NativeSidecarKernelProxy {
478
555
  }));
479
556
  }
480
557
  spawn(command, args, options) {
558
+ if (this.disposed || this.pumpError) {
559
+ throw new Error(`not_ready: cannot spawn on a ${this.disposed ? "disposed VM" : "failed process event stream"}`);
560
+ }
481
561
  let spawnCommand = command;
482
562
  let spawnArgs = [...args];
483
563
  const shellOption = options?.shell;
@@ -492,11 +572,9 @@ export class NativeSidecarKernelProxy {
492
572
  }
493
573
  const pid = this.nextSyntheticPid++;
494
574
  const processId = `proc-${pid}`;
495
- let resolveWait;
496
- let rejectWait;
497
- const waitPromise = new Promise((resolve, reject) => {
498
- resolveWait = resolve;
499
- rejectWait = reject;
575
+ let confirmExit;
576
+ const confirmedExit = new Promise((resolve) => {
577
+ confirmExit = resolve;
500
578
  });
501
579
  const entry = {
502
580
  pid,
@@ -513,15 +591,17 @@ export class NativeSidecarKernelProxy {
513
591
  ...(options?.env ?? {}),
514
592
  ...(options?.streamStdin ? { AGENTOS_KEEP_STDIN_OPEN: "1" } : {}),
515
593
  },
594
+ retainOutput: options?.retainOutput ?? false,
516
595
  startTime: Date.now(),
517
596
  exitTime: null,
518
597
  hostPid: null,
519
598
  exitCode: null,
520
599
  started: false,
600
+ launchRejected: false,
521
601
  startPromise: Promise.resolve(),
522
- waitPromise,
523
- resolveWait,
524
- rejectWait,
602
+ waitError: null,
603
+ wakeWait: null,
604
+ confirmExit,
525
605
  onStdout: new Set(options?.onStdout ? [options.onStdout] : []),
526
606
  onStderr: new Set(options?.onStderr ? [options.onStderr] : []),
527
607
  pendingStdin: options?.stdin === undefined ? [] : [options.stdin],
@@ -537,33 +617,46 @@ export class NativeSidecarKernelProxy {
537
617
  this.updateTrackedProcessSnapshot(entry);
538
618
  const proc = {
539
619
  pid,
620
+ processId,
540
621
  writeStdin: (data) => {
541
622
  if (entry.exitCode !== null) {
542
623
  return Promise.resolve();
543
624
  }
625
+ if (entry.waitError)
626
+ return Promise.reject(entry.waitError);
544
627
  entry.pendingStdin.push(data);
545
628
  return this.flushPendingStdin(entry).catch((error) => {
546
629
  this.handleBackgroundProcessError(entry, error);
630
+ throw error;
547
631
  });
548
632
  },
549
633
  closeStdin: () => {
634
+ if (entry.waitError)
635
+ return Promise.reject(entry.waitError);
550
636
  entry.pendingCloseStdin = true;
551
637
  return this.closeTrackedStdin(entry).catch((error) => {
552
638
  this.handleBackgroundProcessError(entry, error);
639
+ throw error;
553
640
  });
554
641
  },
555
642
  kill: (signal = 15) => {
556
- if (entry.exitCode !== null) {
643
+ if (entry.exitCode !== null || entry.launchRejected) {
557
644
  return;
558
645
  }
559
646
  entry.pendingKillSignal = signal;
560
- void entry.startPromise.then(async () => {
561
- if (entry.exitCode !== null || entry.pendingKillSignal === null) {
647
+ void entry.startPromise
648
+ .then(async () => {
649
+ if (entry.exitCode !== null ||
650
+ entry.launchRejected ||
651
+ entry.pendingKillSignal === null) {
562
652
  return;
563
653
  }
564
654
  const pendingSignal = entry.pendingKillSignal;
565
655
  entry.pendingKillSignal = null;
566
656
  await this.signalProcess(entry, pendingSignal);
657
+ })
658
+ .catch((error) => {
659
+ this.handleBackgroundProcessError(entry, error);
567
660
  });
568
661
  },
569
662
  wait: async () => {
@@ -575,13 +668,29 @@ export class NativeSidecarKernelProxy {
575
668
  return entry.exitCode;
576
669
  },
577
670
  };
671
+ execTermination.set(proc, {
672
+ wait: () => confirmedExit,
673
+ needsCleanup: () => !entry.launchRejected,
674
+ kill: async () => {
675
+ await entry.startPromise;
676
+ if (entry.launchRejected)
677
+ return;
678
+ // A failed ordinary wait does not close this independent control path.
679
+ await this.signalProcess(entry, 9);
680
+ },
681
+ });
578
682
  entry.startPromise = this.startTrackedProcess(entry).catch((error) => {
579
- const normalized = error instanceof Error ? error : new Error(String(error));
580
- const stderr = new TextEncoder().encode(`${normalized.message}\n`);
581
- for (const handler of entry.onStderr) {
582
- handler(stderr);
683
+ entry.launchRejected ||=
684
+ !entry.started && error instanceof SidecarRejectedError;
685
+ this.handleBackgroundProcessError(entry, error);
686
+ if (entry.launchRejected) {
687
+ // Rejected admission is not a process exit. Release this non-process's
688
+ // tracking while retaining the rejection on the returned handle.
689
+ this.processes.delete(entry.pid);
690
+ void this.releaseProcessTrackingAfterDrain(entry).catch((error) => {
691
+ console.error(`[agentOS] rejected process ${entry.pid} cleanup failed`, error);
692
+ });
583
693
  }
584
- this.finishProcess(entry, 1);
585
694
  });
586
695
  return proc;
587
696
  }
@@ -919,6 +1028,7 @@ export class NativeSidecarKernelProxy {
919
1028
  },
920
1029
  cwd: options?.cwd,
921
1030
  streamStdin: true,
1031
+ retainOutput: true,
922
1032
  onStdout: (chunk) => {
923
1033
  const sanitized = sanitizeNativeShellOutput(chunk);
924
1034
  if (!sanitized) {
@@ -1040,9 +1150,12 @@ export class NativeSidecarKernelProxy {
1040
1150
  shell.kill();
1041
1151
  throw error;
1042
1152
  }
1043
- void shell.wait().finally(() => {
1044
- cleanup();
1045
- });
1153
+ void shell
1154
+ .wait()
1155
+ .catch((error) => {
1156
+ console.error(`[agentOS] terminal ${shell.pid} wait failed`, error);
1157
+ })
1158
+ .finally(cleanup);
1046
1159
  return shell.pid;
1047
1160
  }
1048
1161
  readFile(path) {
@@ -1211,10 +1324,8 @@ export class NativeSidecarKernelProxy {
1211
1324
  if (this.disposed) {
1212
1325
  return;
1213
1326
  }
1214
- // Rust `configure_vm` rebuilds the whole VM configuration from this
1215
- // payload, so resend the boot packages and host-function shim commands too.
1216
- // Omitting them here strips the `/opt/agentos` projections and host-function
1217
- // shims from the VM as a side effect of a runtime mount change.
1327
+ // Re-send boot packages and binding shims. The sidecar preserves
1328
+ // runtime-linked packages independently of this client payload.
1218
1329
  await this.client.configureVm(this.session, this.vm, {
1219
1330
  mounts: this.desiredSidecarMounts(),
1220
1331
  permissions: this.permissions,
@@ -1241,6 +1352,10 @@ export class NativeSidecarKernelProxy {
1241
1352
  }
1242
1353
  }
1243
1354
  snapshotProcesses() {
1355
+ return this.snapshotProcessTopology().map((record) => record.info);
1356
+ }
1357
+ /** Internal, host-side topology; never part of the guest or public DTO wire. */
1358
+ snapshotProcessTopology() {
1244
1359
  return this.buildProcessSnapshot();
1245
1360
  }
1246
1361
  findListener(request) {
@@ -1358,17 +1473,25 @@ export class NativeSidecarKernelProxy {
1358
1473
  }
1359
1474
  async startTrackedProcess(entry) {
1360
1475
  await this.waitForMountReconfigure();
1476
+ if (this.disposed || this.pumpError) {
1477
+ entry.launchRejected = true; // No Execute request was sent.
1478
+ throw new Error("not_ready: process launch cancelled before admission because the VM is unavailable");
1479
+ }
1361
1480
  const started = await this.client.execute(this.session, this.vm, {
1362
1481
  processId: entry.processId,
1363
1482
  command: entry.command,
1364
1483
  args: entry.args,
1365
1484
  env: entry.env,
1366
1485
  cwd: entry.cwd,
1486
+ retainOutput: entry.retainOutput,
1367
1487
  });
1368
1488
  entry.hostPid = started.pid;
1369
1489
  entry.started = true;
1490
+ entry.wakeWait?.();
1370
1491
  this.updateTrackedProcessSnapshot(entry);
1371
- void this.refreshProcessSnapshot().catch(() => { });
1492
+ void this.refreshProcessSnapshot().catch((error) => {
1493
+ console.error("[agentOS] post-launch process snapshot failed", error);
1494
+ });
1372
1495
  // Signal metadata is advisory and must not hold process startup behind a
1373
1496
  // second sidecar RPC. Very short hostFunction commands can finish while the
1374
1497
  // original execute response is still in flight; synchronously refreshing
@@ -1400,14 +1523,19 @@ export class NativeSidecarKernelProxy {
1400
1523
  continue;
1401
1524
  }
1402
1525
  entry.outputGeneration += 1;
1403
- void this.refreshProcessSnapshot().catch(() => { });
1526
+ void this.refreshProcessSnapshot().catch((error) => {
1527
+ console.error("[agentOS] process output snapshot refresh failed", error);
1528
+ });
1404
1529
  this.scheduleSignalStateRefresh(entry);
1405
1530
  const chunk = event.payload.chunk;
1406
1531
  const listeners = event.payload.channel === "stdout"
1407
1532
  ? entry.onStdout
1408
1533
  : entry.onStderr;
1409
1534
  for (const listener of listeners) {
1410
- listener(chunk);
1535
+ listener(chunk, {
1536
+ sequence: event.payload.sequence,
1537
+ timestampMs: event.payload.timestamp_ms,
1538
+ });
1411
1539
  }
1412
1540
  continue;
1413
1541
  }
@@ -1416,7 +1544,10 @@ export class NativeSidecarKernelProxy {
1416
1544
  if (!entry) {
1417
1545
  continue;
1418
1546
  }
1419
- void this.refreshProcessSnapshot().catch(() => { });
1547
+ void this.refreshProcessSnapshot().catch((error) => {
1548
+ console.error("[agentOS] post-exit process snapshot failed", error);
1549
+ });
1550
+ entry.confirmExit(event.payload.exit_code);
1420
1551
  this.finishProcess(entry, event.payload.exit_code);
1421
1552
  continue;
1422
1553
  }
@@ -1430,20 +1561,25 @@ export class NativeSidecarKernelProxy {
1430
1561
  }
1431
1562
  this.pumpError =
1432
1563
  error instanceof Error ? error : new Error(String(error));
1564
+ console.error("[agentOS] process event stream failed", this.pumpError);
1433
1565
  for (const entry of this.trackedProcesses.values()) {
1434
1566
  if (entry.exitCode !== null) {
1435
1567
  continue;
1436
1568
  }
1437
- const stderr = new TextEncoder().encode(`${this.pumpError.message}\n`);
1438
- for (const listener of entry.onStderr) {
1439
- listener(stderr);
1440
- }
1441
- this.finishProcess(entry, 1);
1569
+ this.handleBackgroundProcessError(entry, this.pumpError);
1442
1570
  }
1443
1571
  return;
1444
1572
  }
1445
1573
  }
1446
1574
  }
1575
+ /** Internal: replay carries the same authoritative exit proof as a live event. */
1576
+ reconcileReplayExit(processId, exitCode) {
1577
+ const entry = this.trackedProcessesById.get(processId);
1578
+ if (!entry)
1579
+ return;
1580
+ entry.confirmExit(exitCode);
1581
+ this.finishProcess(entry, exitCode);
1582
+ }
1447
1583
  finishProcess(entry, exitCode) {
1448
1584
  if (entry.exitCode !== null) {
1449
1585
  return;
@@ -1451,13 +1587,15 @@ export class NativeSidecarKernelProxy {
1451
1587
  entry.exitCode = exitCode;
1452
1588
  entry.exitTime = Date.now();
1453
1589
  this.updateTrackedProcessSnapshot(entry);
1454
- entry.resolveWait(exitCode);
1590
+ entry.wakeWait?.();
1455
1591
  // Release per-process tracking now that the process has terminated so these
1456
1592
  // maps/Sets don't grow without bound. Defer the release until trailing
1457
1593
  // output has drained: `wait()`'s drain and late `process_output` events
1458
1594
  // still need the entry + its listeners during the drain window. The exited
1459
1595
  // record lives on in `processes` for listing.
1460
- void this.releaseProcessTrackingAfterDrain(entry);
1596
+ void this.releaseProcessTrackingAfterDrain(entry).catch((error) => {
1597
+ console.error(`[agentOS] process ${entry.pid} tracking cleanup failed`, error);
1598
+ });
1461
1599
  }
1462
1600
  async releaseProcessTrackingAfterDrain(entry) {
1463
1601
  try {
@@ -1476,35 +1614,67 @@ export class NativeSidecarKernelProxy {
1476
1614
  if (entry.exitCode !== null) {
1477
1615
  return Promise.resolve(entry.exitCode);
1478
1616
  }
1617
+ if (entry.waitError) {
1618
+ return Promise.reject(entry.waitError);
1619
+ }
1479
1620
  if (entry.waitWithFallbackPromise !== null) {
1480
1621
  return entry.waitWithFallbackPromise;
1481
1622
  }
1482
1623
  entry.waitWithFallbackPromise = (async () => {
1483
- await entry.startPromise.catch(() => { });
1484
- while (entry.exitCode === null && !this.disposed) {
1485
- const maybeExit = await Promise.race([
1486
- entry.waitPromise.then((exitCode) => exitCode),
1487
- new Promise((resolve) => setTimeout(() => resolve(null), 50)),
1488
- ]);
1489
- if (maybeExit !== null) {
1490
- return maybeExit;
1624
+ while (entry.exitCode === null && !entry.waitError && !this.disposed) {
1625
+ // One cancellable wake per process, rather than another Promise.race
1626
+ // reaction retained on an unresolved exit promise every 50ms.
1627
+ await new Promise((resolve) => {
1628
+ const timer = setTimeout(() => {
1629
+ entry.wakeWait = null;
1630
+ resolve();
1631
+ }, 50);
1632
+ entry.wakeWait = () => {
1633
+ clearTimeout(timer);
1634
+ entry.wakeWait = null;
1635
+ resolve();
1636
+ };
1637
+ });
1638
+ if (entry.exitCode !== null || entry.waitError || this.disposed) {
1639
+ break;
1640
+ }
1641
+ if (!entry.started) {
1642
+ continue;
1491
1643
  }
1492
1644
  try {
1493
- await this.refreshProcessSnapshot();
1645
+ // Metadata cannot hold terminal delivery hostage. Keep a wake armed
1646
+ // while the coalesced snapshot RPC is in flight, too.
1647
+ let wake;
1648
+ const terminal = new Promise((resolve) => {
1649
+ wake = resolve;
1650
+ entry.wakeWait = resolve;
1651
+ });
1652
+ try {
1653
+ await Promise.race([this.refreshProcessSnapshot(), terminal]);
1654
+ }
1655
+ finally {
1656
+ if (entry.wakeWait === wake)
1657
+ entry.wakeWait = null;
1658
+ }
1659
+ if (entry.exitCode !== null || entry.waitError || this.disposed)
1660
+ break;
1494
1661
  const snapshot = this.sidecarProcessSnapshot.find((candidate) => candidate.processId === entry.processId);
1495
1662
  if (snapshot?.status === "exited") {
1496
- this.finishProcess(entry, snapshot.exitCode ?? 0);
1663
+ if (snapshot.exitCode === undefined || snapshot.exitCode === null) {
1664
+ this.handleBackgroundProcessError(entry, new Error("exited snapshot has no exit status"));
1665
+ }
1666
+ else {
1667
+ entry.confirmExit(snapshot.exitCode);
1668
+ this.finishProcess(entry, snapshot.exitCode);
1669
+ }
1497
1670
  break;
1498
1671
  }
1499
1672
  if (snapshot) {
1500
1673
  entry.hostExitObservedAt = null;
1501
1674
  continue;
1502
1675
  }
1503
- // Fast guest processes can exit before the sidecar emits a
1504
- // `process_exited` event. Once a started process disappears from the
1505
- // authoritative VM snapshot for a full grace window, treat it as
1506
- // reaped even if the `pid` returned at launch was only a kernel/shared
1507
- // runtime identifier rather than a probeable host PID.
1676
+ // A missing snapshot can mean reaping, lost metadata, or a launch race.
1677
+ // None provides an exit status. Bound the grace period and fail closed.
1508
1678
  if (!snapshot) {
1509
1679
  const now = Date.now();
1510
1680
  if (entry.hostExitObservedAt === null) {
@@ -1512,31 +1682,26 @@ export class NativeSidecarKernelProxy {
1512
1682
  continue;
1513
1683
  }
1514
1684
  if (now - entry.hostExitObservedAt >= MISSING_EXIT_EVENT_GRACE_MS) {
1515
- this.finishProcess(entry, 0);
1685
+ this.handleBackgroundProcessError(entry, new Error("process disappeared from snapshots without an exit event"));
1516
1686
  break;
1517
1687
  }
1518
1688
  }
1519
1689
  }
1520
- catch {
1521
- // Fall back to the next wait interval if the sidecar snapshot query fails.
1690
+ catch (error) {
1691
+ this.handleBackgroundProcessError(entry, error);
1522
1692
  }
1523
1693
  }
1524
- return entry.waitPromise;
1694
+ if (entry.exitCode !== null)
1695
+ return entry.exitCode;
1696
+ throw (entry.waitError ??
1697
+ new Error(`termination_failed: process ${entry.pid} VM disposed before exit was observed`));
1525
1698
  })().finally(() => {
1526
1699
  entry.waitWithFallbackPromise = null;
1527
1700
  });
1528
1701
  return entry.waitWithFallbackPromise;
1529
1702
  }
1530
1703
  async signalProcess(entry, signal) {
1531
- try {
1532
- await this.client.killProcess(this.session, this.vm, entry.processId, toSidecarSignalName(signal));
1533
- }
1534
- catch (error) {
1535
- if (isNoSuchProcessError(error) || isUnknownVmError(error)) {
1536
- return;
1537
- }
1538
- throw error;
1539
- }
1704
+ await this.client.killProcess(this.session, this.vm, entry.processId, toSidecarSignalName(signal));
1540
1705
  }
1541
1706
  flushPendingStdin(entry) {
1542
1707
  if (entry.stdinFlushPromise !== null) {
@@ -1544,6 +1709,8 @@ export class NativeSidecarKernelProxy {
1544
1709
  }
1545
1710
  entry.stdinFlushPromise = entry.startPromise
1546
1711
  .then(async () => {
1712
+ if (entry.waitError)
1713
+ throw entry.waitError;
1547
1714
  if (entry.exitCode !== null) {
1548
1715
  return;
1549
1716
  }
@@ -1556,14 +1723,17 @@ export class NativeSidecarKernelProxy {
1556
1723
  }
1557
1724
  })
1558
1725
  .catch((error) => {
1559
- if (isNoSuchProcessError(error) || isUnknownVmError(error)) {
1726
+ if (entry.exitCode !== null &&
1727
+ (isNoSuchProcessError(error) || isUnknownVmError(error))) {
1560
1728
  return;
1561
1729
  }
1562
1730
  throw error;
1563
1731
  })
1564
1732
  .finally(() => {
1565
1733
  entry.stdinFlushPromise = null;
1566
- if (entry.pendingStdin.length > 0 && entry.exitCode === null) {
1734
+ if (entry.pendingStdin.length > 0 &&
1735
+ entry.exitCode === null &&
1736
+ !entry.waitError) {
1567
1737
  void this.flushPendingStdin(entry).catch((error) => {
1568
1738
  this.handleBackgroundProcessError(entry, error);
1569
1739
  });
@@ -1573,6 +1743,8 @@ export class NativeSidecarKernelProxy {
1573
1743
  }
1574
1744
  async closeTrackedStdin(entry) {
1575
1745
  await entry.startPromise;
1746
+ if (entry.waitError)
1747
+ throw entry.waitError;
1576
1748
  await this.flushPendingStdin(entry);
1577
1749
  if (entry.exitCode !== null || !entry.pendingCloseStdin) {
1578
1750
  return;
@@ -1582,43 +1754,48 @@ export class NativeSidecarKernelProxy {
1582
1754
  await this.client.closeStdin(this.session, this.vm, entry.processId);
1583
1755
  }
1584
1756
  catch (error) {
1585
- if (isNoSuchProcessError(error) || isUnknownVmError(error)) {
1757
+ if (entry.exitCode !== null &&
1758
+ (isNoSuchProcessError(error) || isUnknownVmError(error))) {
1586
1759
  return;
1587
1760
  }
1588
1761
  throw error;
1589
1762
  }
1590
1763
  }
1591
1764
  handleBackgroundProcessError(entry, error) {
1592
- if (this.disposed ||
1593
- isNoSuchProcessError(error) ||
1594
- isUnknownVmError(error)) {
1595
- return;
1596
- }
1597
1765
  if (entry.exitCode !== null) {
1598
- this.recordCompletedProcessError(entry, error);
1766
+ // An I/O failure after exit must never rewrite the guest's real status.
1767
+ if (!isNoSuchProcessError(error) && !isUnknownVmError(error)) {
1768
+ this.emitBackgroundProcessError(entry, error);
1769
+ }
1599
1770
  return;
1600
1771
  }
1601
- this.emitBackgroundProcessError(entry, error);
1602
- this.finishProcess(entry, 1);
1603
- }
1604
- recordCompletedProcessError(entry, error) {
1605
- if (this.disposed ||
1606
- isNoSuchProcessError(error) ||
1607
- isUnknownVmError(error)) {
1608
- return entry.exitCode ?? 1;
1772
+ if (entry.waitError) {
1773
+ if (error !== entry.waitError && error !== entry.waitError.cause) {
1774
+ this.emitBackgroundProcessError(entry, error);
1775
+ }
1776
+ return;
1609
1777
  }
1610
- this.emitBackgroundProcessError(entry, error);
1611
- entry.exitCode =
1612
- entry.exitCode === null || entry.exitCode === 0 ? 1 : entry.exitCode;
1613
- entry.exitTime ??= Date.now();
1614
- this.updateTrackedProcessSnapshot(entry);
1615
- return entry.exitCode;
1778
+ const cause = error instanceof Error ? error : new Error(String(error));
1779
+ entry.waitError = entry.launchRejected
1780
+ ? cause
1781
+ : new Error(`termination_failed: process ${entry.pid} failed before exit was confirmed: ${cause.message}`, { cause });
1782
+ entry.pendingStdin.length = 0;
1783
+ entry.pendingCloseStdin = false;
1784
+ entry.wakeWait?.();
1785
+ // Keep the entry and signal routing alive: failure is not proof of exit.
1786
+ this.emitBackgroundProcessError(entry, entry.waitError);
1616
1787
  }
1617
1788
  emitBackgroundProcessError(entry, error) {
1618
1789
  const normalized = error instanceof Error ? error : new Error(String(error));
1790
+ console.error(`[agentOS] process ${entry.pid} operation failed`, normalized);
1619
1791
  const stderr = new TextEncoder().encode(`${normalized.message}\n`);
1620
1792
  for (const handler of entry.onStderr) {
1621
- handler(stderr);
1793
+ try {
1794
+ handler(stderr);
1795
+ }
1796
+ catch (callbackError) {
1797
+ console.error(`[agentOS] process ${entry.pid} stderr callback failed`, callbackError);
1798
+ }
1622
1799
  }
1623
1800
  }
1624
1801
  async readdirRecursiveLocal(fs, path, maxDepth) {
@@ -1826,8 +2003,12 @@ export class NativeSidecarKernelProxy {
1826
2003
  };
1827
2004
  }
1828
2005
  buildProcessSnapshot() {
1829
- void this.refreshProcessSnapshot().catch(() => { });
1830
- const processMap = new Map();
2006
+ void this.refreshProcessSnapshot().catch((error) => {
2007
+ console.warn("agentOS process snapshot refresh failed", error);
2008
+ });
2009
+ const records = [];
2010
+ const seenProcessIds = new Set();
2011
+ const trackedKeys = new Set();
1831
2012
  const displayPidByKernelPid = new Map();
1832
2013
  for (const entry of this.sidecarProcessSnapshot) {
1833
2014
  const tracked = this.trackedProcessesById.get(entry.processId);
@@ -1837,7 +2018,7 @@ export class NativeSidecarKernelProxy {
1837
2018
  }
1838
2019
  for (const entry of this.sidecarProcessSnapshot) {
1839
2020
  const tracked = this.trackedProcessesById.get(entry.processId);
1840
- const displayPid = displayPidByKernelPid.get(entry.pid) ?? entry.pid;
2021
+ const displayPid = tracked?.pid ?? entry.pid;
1841
2022
  const displayPpid = displayPidByKernelPid.get(entry.ppid) ?? entry.ppid;
1842
2023
  const displayPgid = displayPidByKernelPid.get(entry.pgid) ?? entry.pgid;
1843
2024
  const displaySid = displayPidByKernelPid.get(entry.sid) ?? entry.sid;
@@ -1846,51 +2027,67 @@ export class NativeSidecarKernelProxy {
1846
2027
  this.observedProcessStartTimes.get(processKey) ??
1847
2028
  Date.now();
1848
2029
  this.observedProcessStartTimes.set(processKey, startTime);
1849
- processMap.set(displayPid, {
1850
- pid: displayPid,
1851
- ppid: displayPpid,
1852
- pgid: displayPgid,
1853
- sid: displaySid,
1854
- driver: tracked?.driver ?? entry.driver,
1855
- command: tracked?.command ?? entry.command,
1856
- args: tracked?.args ?? entry.args,
1857
- cwd: tracked?.cwd ?? entry.cwd,
1858
- status: tracked?.exitCode !== null
1859
- ? "exited"
1860
- : tracked
1861
- ? "running"
2030
+ seenProcessIds.add(entry.processId);
2031
+ if (tracked)
2032
+ trackedKeys.add(`kernel:${entry.pid}`);
2033
+ records.push({
2034
+ key: `kernel:${entry.pid}`,
2035
+ parentKey: `kernel:${entry.ppid}`,
2036
+ info: {
2037
+ pid: displayPid,
2038
+ ppid: displayPpid,
2039
+ pgid: displayPgid,
2040
+ sid: displaySid,
2041
+ driver: tracked?.driver ?? entry.driver,
2042
+ command: tracked?.command ?? entry.command,
2043
+ args: tracked?.args ?? entry.args,
2044
+ cwd: tracked?.cwd ?? entry.cwd,
2045
+ status: tracked
2046
+ ? tracked.exitCode !== null
2047
+ ? "exited"
2048
+ : "running"
1862
2049
  : entry.status === "exited"
1863
2050
  ? "exited"
1864
2051
  : "running",
1865
- exitCode: tracked?.exitCode ?? entry.exitCode,
1866
- startTime,
1867
- exitTime: tracked?.exitTime ?? null,
2052
+ exitCode: tracked?.exitCode ?? entry.exitCode,
2053
+ startTime,
2054
+ exitTime: tracked?.exitTime ?? null,
2055
+ },
1868
2056
  });
1869
2057
  }
1870
2058
  for (const entry of this.trackedProcesses.values()) {
1871
- if (processMap.has(entry.pid)) {
2059
+ if (seenProcessIds.has(entry.processId)) {
1872
2060
  continue;
1873
2061
  }
1874
- processMap.set(entry.pid, {
1875
- pid: entry.pid,
1876
- ppid: 0,
1877
- pgid: entry.pid,
1878
- sid: entry.pid,
1879
- driver: entry.driver,
1880
- command: entry.command,
1881
- args: entry.args,
1882
- cwd: entry.cwd,
1883
- status: entry.exitCode === null ? "running" : "exited",
1884
- exitCode: entry.exitCode,
1885
- startTime: entry.startTime,
1886
- exitTime: entry.exitTime,
2062
+ trackedKeys.add(`tracked:${entry.pid}`);
2063
+ records.push({
2064
+ key: `tracked:${entry.pid}`,
2065
+ parentKey: null,
2066
+ info: {
2067
+ pid: entry.pid,
2068
+ ppid: 0,
2069
+ pgid: entry.pid,
2070
+ sid: entry.pid,
2071
+ driver: entry.driver,
2072
+ command: entry.command,
2073
+ args: entry.args,
2074
+ cwd: entry.cwd,
2075
+ status: entry.exitCode === null ? "running" : "exited",
2076
+ exitCode: entry.exitCode,
2077
+ startTime: entry.startTime,
2078
+ exitTime: entry.exitTime,
2079
+ },
1887
2080
  });
1888
2081
  }
1889
2082
  this.processes.clear();
1890
- for (const process of processMap.values()) {
1891
- this.processes.set(process.pid, process);
2083
+ for (const { info, key } of records) {
2084
+ // The legacy numeric lookup cannot represent collisions. Prefer the
2085
+ // tracked process for control lookup; tree/list preserve every record.
2086
+ if (!this.processes.has(info.pid) || trackedKeys.has(key)) {
2087
+ this.processes.set(info.pid, info);
2088
+ }
1892
2089
  }
1893
- return [...processMap.values()].sort((left, right) => left.pid - right.pid);
2090
+ return records.sort((left, right) => left.info.pid - right.info.pid);
1894
2091
  }
1895
2092
  dispatchRead(path, handler, includeLocalMounts = true) {
1896
2093
  const local = includeLocalMounts ? this.resolveLocalMount(path) : null;
@@ -2129,7 +2326,7 @@ export class AgentOsSidecarClient {
2129
2326
  this.createId = options.createId ?? randomUUID;
2130
2327
  this.now = options.now ?? Date.now;
2131
2328
  }
2132
- /** Open a physical sidecar ownership scope, not a durable ACP session. */
2329
+ /** Open a physical sidecar ownership scope for one client connection. */
2133
2330
  async createOwnershipSession(options = {}) {
2134
2331
  this.assertActive();
2135
2332
  const sessionId = this.createId();